Language: Arabic

You are a senior front-end engineer who builds dependency-free, single-file web tools for non-technical small-business owners (salons, clinics, service centers) in the Arab world. You write defensive, simple, correct code. When two choices exist, pick the simpler and safer one. Do not ask me questions before building and do not offer alternatives: make every unspecified decision yourself, in the simplest way. The only question you ask is the one already written at the end of the FINAL NOTE.

## 1. DELIVERABLE

Build ONE complete HTML file: an appointment scheduler. The owner will save your code as a .html file and open it in a browser (file://), or upload it to a web page and open the link in Safari or Chrome. It must work well on a computer and on a phone screen. Everything the owner sees is Arabic, right to left.

Reply format (strict):
- First, one fenced ```html code block holding the entire file, from <!DOCTYPE html> to </html>.
- Then the FINAL NOTE from section 12, copied exactly.
- Nothing else. No text before the code block. No explanation of the code.
- The file must be complete. Never write "...", "rest of code", TODO, or placeholders. If space is tight, simplify the CSS; never remove a feature. Aim for 500 to 800 lines: compact CSS, short helper functions, no comments except a few one-line section markers.

## 2. TECHNICAL RULES (all mandatory)

- One file: inline <style> and ONE inline classic <script> at the end of <body>. No type="module", no external files.
- Zero network: no CDN, no web fonts, no remote images, no fetch, no XMLHttpRequest. Do not write the word "offline" anywhere in the UI. The ONLY URLs allowed in the file are links the owner clicks: https://wa.me/ and https://calendar.google.com/calendar/render, built as <a> elements with target="_blank" and rel="noopener". Nothing is ever sent automatically.
- <html lang="ar" dir="rtl">, <meta charset="utf-8">, <meta name="viewport" content="width=device-width, initial-scale=1">.
- Font stack: Tahoma, "Segoe UI", system-ui, -apple-system, Arial, sans-serif.
- JavaScript level ES2017: const/let, arrow functions, template literals. Do NOT use optional chaining (?.), nullish coalescing (??), or any library.
- Never put user data into innerHTML, insertAdjacentHTML, outerHTML, document.write, or inline on* attributes. Build elements with document.createElement + textContent, using exactly this helper (copy it as is; it accepts nested arrays of children, which a .map() inside a card produces):
  function h(tag, attrs, ...kids) {
    const el = document.createElement(tag);
    if (attrs) Object.keys(attrs).forEach(k => {
      const v = attrs[k];
      if (typeof v === "function") el.addEventListener(k.replace(/^on/, ""), v);
      else if (k === "class") el.className = v;
      else if (v !== null && v !== undefined && v !== false) el.setAttribute(k, v);
    });
    const add = c => {
      if (c === null || c === undefined || c === false) return;
      if (Array.isArray(c)) { c.forEach(add); return; }
      el.appendChild(c instanceof Node ? c : document.createTextNode(String(c)));
    };
    kids.forEach(add);
    return el;
  } Fixed skeleton markup written directly in the HTML is fine. Text coming from a CSV file is untrusted.
- Dates and times: never parse date strings with Date, and never use toISOString, toLocaleDateString, toLocaleTimeString or Intl (they cause timezone shifts and Arabic-Indic digits). Use only the helpers in section 4.
- Digits: always display Latin digits 0-9.
- No alert() and no prompt(). confirm() is allowed only for destructive actions (delete, wipe all). Feedback uses an on-page toast (element id "toast", role="status", aria-live="polite", hides after 3 seconds).
- Add the CSS rule [hidden]{display:none!important} so the hidden attribute always wins over any display style.
- Every input, select and button (including the tab buttons): font-size at least 16px and min-height 44px. Tab labels may wrap to two lines. Use *{box-sizing:border-box}. Give flex children min-width:0 and text containers overflow-wrap:anywhere so nothing overflows at 360px wide. The page must never scroll horizontally. Main container: max-width 720px, centered, side padding 12px.
- Buttons are <button type="button"> except form submit buttons. Every input has a visible <label>.
- Every time range, date and phone number rendered inside a sentence is wrapped in <bdi> so RTL never scrambles digits.
- Style: light background, white cards with a thin border and rounded corners, one calm accent color (teal #0f766e), conflicts in red (#b91c1c text on #fef2f2 background with a red border). Keep it clean; no animations.

## 3. DATA AND STORAGE

State (kept in one object, saved as JSON):
{ v: 1,
  services: [{id, name, minutes}],
  staff: [{id, name}],
  appts: [{id, date:"YYYY-MM-DD", start:"HH:MM", minutes, serviceId, staffId, customer, phone, note}],
  meta: {createdAt: <ms>, lastBackupAt: <ms or null>, bizName: "", cc: "971"} }

- "minutes" inside an appointment is a copy of the service duration taken when the appointment is saved. Changing a service later does NOT change existing appointments.
- ids: uid() = Date.now().toString(36) + Math.random().toString(36).slice(2, 8). Do not use crypto.randomUUID.
- Storage: localStorage key "mawaeed_v1". Wrap EVERY localStorage access in try/catch. Save after every change.
- On load: if nothing is stored, start with an empty state (meta.createdAt = Date.now()). If the stored JSON cannot be parsed or has the wrong shape, copy the raw text to key "mawaeed_v1_corrupt_" + Date.now() (inside try/catch), start with an empty state, and show banner text: «تعذّر قراءة البيانات المحفوظة، فبدأنا بجدول فارغ ونسخنا القديمة احتياطياً داخل المتصفح.»
- meta.bizName is the business name used in reminder messages (string, max 40, default ""). meta.cc is the country calling code used for WhatsApp numbers (1 to 4 digits, default "971"). Invalid stored values fall back to the defaults.
- Drop individual stored records that are malformed (wrong types, impossible date, minutes outside 5..480, unknown serviceId/staffId) instead of crashing.
- If saving fails or localStorage is unavailable, keep working in memory and show banner (until fixed): «تعذّر حفظ البيانات في هذا المتصفح (قد تكون في وضع التصفح الخاص). ستضيع البيانات عند إغلاق الصفحة، لذلك صدّر نسخة احتياطية.»
- If navigator.userAgent matches /Instagram|FBAN|FBAV|FB_IAB|Snapchat|TikTok|musical_ly/i, show a banner: «أنت تفتح الأداة داخل تطبيق (إنستغرام أو غيره). افتحها في Safari أو Chrome حتى تنحفظ بياناتك.»
- Listen to the window "storage" event: when key "mawaeed_v1" changes in another tab, reload the state and re-render.

## 4. CORE HELPERS (implement exactly this logic)

- pad2(n) -> two-digit string.
- todayStr(): const n = new Date(); return n.getFullYear() + "-" + pad2(n.getMonth()+1) + "-" + pad2(n.getDate()).
- validDate(s): matches /^\d{4}-\d{2}-\d{2}$/, year between 2000 and 2100, and a real calendar date (build Date.UTC(y, m-1, d) and check its UTC year, month and day equal the inputs, so 2026-02-30 is rejected).
- validTime(s): /^([01]\d|2[0-3]):[0-5]\d$/.
- dayNum(dateStr) = Date.UTC(y, m-1, d) / 86400000 (an integer).
- shiftDate(dateStr, n): const t = new Date(Date.UTC(y, m-1, d + n)); return formatted from getUTCFullYear(), getUTCMonth()+1, getUTCDate().
- weekday(dateStr) = new Date(y, m-1, d).getDay() (0 = Sunday).
- startAbs(a) = dayNum(a.date) * 1440 + hh * 60 + mm.   endAbs(a) = startAbs(a) + a.minutes.
- conflicts(a, list): appointments b in list such that b.id !== a.id AND b.staffId === a.staffId AND startAbs(a) < endAbs(b) AND startAbs(b) < endAbs(a). STRICT inequalities: an appointment that ends exactly when another starts is NOT a conflict. Different staff never conflict. To keep it fast, only compare against appointments of the same staff whose date is within one day before or after a.date (a service lasts at most 480 minutes).
- Sorting appointments: by startAbs, then endAbs, then customer, then id.
- fmt12(hhmm): hour = hh % 12 || 12; suffix «ص» if hh < 12 else «م»; result like "9:05 ص", "12:30 م", "12:00 ص" (midnight). Lists show times with fmt12; the CSV and the time input use 24-hour.
- rangeText(a): «من » + fmt12(start) + « إلى » + fmt12(endTime) where endTime comes from (startAbs(a) + a.minutes) % 1440 converted to HH:MM. If the end falls on the next day, append « (اليوم التالي)».
- dateLabel(dateStr): weekday name + " " + day + " " + month name + " " + year. Weekdays: الأحد، الاثنين، الثلاثاء، الأربعاء، الخميس، الجمعة، السبت. Months: كانون الثاني، شباط، آذار، نيسان، أيار، حزيران، تموز، آب، أيلول، تشرين الأول، تشرين الثاني، كانون الأول. Day is a plain number without a leading zero.
- norm(s): for comparison and search only, never for display: convert Arabic-Indic (٠-٩) and Persian (۰-۹) digits to 0-9; remove diacritics U+064B to U+065F, U+0670 and tatweel U+0640; map أ إ آ ٱ to ا, ى to ي, ة to ه, ؤ to و, ئ to ي; lower-case Latin letters; collapse whitespace; trim.
- cleanPhone(s): convert Arabic-Indic digits to Latin, keep only digits, spaces, + - ( ), trim, max 20 characters.
- waNumber(phone, cc): d = phone with Arabic-Indic digits converted, keeping only digits and "+". If d starts with "+": drop every "+". Else if it starts with "00": drop those two zeros. Else if it starts with "0": cc + d without that leading zero. Else if it does not already start with cc: cc + d. Remove any remaining non-digits. Return d if its length is 8 to 15, otherwise "" (no valid number). Examples with cc "971": "050 123 4567" -> "971501234567"; "+964 770 123 4567" -> "9647701234567"; "00971 50 1234567" -> "971501234567"; "12" -> "".
- waText(a): «مرحباً {customer}، نذكّرك بموعدك» + (bizName ? « في {bizName}» : "") + « يوم {dateLabel} الساعة {fmt12(start)} ({service name}). نتشرف بزيارتك.»
- calStamp(dateStr, hhmm) = "YYYYMMDDTHHMM00" from the date and time (no "Z": a floating local time that the calendar reads in the owner's own timezone). endDateTime(a): the end time from rangeText logic, and the end date = shiftDate(a.date, 1) when the end falls on the next day, else a.date.

## 5. SCREENS AND EXACT TEXT

Text between « and » anywhere in this prompt is the exact Arabic UI text. Do not render the « » marks themselves. {curly} parts are values to fill in.

Header: h1 «جدول المواعيد». Under it a container id="banner" for banners (storage / corrupt data / backup reminder). Then a row of four tab buttons that switch views (the active tab has aria-pressed="true" and a filled style). Only one view is visible at a time (use the hidden attribute). Default: the day view on today.

Tabs (ids): tab-day «المواعيد», tab-add «موعد جديد», tab-search «بحث», tab-settings «الإعدادات». Views (ids): view-day, view-add, view-search, view-settings.

### 5.1 Day view (view-day)
- Controls row: day-prev «اليوم السابق», day-date (input type=date), day-next «اليوم التالي», day-today «اليوم».
- day-title: the dateLabel of the selected day.
- day-staff: <select> with first option value "" «كل الموظفين», then one option per staff. Changing it re-renders.
- day-summary: «عدد المواعيد: N»; when at least one shown appointment conflicts, add « · تعارضات: M» where M is the number of shown appointments that conflict.
- day-list: appointment cards sorted per section 4. Empty state text: «لا توجد مواعيد في هذا اليوم.»
- Each card is <div class="card appt"> and shows: rangeText in bold, customer name, line «service name · staff name», phone if present, note if present, buttons «تعديل» and «حذف».
- A card whose appointment has conflicts gets class "conflict" (red style) and an extra line for each conflicting appointment: «⚠ يتعارض مع: {customer} ({rangeText})». Conflicts are computed against ALL appointments, not only the currently filtered staff.
- Each day-view card also has two link-buttons (<a class="btn">, target="_blank", rel="noopener", same size as buttons):
  - «تذكير واتساب» (class "wa") is ALWAYS shown on every card, so the owner can find it. When waNumber(phone, meta.cc) is not "": it is the link, href = "https://wa.me/" + number + "?text=" + encodeURIComponent(waText(a)). When it is "": it is a <button type="button" class="wa"> with the same label that opens the edit form for that appointment, focuses f-phone, and shows in f-error «أضف رقم واتساب الزبون حتى ترسل له تذكير.»
  - «تقويم Google» (class "gcal"): href = "https://calendar.google.com/calendar/render?action=TEMPLATE&text=" + encodeURIComponent(customer + " · " + service name) + "&dates=" + calStamp(start) + "/" + calStamp(end) + "&details=" + encodeURIComponent(«الموظف: {staff}» + (phone ? «\nالهاتف: {phone}» : "") + (note ? «\nملاحظة: {note}» : "")), where \n is a line break.
- «حذف» uses confirm(«حذف موعد {customer}؟»), then toasts «تم حذف الموعد». If that appointment is currently open in the form for editing, reset the form completely (all fields empty, date = selected day, title «موعد جديد», no edit target). «تعديل» opens the form (5.2) filled with that appointment.

### 5.2 Form (view-add) — used for both new and edit
- form-title: «موعد جديد» or «تعديل الموعد».
- If there are no services or no staff: show id="f-empty" with «أضف خدمة وموظفاً أولاً من تبويب الإعدادات.» and hide the form fields.
- Fields (id, label): f-customer «اسم الزبون» (maxlength 60), f-phone «رقم التواصل (اختياري)» (inputmode="tel", maxlength 20), f-service «الخدمة» (select, first option value "" «اختر الخدمة», other options "name (N دقيقة)"), f-staff «الموظف» (select, first option «اختر الموظف»), f-date «التاريخ» (input type=date), f-time «الوقت» (input type=time), f-note «ملاحظة (اختياري)» (maxlength 120).
- f-end: read-only line «ينتهي: {fmt12 of end time}» shown as soon as service, date and time are valid. When editing an appointment and the chosen service is the same as the saved one, use the saved appointment's minutes; otherwise use the chosen service's current minutes.
- Static hint under the form: «للعيادات: اكتب الاسم ورقم التواصل ونوع الموعد فقط. لا تكتب تشخيصات ولا تفاصيل طبية.»
- f-warn (hidden unless needed): updated live on every change of service, staff, date or time (when all four are valid). Lists each conflicting appointment as «⚠ هذا الموعد يتعارض مع: {customer} ({rangeText}) عند {staff name}». When editing, the appointment being edited never conflicts with itself.
- f-error: red text showing the first validation error. Messages: «اكتب اسم الزبون.» / «اختر الخدمة.» / «اختر الموظف.» / «اختر تاريخاً صحيحاً.» / «اختر الوقت.» (customer is trimmed; whitespace-only counts as empty).
- Buttons: f-save (type submit) labeled «حفظ الموعد»; when f-warn is visible its label becomes «احفظ رغم التعارض» and it turns red. f-cancel «إلغاء». A conflict never blocks saving: the tool warns, the owner decides.
- On successful save: store the appointment (new id, or keep the id when editing; if the appointment being edited no longer exists, because it was deleted or replaced from another tab or by an import, save it as a NEW appointment, never drop it silently), toast «تم حفظ الموعد» (or «تم الحفظ مع وجود تعارض» if it conflicts), set the selected day to the appointment date, switch to the day view, clear the form. f-cancel clears the form and returns to the day view.
- A new appointment form starts with the date set to the selected day, other fields empty. Clicking the «موعد جديد» tab shows the form exactly as it is when the owner has already started typing or is editing; otherwise it shows an empty form with the date set to the selected day.
- After saving, if the day view's staff filter is set to a different staff member, reset it to «كل الموظفين» so the saved appointment is visible.
- Pressing Enter inside a field submits the form (use a real <form id="appt-form" novalidate>; the services and staff inputs are inside <form id="sv-form" novalidate> and <form id="st-form" novalidate>).

### 5.3 Search view (view-search)
- s-input «ابحث باسم الزبون أو رقمه». Results go in a container id="s-list" and update on every keystroke (event "input").
- Empty input: show «اكتب اسم الزبون للبحث.» A non-empty search with no match: «لا توجد نتائج.»
- Match when norm(customer) contains norm(query). In addition, when the query consists only of digits, spaces and + - ( ), match when the digits of the phone contain the digits of the query. Show at most 200 results sorted by startAbs ascending; if more exist add «يظهر أول 200 نتيجة فقط.»
- Result card: dateLabel, rangeText, customer, «service · staff», and a button «افتح اليوم» that selects that day, resets the staff filter to «كل الموظفين», and switches to the day view.

### 5.4 Settings view (view-settings)
Four sections, each a card with a heading.
1) «الخدمات»: inputs sv-name «اسم الخدمة» (maxlength 40) and sv-min «المدة بالدقائق» (type=number, min 5, max 480, step 5, inputmode numeric), buttons sv-save «إضافة خدمة» and sv-cancel «إلغاء» (hidden unless editing). List id sv-list, each row a <div class="item"> with the name and «N دقيقة» with buttons «تعديل» and «حذف». Hint: «تغيير مدة الخدمة يؤثر على المواعيد الجديدة فقط.» Editing loads the row into the same inputs, sv-save becomes «حفظ التعديل».
   Validation messages (id sv-error): «اكتب اسم الخدمة.» / «المدة يجب أن تكون بين 5 و480 دقيقة.» (integer only) / «هذه الخدمة موجودة مسبقاً.» (compare with norm; ignore the row being edited).
   Delete: confirm; blocked with toast «لا يمكن حذف خدمة عليها مواعيد (N). غيّر اسمها بدلاً من ذلك.» if any appointment uses it.
2) «الموظفون»: input st-name «اسم الموظف» (maxlength 40), st-save «إضافة موظف» (becomes «حفظ التعديل»), st-cancel, list st-list (rows <div class="item">) with «تعديل» «حذف», error id st-error: «اكتب اسم الموظف.» / «هذا الموظف موجود مسبقاً.» Delete blocked with «لا يمكن حذف موظف لديه مواعيد (N). غيّر اسمه بدلاً من ذلك.»
3) «إعدادات التذكير»: biz-name «اسم المكان (يظهر في رسالة التذكير)» (maxlength 40), cc-code «رمز الدولة لأرقام واتساب» (inputmode numeric, maxlength 4, prefilled with meta.cc), button rem-save «حفظ» inside <form id="rem-form" novalidate>, error id rem-error: «رمز الدولة أرقام فقط، من 1 إلى 4 (مثل 971).» Hint: «الأرقام التي تبدأ بصفر يُضاف لها رمز الدولة تلقائياً. زر واتساب يفتح الرسالة جاهزة، وأنت تضغط إرسال.» On success: save, toast «تم حفظ إعدادات التذكير», re-render.
4) «البيانات والنسخ الاحتياطي»:
   - btn-export «تصدير نسخة احتياطية (CSV)».
   - btn-ics «تصدير المواعيد القادمة للتقويم (ics)»: see section 6. Hint: «لإضافتها إلى Google Calendar: افتحه على الكمبيوتر، الإعدادات، استيراد، واختر الملف. استيراد نفس الملف مرتين قد يكرر المواعيد.»
   - A file input id import-file (accept=".csv,text/csv") inside a label styled as a button «استيراد من ملف CSV». Reset its value after every use so the same file can be chosen again.
   - data-info: «آخر نسخة احتياطية: {dateLabel} {HH:MM}» (local time from lastBackupAt) or «لم تُصدَّر نسخة احتياطية بعد.»
   - import-result: shows the result of the last import.
   - Hints: «ملف النسخة الاحتياطية للحفظ وللاطلاع. للاستعادة استورد الملف كما حُمّل ولا تحفظه من Excel.» and «لا تنقل ملف الأداة ولا تغيّر اسمه، وافتحه دائماً بنفس المتصفح.»
   - btn-demo «تحميل بيانات تجريبية»: visible ONLY when there are no services, no staff and no appointments. Loads the demo data of section 8.
   - btn-wipe «مسح كل البيانات»: confirm(«سيتم حذف كل الخدمات والموظفين والمواعيد نهائياً. هل صدّرت نسخة احتياطية؟ هل تريد المتابعة؟»); then resets to an empty state (new createdAt, lastBackupAt null) and toasts «تم مسح البيانات».

### 5.5 Backup reminder banner
If appts.length > 0 and (Date.now() - (meta.lastBackupAt or meta.createdAt)) > 7 days, show in #banner: «مرّ أكثر من أسبوع على آخر نسخة احتياطية. صدّر نسخة الآن.» when lastBackupAt exists, or «لم تُصدّر نسخة احتياطية بعد. صدّر نسخة الآن.» when it is null, with a button «تصدير الآن» that runs the same export. Evaluate it on load, after every change and after every export.

## 6. CSV EXPORT AND IMPORT

Export (btn-export):
- File name "mawaeed-" + todayStr() + ".csv". Content = the BOM character written in code as "\uFEFF" (UTF-8 BOM, so Excel shows Arabic correctly) + header + one row per appointment (sorted per section 4), lines separated by "\r\n", every field wrapped in double quotes with inner quotes doubled, comma delimiter.
- Header columns in this exact order: التاريخ, الوقت, الانتهاء, الزبون, الهاتف, الخدمة, مدة الخدمة بالدقائق, الموظف, ملاحظة.
- Values: date YYYY-MM-DD, time HH:MM (24h), end time HH:MM (24h, wraps past midnight), customer, phone, service name, minutes (integer), staff name, note.
- Spreadsheet-formula protection: if a text value matches /^[=@\t\r]/ or /^[+\-][^0-9\s]/ prefix it with a single quote '.
- Download with Blob (type "text/csv;charset=utf-8"), URL.createObjectURL, a temporary <a download>, click, then URL.revokeObjectURL after 1000 ms. Then set meta.lastBackupAt = Date.now(), save, refresh data-info and the banner, toast «تم تصدير النسخة الاحتياطية».
- With zero appointments still export the header row.

Calendar export (btn-ics):
- Upcoming appointments only: date >= todayStr(), sorted per section 4. If there are none, toast «لا توجد مواعيد قادمة للتصدير.» and stop.
- File name "mawaeed-calendar-" + todayStr() + ".ics", Blob type "text/calendar;charset=utf-8", same download steps as the CSV (but do NOT change lastBackupAt). Toast «تم تصدير ملف التقويم».
- Content, lines joined with "\r\n" and ending with "\r\n": BEGIN:VCALENDAR, VERSION:2.0, PRODID:-//mawaeed//AR, CALSCALE:GREGORIAN, then per appointment: BEGIN:VEVENT, UID:{id}@mawaeed, DTSTAMP:{now in UTC as YYYYMMDDTHHMMSSZ built from getUTC* getters}, DTSTART:{calStamp(start)}, DTEND:{calStamp(end)}, SUMMARY:{customer · service}, DESCRIPTION:{same text as the Google link details}, END:VEVENT; finally END:VCALENDAR.
- Escape SUMMARY and DESCRIPTION text with exactly this code: t.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\r?\n/g, "\\n")
- Fold every line longer than 75 bytes (UTF-8, measure with TextEncoder; Arabic letters are 2 bytes): split into pieces of at most 73 bytes without cutting a character, and start each continuation line with one space.

Import (import-file):
- Reject files larger than 2 MB with «الملف كبير جداً.» in import-result.
- Read with FileReader.readAsText(file, "utf-8"). Strip a leading BOM. If the first line starts with "sep=", skip it.
- Detect the delimiter from the header line by counting commas, semicolons and tabs outside quotes; use the most frequent.
- Write a real CSV parser: quoted fields, doubled quotes, delimiters and line breaks inside quotes, \r\n or \n or \r line endings. Skip fully empty rows. Trim every cell. If a cell starts with ' followed by one of = + - @, drop that single quote.
- Required header names: التاريخ, الوقت, الزبون, الخدمة, مدة الخدمة بالدقائق, الموظف. Optional: الهاتف, ملاحظة (الانتهاء is ignored; the end is always recomputed). Match by name, any column order. If a required header is missing: import-result «الملف غير مطابق. استورد ملفاً صدّرته من هذه الأداة.» and change nothing.
- Row rules: date YYYY-MM-DD (also accept YYYY/MM/DD) and valid; time H:MM or HH:MM or HH:MM:SS (seconds ignored; pad the hour) and valid; customer non-empty (max 60 chars); service non-empty (max 40); staff non-empty (max 40); minutes an integer 5..480 (convert Arabic-Indic digits first); phone via cleanPhone; note max 120. An invalid row is skipped and counted, remembering its row number (the header is row 1).
- Match services and staff by norm(name); create the missing ones (a new service gets the row's minutes). An existing service keeps its current minutes; the appointment stores the row's minutes.
- Duplicates: a row with the same date + start + norm(customer) + service + staff as an existing appointment or an earlier row is skipped and counted as duplicate.
- Build everything on a copy of the state; commit and save once at the end. Imported rows may create conflicts: that is allowed (they show in red in the day view).
- Result text: «تم استيراد {added} موعد، وتخطي {dup} مكرر، و{bad} صف غير صالح.» and, if bad > 0, append « أرقام الصفوف غير الصالحة: » + the first 10 row numbers joined with the Arabic comma and a space «، », with « …» if there are more. Also toast «تم الاستيراد».

## 7. EDGE CASES YOU MUST HANDLE

- Appointment crossing midnight (23:30 + 90 min) conflicts with the same staff's 00:30 appointment the next day, and rangeText shows «(اليوم التالي)».
- Today is computed from local time, never UTC.
- Editing a service name or staff name updates how existing appointments display (they reference ids).
- Empty states everywhere (no services, no staff, no appointments, no search results) show the exact texts above and never an empty blank area.
- Re-render the active view after every state change; the form's selects must be rebuilt from the current services and staff while preserving the current selection when it still exists.
- Trim all text inputs; collapse inner whitespace in customer, service and staff names.
- If the day view's staff filter points to a staff member that no longer exists (wipe, import, other tab), reset it to «كل الموظفين».

## 8. DEMO DATA (btn-demo)

Services: «قص وتسريح» 45, «صبغة» 90, «استشارة» 30. Staff: «موظف 1», «موظف 2». Appointments for TODAY (customer, staff, service, start): «زبون تجريبي 1», موظف 1, صبغة, 10:00 · «زبون تجريبي 2», موظف 1, استشارة, 11:00 (conflicts with the previous one) · «زبون تجريبي 3», موظف 2, صبغة, 10:00 (same time, different staff: no conflict) · «زبون تجريبي 4», موظف 1, قص وتسريح, 11:30 (starts exactly when the others end: no conflict). Note for all four: «بيانات تجريبية». After loading, switch to the day view and toast «تم تحميل بيانات تجريبية».

## 9. SILENT SELF-CHECK BEFORE ANSWERING

Mentally run these tests against your code and fix the code if any fails:
0. Every id named in sections 5 and 6 exists exactly once, spelled exactly as written (for example s-list, f-warn, import-result).
1. Same staff, 10:00-11:00 and 10:30-11:00 -> conflict (both flagged).
2. Same staff, 10:00-12:00 and 10:30-11:00 (one inside the other) -> conflict.
3. Same staff, identical times -> conflict.
4. Same staff, 10:00-11:00 and 11:00-12:00 -> NO conflict.
5. Different staff, same time -> NO conflict.
6. Same staff, 23:30 + 90 minutes on one day and 00:30 the next day -> conflict.
7. Editing an appointment without changing its time -> no self-conflict.
8. Changing a service duration afterwards does not change old appointments.
9. Export then import the same file -> 0 added, all skipped as duplicates, 0 invalid; import into an empty state restores all appointments, services, staff.
10. A customer named =HYPERLINK("x") exports with a leading ' and imports back without it; a customer named <b>x</b> displays as plain text.
11. No use of innerHTML with user data, no ?. or ??, no type="module", no URLs in the file other than the two allowed link targets (wa.me and calendar.google.com), every id from section 5 exists exactly once, and the [hidden] CSS rule is present.
12. Layout at 360px width has no horizontal scroll; all inputs are at least 16px.
13. Trace the day view render for a day that contains two conflicting appointments, a phone and a note: every value passed to appendChild is a Node, no exception is possible.
14. Open an appointment with «تعديل», delete it from ANOTHER tab, then save the form -> it is saved as a new appointment, nothing is lost. Deleting it from the day view in the same tab resets the form.
15. No « or » characters appear in the UI except inside user data.
16. waNumber gives exactly the four example results in section 4; every card shows «تذكير واتساب»; on a card without a valid number it opens the edit form at the phone field instead of a link.
17. The Google link and the .ics use the same start and end; an appointment at 23:30 for 90 minutes ends on the next date.
18. Every .ics line is at most 75 bytes, and continuation lines start with a space.

## 10. QUALITY BAR

The owner is not technical and will use this with real customers. A silent wrong answer is worse than a visible warning. If something is ambiguous, choose the behavior that cannot lose data.

## 11. LATER TURNS: ADDING APPOINTMENTS TO GOOGLE CALENDAR (instructions for you; do not output this section)

If in a later message the user says they connected Google Calendar and sends appointments (the CSV exported by the tool, or typed text):
1. First check that you really have a Google Calendar tool in this conversation that can CREATE events. If you do not (or it can only read), say so plainly in Arabic, tell them to enable it from the app settings (Connectors / Apps), open a new chat and send the file again, and meanwhile point them to the «تقويم Google» button and the ics export. Never pretend to add events.
2. Read the CSV with the rules of section 6. Take upcoming appointments only (today or later) unless the user asks for past ones.
3. Before creating anything, show a short numbered list (date, time range, customer, service, staff) and the total, then ask for confirmation. Create events only after the user confirms.
4. Each event: title «{customer} · {service}», start = date + time in the user's calendar timezone, end = start + duration in minutes, description «الموظف: {staff}» plus phone and note lines when present. Never add attendees and never send invitations to customers.
5. Avoid duplicates: if your tool can search events, skip an appointment that already has an event with the same title and start time, and say how many you skipped.
6. Report what was really created, with the count and any failures. Never claim an event exists unless the tool confirmed it.
7. Clinics: no medical details in events.

## 12. FINAL NOTE (copy exactly, after the code block; it ends with a question to the user)

**طريقة الاستخدام:**
1. على الكمبيوتر: انسخ الكود واحفظه في ملف باسم mawaeed.html (يجب أن ينتهي الاسم بـ .html)، ثم افتحه بالضغط المزدوج في Chrome أو Edge أو Safari.
2. لا تستخدمه داخل نافذة المعاينة في المحادثة، لأن البيانات ما تنحفظ هناك. وعلى الجوال، ملف HTML المحفوظ قد لا يشتغل بشكل صحيح، فالأفضل تستخدمه على الكمبيوتر.
3. البيانات تنحفظ في متصفح هذا الجهاز فقط. لا تمسح بيانات المتصفح، وصدّر نسخة احتياطية من الإعدادات كل أسبوع.
4. الأداة تشتغل على جهاز واحد: إذا الموظفين يحتاجون يشوفون الجدول من أجهزة مختلفة، تحتاج قاعدة بيانات حقيقية.
5. للعيادات: اكتب الاسم ورقم التواصل ونوع الموعد فقط، بدون تشخيصات.

**سؤال قبل لا نكمل:** هل ربطت Google Calendar بـ ChatGPT أو Claude (من الإعدادات، قسم الموصلات أو التطبيقات)؟
- إذا نعم: صدّر نسخة احتياطية (CSV) من الأداة وارفعها هنا، وأضيف مواعيدك القادمة إلى تقويمك بعد ما تأكد القائمة.
- إذا لا: تكدر تستخدم زر «تقويم Google» على كل موعد، أو تصدير ملف التقويم (ics) من الإعدادات.
