/*  v3.0.2 — K9 Pulse, mobile-first surface (Direction 1 Editorial Calm).

    Wired against the REAL backend, no client-side mocks. Endpoints used:

      /api/auth/me                       — user, avatar, team, timezone
      /api/me/checkin                    — today's signed_in / signed_off state
      POST /api/me/checkin               — sign in
      POST /api/me/checkout              — sign off
      /api/today/kpis                    — weekly_hours, strikes, pending,
                                            active_blockers
      /api/pto                           — all PTOs (filter by user_id === me)
      /api/pto/balances                  — vacation balance per year
      POST /api/pto                      — create new PTO request
      /api/oncall/current?team=<team>    — who has the pager right now
      /api/oncall/active                 — all current shifts (find mine)
      /api/support/tickets?mine=1        — my open + closed tickets
      /api/support/tickets/stats         — open/in_progress/resolved counts
      POST /api/support/tickets          — create new ticket
      /api/audit?limit=15                — recent events (filtered to me)
      /api/auth/logout                   — sign out
*/

const { useState, useEffect, useMemo, useRef, useCallback } = React;

// ── helpers ──────────────────────────────────────────────────────────
const m_fmt12 = (iso) => {
  if (!iso) return null;
  try {
    const d = new Date(iso);
    if (isNaN(d.getTime())) return null;
    let h = d.getHours();
    const m = String(d.getMinutes()).padStart(2, '0');
    const ap = h >= 12 ? 'pm' : 'am';
    h = h % 12 || 12;
    return { h: String(h), m, ap };
  } catch (_) { return null; }
};
const m_today_str = () => new Date().toLocaleDateString([],
  { weekday: 'short', month: 'short', day: 'numeric' });
const m_greet = () => {
  const h = new Date().getHours();
  if (h < 12) return 'good morning';
  if (h < 18) return 'good afternoon';
  return 'good evening';
};
const m_initial = (name) => (name || '?').trim().charAt(0).toUpperCase();
const m_first   = (name) => (name || '').trim().split(/\s+/)[0];
const m_ago = (iso) => {
  if (!iso) return '';
  const d = new Date(iso); if (isNaN(d)) return '';
  const mins = Math.round((Date.now() - d.getTime()) / 60000);
  if (mins < 1)     return 'just now';
  if (mins < 60)    return `${mins}m ago`;
  if (mins < 1440)  return `${Math.round(mins/60)}h ago`;
  const days = Math.round(mins / 1440);
  if (days < 7)     return `${days}d ago`;
  return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
};
// FastAPI returns errors in two shapes:
//   { detail: "human string" }                       — HTTPException
//   { detail: [{ msg, type, loc, ... }, ...] }       — pydantic validation
// Stringifying the second shape gave the user "[object Object]" instead
// of a real message. This helper always produces something readable.
async function m_parseErr(r, fallback) {
  let j = null;
  try { j = await r.json(); } catch (_) {}
  const d = j && j.detail;
  if (!d) return fallback || `Request failed (${r.status})`;
  if (typeof d === 'string') return d;
  if (Array.isArray(d)) {
    return d.map(e => {
      if (typeof e === 'string') return e;
      const loc = Array.isArray(e?.loc) ? e.loc.filter(x => x !== 'body').join('.') : '';
      const msg = e?.msg || e?.message || '';
      return loc ? `${loc}: ${msg}` : msg;
    }).filter(Boolean).join(' · ');
  }
  if (typeof d === 'object') return d.msg || d.message || d.error || JSON.stringify(d);
  return String(d);
}

const m_dateShort = (iso) => {
  if (!iso) return '—';
  const d = new Date(iso); if (isNaN(d)) return '—';
  return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
};

// ── v3.7.0/v3.7.1 K9Bridge — native iOS plugin for biometric / Live Activity /
//   widget state. No-op on non-Capacitor surfaces (web, Android).
//
//   IMPORTANT (v3.7.1 fix): the app loads a REMOTE server.url, and Capacitor
//   only auto-populates window.Capacitor.Plugins.X for SPM-package plugins
//   (LocalNotifications etc). App-target custom plugins like K9Bridge must be
//   obtained via Capacitor.registerPlugin('K9Bridge'). The previous build read
//   Plugins.K9Bridge directly, which was always undefined → Face ID/Live
//   Activity/widget-sync all silently no-oped. We now resolve via registerPlugin
//   and treat a native "not implemented" rejection as 'missing' (so an absent
//   plugin can NEVER block sign-in — only an explicit OS auth failure does).
const K9 = (() => {
  let _plugin;            // undefined = unresolved, object = proxy, null = unavailable
  let _how = 'unresolved';
  const resolve = () => {
    if (_plugin !== undefined) return _plugin;
    const cap = (typeof window !== 'undefined') && window.Capacitor;
    if (!cap) { _plugin = null; _how = 'no-capacitor'; return _plugin; }
    if (cap.Plugins && cap.Plugins.K9Bridge) { _plugin = cap.Plugins.K9Bridge; _how = 'Plugins'; return _plugin; }
    if (typeof cap.registerPlugin === 'function') {
      try { _plugin = cap.registerPlugin('K9Bridge'); _how = 'registerPlugin'; return _plugin; }
      catch (_) { /* fall through */ }
    }
    _plugin = null; _how = 'unavailable';
    return _plugin;
  };
  // A registerPlugin() proxy forwards EVERY method to native, so it reaches
  // methods added in newer builds even when Capacitor.Plugins.K9Bridge is a
  // stale/partial object that only lists the original methods. Cache it.
  let _proxy;
  const proxyFor = () => {
    if (_proxy !== undefined) return _proxy;
    const cap = (typeof window !== 'undefined') && window.Capacitor;
    _proxy = (cap && typeof cap.registerPlugin === 'function')
      ? (() => { try { return cap.registerPlugin('K9Bridge'); } catch (_) { return null; } })()
      : null;
    return _proxy;
  };
  const safeCall = async (method, args) => {
    let p = resolve();
    // If the primary handle doesn't expose this method, fall through to the
    // registerPlugin proxy (forwards to native regardless of the JS method map).
    if (!p || typeof p[method] !== 'function') {
      const px = proxyFor();
      if (px && typeof px[method] === 'function') { p = px; _how += '+proxy'; }
    }
    if (!p || typeof p[method] !== 'function') return { ok: false, missing: true };
    try {
      const res = await p[method](args || {});
      return (res && typeof res === 'object') ? res : { ok: true };
    } catch (e) {
      const msg = String((e && e.message) || e || '');
      // Capacitor rejects with "not implemented" when the native plugin is
      // absent on this platform — treat as missing, never as a hard failure.
      const missing = /not implemented|unimplemented/i.test(msg);
      return { ok: false, missing, error: msg };
    }
  };
  return {
    available: () => !!resolve(),
    how: () => { resolve(); return _how; },
    biometricAvailable: () => safeCall('biometricAvailable'),
    biometricGate: (reason) => safeCall('biometricGate', { reason }),
    startLiveActivity: (state) => safeCall('startLiveActivity', state),
    updateLiveActivity: (patch) => safeCall('updateLiveActivity', patch),
    endLiveActivity: () => safeCall('endLiveActivity'),
    syncWidgetState: (state) => safeCall('syncWidgetState', state),
    // v4.1.0 — "Hey Pulse" native wake word (foreground, on-device).
    wakeWordAvailable: () => safeCall('wakeWordAvailable'),
    startWakeWord: () => safeCall('startWakeWord'),
    stopWakeWord: () => safeCall('stopWakeWord'),
    onWakeWord: (cb) => {
      const p = resolve();
      if (p && typeof p.addListener === 'function') {
        try { return p.addListener('wakeword', cb); } catch (_) {}
      }
      return null;
    },
    onWakeStatus: (cb) => {
      const p = resolve();
      if (p && typeof p.addListener === 'function') {
        try { return p.addListener('wakestatus', cb); } catch (_) {}
      }
      return null;
    },
  };
})();

// ── v4.3.0 i18n (English / العربية) ──────────────────────────────────
// t(s) returns the Arabic string for `s` when the app language is Arabic and a
// translation exists; otherwise it returns `s` unchanged. The English string IS
// the key, so any unwrapped/untranslated text gracefully stays English — the UI
// can never break mid-translation. Switching language reloads (cheap + rare),
// so the module-level _LANG read at load is always correct.
const I18N_AR = {
  // Face ID lock
  "Face ID lock": "قفل بصمة الوجه",
  "K9 Pulse is locked": "تطبيق K9 Pulse مُقفَل",
  "Unlock with Face ID to continue.": "افتح القفل ببصمة الوجه للمتابعة.",
  "Unlock with Face ID": "فتح القفل ببصمة الوجه",
  "Scanning…": "جارٍ المسح…",
  "Use a code instead": "استخدم رمزًا بدلاً من ذلك",
  "Face ID failed — try again.": "فشلت بصمة الوجه — حاول مرة أخرى.",
  // nav / tabs
  "Today": "اليوم", "Requests": "الطلبات", "On-call": "المناوبة", "Support": "الدعم", "Me": "حسابي",
  // common actions
  "Confirm": "تأكيد", "Cancel": "إلغاء", "Close": "إغلاق", "Send": "إرسال", "Save": "حفظ",
  "Done": "تم", "Next": "التالي", "Back": "رجوع", "Skip": "تخطّي", "Open": "فتح", "Retry": "إعادة المحاولة",
  "Yes": "نعم", "No": "لا", "Edit": "تعديل", "Delete": "حذف", "Add": "إضافة",
  // sign-in / attendance
  "Sign in": "تسجيل الدخول", "Sign off": "تسجيل الخروج", "Signed in": "مُسجّل الدخول",
  "Signed off": "مُسجّل الخروج", "Sign in to your day.": "ابدأ يومك.",
  "I'm stuck": "أنا متعثّر", "Request time off": "طلب إجازة", "Working from home": "العمل من المنزل",
  "Yesterday": "أمس", "Today's plan": "خطة اليوم", "Issues / blockers": "المشاكل / العوائق",
  "What are you working on today?": "علام تعمل اليوم؟",
  "Hours this week": "ساعات هذا الأسبوع", "On-time streak": "سلسلة الالتزام بالوقت",
  "Office": "المكتب", "Home": "المنزل", "Running late": "متأخر",
  // requests / PTO
  "Pending": "قيد الانتظار", "Approved": "مقبول", "Denied": "مرفوض",
  "Vacation": "إجازة", "Sick leave": "إجازة مرضية", "Sick day": "يوم مرضي",
  "Personal day": "يوم شخصي", "Holiday": "عطلة", "Reason": "السبب", "Start": "البداية", "End": "النهاية",
  "Time off": "إجازة", "My requests": "طلباتي",
  // support / tickets
  "Open tickets": "تذاكر مفتوحة", "In progress": "قيد التنفيذ", "Resolved": "تم الحل",
  "Done this week": "أُنجز هذا الأسبوع", "Ticket": "تذكرة", "Severity": "الأهمية", "Assigned to": "مُسند إلى",
  // profile
  "Profile": "الملف الشخصي", "Settings": "الإعدادات", "Theme": "السمة", "Dark": "داكن", "Light": "فاتح",
  "Use system theme": "استخدام سمة النظام", "Language": "اللغة", "Account · tap to edit": "الحساب · اضغط للتعديل",
  "Display name": "الاسم الظاهر", "Email": "البريد الإلكتروني", "Cell": "الجوال", "Timezone": "المنطقة الزمنية",
  "Team": "الفريق", "App version": "إصدار التطبيق", "Team & rooms": "الفريق والغرف",
  "Export my attendance": "تصدير حضوري", "Sign out": "تسجيل الخروج من الحساب",
  "“Hey Pulse” wake word": "كلمة التنبيه “Hey Pulse”",
  // login / onboarding
  "K9 attendance": "حضور K9", "Enter the code.": "أدخل الرمز.",
  "We'll DM you a 6-digit code in K9 Chat. No passwords, ever.":
    "سنرسل لك رمزًا من 6 أرقام في K9 Chat. بدون كلمات مرور، أبدًا.",
  "Send code": "إرسال الرمز", "use a code instead": "استخدم رمزًا بدلًا من ذلك",
  "Sign in with Face ID": "تسجيل الدخول ببصمة الوجه",
  // pulse
  "Ask Pulse or say a command…": "اسأل Pulse أو انطق بأمر…", "Listening…": "يستمع…",
  "How many hours did I work this week?": "كم ساعة عملت هذا الأسبوع؟",
  "Show my resolved tickets": "اعرض تذاكري المُغلقة",
  "Request a sick day tomorrow": "اطلب إجازة مرضية غدًا",
  "Am I on-call this week?": "هل أنا في المناوبة هذا الأسبوع؟",
  "No worries — cancelled.": "لا مشكلة — تم الإلغاء.",
  "Choose model": "اختر النموذج", "Auto (recommended)": "تلقائي (موصى به)",
  // requests screen
  "Time off you’ve asked for and tickets you’ve raised.": "الإجازات التي طلبتها والتذاكر التي رفعتها.",
  "Tickets": "التذاكر", "days left": "أيام متبقية", "used": "مُستخدَمة",
  "No time-off requests": "لا توجد طلبات إجازة", "Tap + to ask for time off.": "اضغط + لطلب إجازة.",
  "No tickets": "لا توجد تذاكر", "Tap + to open a new one.": "اضغط + لفتح تذكرة جديدة.",
  "approved": "مقبول", "denied": "مرفوض", "pending": "قيد الانتظار", "day": "يوم", "days": "أيام",
  // on-call screen
  "On call": "المناوبة", "Who has the pager, when you’re next.": "من يحمل جهاز النداء، ومتى دورك القادم.",
  "No one on call": "لا أحد في المناوبة",
  "Your team has no active rotation right now.": "لا يوجد لفريقك مناوبة نشطة حاليًا.",
  "Your next shift": "مناوبتك القادمة", "Upcoming · your shifts": "القادمة · مناوباتك",
  "Past · your shifts": "السابقة · مناوباتك", "rotation": "المناوبة", "primary": "أساسي", "backup": "احتياطي",
  "none scheduled · you’re free.": "لا شيء مجدول · أنت متفرّغ.", "Request cover": "اطلب تغطية",
  "cover requested": "تم طلب التغطية",
  // support screen
  "All VPSie-Support tickets — open, in progress, done this week.":
    "كل تذاكر دعم VPSie — مفتوحة، قيد التنفيذ، أُنجزت هذا الأسبوع.",
  "open": "مفتوحة", "in progress": "قيد التنفيذ", "done this wk": "أُنجز هذا الأسبوع",
  "Tap + to open one — IT, hardware, access, anything.":
    "اضغط + لفتح تذكرة — تقنية، أجهزة، صلاحيات، أي شيء.",
  "Open": "مفتوحة", "Acknowledge": "استلام", "Mark resolved": "وضع كمحلولة",
  "Untitled": "بدون عنوان", "Sign in to your day.": "ابدأ يومك.",
  "Out sick today": "إجازة مرضية اليوم",
  "Issues": "المشاكل", "Working from": "مكان العمل",
  "+ save": "+ حفظ",
  ", and any": "، وأي",
  ". Check your DMs.": ". تحقّق من رسائلك.",
  "8 hours": "8 ساعات",
  "A short note for your manager…": "ملاحظة قصيرة لمديرك…",
  "A short note · routed to your lead": "ملاحظة قصيرة · تُرسل إلى قائدك",
  "add a number": "أضف رقمًا",
  "add an email": "أضف بريدًا إلكترونيًا",
  "All caught up": "كل شيء محدّث",
  "Anything we should know…": "أي شيء يجب أن نعرفه…",
  "Appearance": "المظهر",
  "Blocker": "عائق",
  "Change profile photo": "تغيير صورة الملف",
  "Close Pulse": "إغلاق Pulse",
  "Daily schedule": "الجدول اليومي",
  "Dates": "التواريخ",
  "Day complete": "اكتمل اليوم",
  "Day in progress": "اليوم قيد التقدّم",
  "Describe the issue with as much detail as you can…": "صف المشكلة بأكبر قدر من التفاصيل…",
  "device trust": "ثقة الجهاز",
  "Enable notifications →": "تفعيل الإشعارات →",
  "End date is before the start date.": "تاريخ النهاية قبل تاريخ البداية.",
  "Enter the": "أدخِل",
  "code": "الرمز",
  "From": "من",
  "To": "إلى",
  "FROM": "من",
  "TO": "إلى",
  "grow on the week card.": "يكبر في بطاقة الأسبوع.",
  "h elapsed": "ساعة منقضية",
  "History": "السجل",
  "History →": "السجل →",
  "Hit a wall? Tap": "اصطدمت بعائق؟ اضغط",
  "I’m stuck": "أنا متعثّر",
  "K9 Chat ID": "معرّف K9 Chat",
  "last 30 days": "آخر 30 يومًا",
  "Loading…": "جارٍ التحميل…",
  "Mark all read": "تحديد الكل كمقروء",
  "mark it resolved": "ضعها كمحلولة",
  "New": "جديد",
  "New ticket": "تذكرة جديدة",
  "New value": "قيمة جديدة",
  "New time off": "إجازة جديدة",
  "Next month": "الشهر التالي",
  "Previous month": "الشهر السابق",
  "No recent details": "لا توجد تفاصيل حديثة",
  "No strike events in the last 30 days.": "لا مخالفات في آخر 30 يومًا.",
  "Not coming in? Tap": "لن تأتي؟ اضغط",
  "Not now": "ليس الآن",
  "Nothing new since you last checked.": "لا جديد منذ آخر مرة.",
  "Notifications": "الإشعارات",
  "Open a help ticket and follow it to resolution — you can": "افتح تذكرة دعم وتابعها حتى الحل — يمكنك",
  "Open Pulse": "فتح Pulse",
  "Other phone": "هاتف آخر",
  "Out today?": "إجازة اليوم؟",
  "Pulled from your sign-in record": "مأخوذ من سجل تسجيل دخولك",
  "Pulse assistant": "مساعد Pulse",
  "Ready when you are": "جاهز عندما تكون مستعدًا",
  "Reply to the bot…": "ردّ على البوت…",
  "Request": "طلب",
  "Rooms you’re in": "الغرف التي أنت فيها",
  "Save current line as a phrase": "حفظ السطر الحالي كعبارة",
  "Sent to": "أُرسل إلى",
  "Sent — the bot will reply in your K9 chat.": "أُرسل — سيرد البوت في محادثة K9.",
  "Server pushes": "إشعارات الخادم",
  "Sign in again": "تسجيل الدخول من جديد",
  "Sign in for the day": "سجّل دخول اليوم",
  "Sign in to": "سجّل الدخول إلى",
  "Sign-in reminder": "تذكير تسجيل الدخول",
  "Sign-off reminder": "تذكير تسجيل الخروج",
  "Signed off · see": "سجّلت الخروج · انظر",
  "signing you in": "جارٍ تسجيل دخولك",
  "Stay in the": "ابقَ في",
  "Stuck or curious": "متعثّر أو فضولي",
  "Talk to Pulse": "تحدّث إلى Pulse",
  "Tap the big purple button and fill in three quick lines —": "اضغط الزر البنفسجي الكبير واملأ ثلاثة أسطر سريعة —",
  "Tap to read your summary for the week.": "اضغط لقراءة ملخص أسبوعك.",
  "This week": "هذا الأسبوع",
  "time off": "إجازة",
  "The thing that's slowing you down…": "الأمر الذي يبطئك…",
  "this month ›": "هذا الشهر ›",
  "to flag a blocker to your lead. Tap": "للإبلاغ عن عائق لقائدك. اضغط",
  "to scroll your last 30 days, and watch your": "لتصفّح آخر 30 يومًا، وراقب",
  "to submit a new one.": "لإرسال واحدة جديدة.",
  "tomorrow.": "غدًا.",
  "Trusted devices stay signed in for 90 days": "تبقى الأجهزة الموثوقة مسجّلة الدخول 90 يومًا",
  "Type": "النوع",
  "Verification code": "رمز التحقق",
  "Welcome back. Tap to sign in — no code needed.": "مرحبًا بعودتك. اضغط لتسجيل الدخول — بلا رمز.",
  "welcome back…": "مرحبًا بعودتك…",
  "Welcome to": "مرحبًا بك في",
  "What you’re notified about": "ما الذي تُشعَر به",
  "What’s happening?": "ماذا يحدث؟",
  "Wrap up": "الخلاصة",
  "Yest": "أمس",
  "You have": "لديك",
  "You're at the": "أنت في",
  "Your data": "بياناتك",
  "your day": "يومك",
  "Your recent activity": "نشاطك الأخير",
  "yourself when it’s sorted.": "نفسك عند حلّها.",
  "· backup": "· احتياطي",
  "— your sign-in note": "— ملاحظة تسجيل دخولك",
  "← back": "← رجوع",
  "← use a different ID": "← استخدم معرّفًا آخر",
  "↩ Reply to bot": "↩ ردّ على البوت",
  "↻ Same as yesterday": "↻ مثل أمس",
  "● listening… tap mic to stop": "● يستمع… اضغط الميكروفون للإيقاف",
  "⬇ Export my attendance": "⬇ تصدير حضوري",
  "🌴 PTO today": "🌴 إجازة اليوم",
  "📅 My attendance": "📅 حضوري",
  "📋 Weekly recap": "📋 ملخص الأسبوع",
  "🔒 Sign in with Face ID": "🔒 الدخول ببصمة الوجه",
  "🔥 on-time streak": "🔥 سلسلة الالتزام",
  "🤒 Out sick": "🤒 إجازة مرضية",
  "Welcome to K9": "مرحبًا بك في K9",
  "Yesterday, Today": "أمس، اليوم",
  "blockers": "العوائق",
};
const _LANG = (() => {
  try {
    const saved = localStorage.getItem('m_lang');
    if (saved === 'ar' || saved === 'en') return saved;
    return (navigator.language || 'en').toLowerCase().startsWith('ar') ? 'ar' : 'en';
  } catch (_) { return 'en'; }
})();
function m_lang() { return _LANG; }
function t(s) { return (_LANG === 'ar' && I18N_AR[s]) ? I18N_AR[s] : s; }
const i18n = t; // alias for components where `t` is a prop name
function m_setLang(lang) {
  try { localStorage.setItem('m_lang', lang); } catch (_) {}
  try { window.location.reload(); } catch (_) {}
}
// Apply direction + lang attributes as early as possible.
(() => {
  try {
    const rtl = _LANG === 'ar';
    document.documentElement.setAttribute('lang', _LANG);
    document.documentElement.setAttribute('dir', rtl ? 'rtl' : 'ltr');
    if (rtl) document.documentElement.classList.add('m-rtl');
  } catch (_) {}
})();

// v4.3.2 — runtime translation sweep. When Arabic is active, any text node whose
// trimmed text matches a dictionary key is swapped to Arabic, so even rich-JSX
// (login/onboarding) and dynamically-mounted sheets translate without wrapping
// every string. Only nodeValue changes (never DOM structure) → React's
// reconciliation is never disturbed (no removeChild crashes); the observer just
// re-applies after each render. Idempotent: Arabic output isn't a key.
(() => {
  if (_LANG !== 'ar' || typeof MutationObserver === 'undefined') return;
  const SKIP = { INPUT: 1, TEXTAREA: 1, SCRIPT: 1, STYLE: 1, SELECT: 1, OPTION: 1, CODE: 1 };
  let mo = null;
  const walk = (node) => {
    if (node.nodeType === 3) {
      const raw = node.nodeValue;
      if (!raw) return;
      const core = raw.trim();
      if (!core || !/[A-Za-z]/.test(core)) return;
      const tr = I18N_AR[core];
      if (tr && tr !== core) {
        const lead = raw.slice(0, raw.length - raw.trimStart().length);
        const trail = raw.slice(raw.trimEnd().length);
        node.nodeValue = lead + tr + trail;
      }
      return;
    }
    if (node.nodeType !== 1) return;
    const tag = node.tagName;
    if (!tag || SKIP[tag] || tag.toLowerCase() === 'svg' || node.isContentEditable) return;
    for (let c = node.firstChild; c; c = c.nextSibling) walk(c);
  };
  const sweep = () => {
    if (!document.body) return;
    try { if (mo) mo.disconnect(); walk(document.body); }
    catch (_) {}
    finally { if (mo) { try { mo.observe(document.body, { childList: true, subtree: true, characterData: true }); } catch (_) {} } }
  };
  let pending = false;
  const schedule = () => {
    if (pending) return; pending = true;
    requestAnimationFrame(() => { pending = false; sweep(); });
  };
  const boot = () => {
    if (!document.body) { setTimeout(boot, 60); return; }
    mo = new MutationObserver(schedule);
    sweep();
  };
  boot();
})();

// v3.7.1 — one-tap Face ID / native-bridge diagnostic. Returns a human-readable
// readout the user can screenshot so we can tell JS-access vs native-registration
// problems apart without device round-trips.
async function k9NativeDiagnostic() {
  const cap = (typeof window !== 'undefined') && window.Capacitor;
  const L = [];
  L.push('Capacitor present: ' + !!cap);
  if (cap) {
    try { L.push('isNativePlatform: ' + (typeof cap.isNativePlatform === 'function' ? cap.isNativePlatform() : '?')); } catch (_) {}
    try { L.push('platform: ' + (typeof cap.getPlatform === 'function' ? cap.getPlatform() : '?')); } catch (_) {}
    L.push('Plugins.K9Bridge auto-present: ' + !!(cap.Plugins && cap.Plugins.K9Bridge));
    L.push('registerPlugin available: ' + (typeof cap.registerPlugin === 'function'));
  }
  L.push('resolved via: ' + K9.how());
  let avail;
  try { avail = await K9.biometricAvailable(); } catch (e) { avail = { thrown: String((e && e.message) || e) }; }
  L.push('biometricAvailable() => ' + JSON.stringify(avail));
  return L.join('\n');
}

// ── hook: fetch + auto-refresh on focus ──────────────────────────────
function useApi(url) {
  const [data, setData] = useState({ loading: true });
  const load = useCallback(async () => {
    if (!url) { setData({ loading: false, value: null }); return; }
    try {
      const r = await fetch(url, { credentials: 'include' });
      const j = r.ok ? await r.json() : null;
      setData({ loading: false, value: j, status: r.status });
    } catch (e) {
      setData({ loading: false, value: null, error: String(e.message || e) });
    }
  }, [url]);
  useEffect(() => {
    load();
    const onVis = () => { if (!document.hidden) load(); };
    document.addEventListener('visibilitychange', onVis);
    window.addEventListener('focus', onVis);
    // Pull-to-refresh and event-driven invalidation both fire this.
    window.addEventListener('attendance-updated', load);
    return () => {
      document.removeEventListener('visibilitychange', onVis);
      window.removeEventListener('focus', onVis);
      window.removeEventListener('attendance-updated', load);
    };
  }, [load]);
  return [data, load];
}

function useTicker(ms = 30000) {
  const [, force] = useState(0);
  useEffect(() => {
    const t = setInterval(() => force(x => x + 1), ms);
    return () => clearInterval(t);
  }, [ms]);
}

// ── Notifications: register APNs + schedule daily local reminders ────
// Local notifications fire from the device itself with no Apple
// Developer setup required — handle sign-in / sign-off reminders.
// APNs (server-driven push) is wired but needs an .p8 key on the
// backend before it actually delivers; see send_apns_push() in api.py.
async function m_initNotifications(me, { requestPerms = false } = {}) {
  try {
    const cap = window.Capacitor;
    if (!cap || !cap.Plugins) return;
    // Skip if user explicitly opted out OR hasn't opted in yet.
    // The onboarding flow sets m_notif_optin=1 on Enable.
    if (localStorage.getItem('m_notif_optout') === '1') return;
    const optedIn = localStorage.getItem('m_notif_optin') === '1';
    if (!optedIn && !requestPerms) return;

    // 1. Local notifications: daily sign-in + sign-off reminders.
    const Local = cap.Plugins.LocalNotifications;
    if (Local) {
      try {
        const perm = await Local.requestPermissions();
        if (perm && (perm.display === 'granted' || perm.display === 'prompt-with-rationale' || perm === 'granted')) {
          const inH  = parseInt(localStorage.getItem('m_remind_in_hour')  || '9',  10);
          const outH = parseInt(localStorage.getItem('m_remind_out_hour') || '17', 10);
          // Cancel previous reminders before re-scheduling
          try { await Local.cancel({ notifications: [{id: 901}, {id: 902}] }); } catch(_) {}
          await Local.schedule({ notifications: [
            {
              id: 901,
              title: "Good morning, sign in",
              body:  "Open K9 Pulse to start your day.",
              sound: 'reminder.caf',
              schedule: { on: { hour: inH, minute: 0 }, allowWhileIdle: true, every: 'day' },
            },
            {
              id: 902,
              title: "Wrap up — sign off",
              body:  "Take a moment to sign off your day.",
              sound: 'reminder.caf',
              schedule: { on: { hour: outH, minute: 0 }, allowWhileIdle: true, every: 'day' },
            },
          ]});
        }
      } catch (e) { try { console.warn('LocalNotifications failed', e); } catch(_){} }
    }

    // 2. APNs push registration — captures the device token and posts
    //    it to the backend. Server uses it for PTO approved/declined +
    //    ticket assigned/resolved events.
    const Push = cap.Plugins.PushNotifications;
    if (Push) {
      try {
        const perm = await Push.requestPermissions();
        if (perm && perm.receive === 'granted') {
          // Listener for the device token
          Push.addListener('registration', async (info) => {
            try {
              await fetch('/api/me/push-token', {
                method: 'POST', credentials: 'include',
                headers: {'Content-Type': 'application/json'},
                body: JSON.stringify({
                  token: info.value,
                  platform: 'ios',
                  app_version: '3.0.8',
                  locale: navigator.language || 'en-US',
                }),
              });
              localStorage.setItem('m_push_token', info.value);
            } catch (_) {}
          });
          Push.addListener('registrationError', (err) => {
            try { console.warn('push registration error', err); } catch(_) {}
          });
          // Listener for foreground pushes — refresh state.
          Push.addListener('pushNotificationReceived', (n) => {
            try {
              window.dispatchEvent(new CustomEvent('attendance-updated'));
            } catch (_) {}
          });
          // Tap on a push — eventually we could deep-link to a tab.
          Push.addListener('pushNotificationActionPerformed', (n) => {
            try {
              const t = n?.notification?.data?.type;
              if (t && t.startsWith('pto.'))    location.hash = '#requests';
              if (t && t.startsWith('ticket.')) location.hash = '#support';
            } catch (_) {}
          });
          await Push.register();
        }
      } catch (e) { try { console.warn('PushNotifications failed', e); } catch(_){} }
    }
  } catch (_) {}
}

// ── MobileApp root ───────────────────────────────────────────────────
// ── v3.2.0 Pull-to-refresh ───────────────────────────────────────────
// Body is the scroll container (v3.0.5 change). When the user starts a
// touch with scrollY at 0 and drags down, translate the page +Y up to
// a threshold; on release past threshold, fire a refresh hook. Uses
// pure DOM events — no library. Renders a small "↓ release to refresh"
// hint at the top edge.
function usePullToRefresh(onRefresh) {
  const startYRef = useRef(null);
  const dyRef     = useRef(0);
  const [pull, setPull] = useState(0); // 0..THRESH px
  const [refreshing, setRefreshing] = useState(false);
  const THRESH = 70;
  useEffect(() => {
    const handleStart = (e) => {
      if (refreshing) return;
      if ((window.scrollY || document.documentElement.scrollTop) > 0) return;
      startYRef.current = e.touches?.[0]?.clientY ?? null;
      dyRef.current = 0;
    };
    const handleMove = (e) => {
      if (refreshing) return;
      if (startYRef.current == null) return;
      const y = e.touches?.[0]?.clientY ?? null;
      if (y == null) return;
      const dy = y - startYRef.current;
      if (dy <= 0) { setPull(0); dyRef.current = 0; return; }
      // Resistance: only show 40% of the drag distance.
      const shown = Math.min(THRESH + 20, dy * 0.4);
      dyRef.current = dy;
      setPull(shown);
    };
    const handleEnd = async () => {
      const dy = dyRef.current;
      startYRef.current = null;
      dyRef.current = 0;
      if (refreshing) return;
      if (dy * 0.4 >= THRESH) {
        setRefreshing(true);
        setPull(THRESH);
        try { await onRefresh?.(); } catch (_) {}
        setRefreshing(false);
        setPull(0);
      } else {
        setPull(0);
      }
    };
    window.addEventListener('touchstart', handleStart, { passive: true });
    window.addEventListener('touchmove',  handleMove,  { passive: true });
    window.addEventListener('touchend',   handleEnd);
    window.addEventListener('touchcancel',handleEnd);
    return () => {
      window.removeEventListener('touchstart', handleStart);
      window.removeEventListener('touchmove',  handleMove);
      window.removeEventListener('touchend',   handleEnd);
      window.removeEventListener('touchcancel',handleEnd);
    };
  }, [onRefresh, refreshing]);
  return { pull, refreshing, threshold: THRESH };
}

function PullToRefreshIndicator({ pull, refreshing, threshold }) {
  const visible = pull > 0 || refreshing;
  if (!visible) return null;
  const rot = refreshing ? null : Math.min(180, (pull / threshold) * 180);
  return (
    <div style={{
      position: 'fixed',
      top: `calc(env(safe-area-inset-top) + ${Math.max(0, pull - 36)}px)`,
      left: '50%',
      transform: 'translateX(-50%)',
      width: 36, height: 36,
      borderRadius: 18,
      background: '#7c3aed',
      color: '#fff',
      display: 'flex', alignItems: 'center', justifyContent: 'center',
      boxShadow: '0 6px 16px -4px #7c3aed88',
      pointerEvents: 'none',
      zIndex: 55,
      opacity: Math.min(1, pull / 36),
    }}>
      {refreshing ? (
        <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
          <circle cx="12" cy="12" r="9" strokeDasharray="14 14"
            style={{ animation: 'm-spin 0.8s linear infinite', transformOrigin: 'center' }} />
        </svg>
      ) : (
        <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
          style={{ transform: `rotate(${rot}deg)`, transition: 'transform 80ms' }}>
          <path d="M12 5v14M5 12l7 7 7-7"/>
        </svg>
      )}
    </div>
  );
}

// ── Onboarding tour ──────────────────────────────────────────────────
// First-run carousel — 5 cards introducing the tabs + an explicit
// notification opt-in. Sets m_onboarded=1 in localStorage so it never
// reappears for that user on that device. Also syncs the Synapse
// avatar so the user lands with their real photo.
const ONBOARD_STEPS = [
  { kind: 'hero', title: <>Welcome to <em>K9 Pulse</em>.</>,
    body: 'A small, fast app for your work day. Here’s the 60-second tour — how to sign in, log time off, and stay on track.' },
  { kind: 'tab', tab: 'today',    icon: 'home',  title: <>Sign in for the day</>,
    body: <>Tap the big purple button and fill in three quick lines — <strong>Yesterday</strong>, <strong>Today</strong>, and any <strong>blockers</strong>. That’s your standup. Sign off in the evening with a short recap of what you got done.</> },
  { kind: 'tab', tab: 'today',    icon: 'home',  title: <>Out today?</>,
    body: <>Not coming in? Tap <strong>🤒 Out sick</strong> or <strong>🌴 PTO today</strong> right on the sign-in screen — it logs the day correctly so you’re never marked absent by mistake.</> },
  { kind: 'tab', tab: 'today',    icon: 'home',  title: <>Stuck or curious</>,
    body: <>Hit a wall? Tap <strong>I’m stuck</strong> to flag a blocker to your lead. Tap <strong>History</strong> to scroll your last 30 days, and watch your <strong>🔥 on-time streak</strong> grow on the week card.</> },
  { kind: 'tab', tab: 'requests', icon: 'cal',   title: <>Requests</>,
    body: <>Book longer time off and track every PTO request and ticket here. Tap <strong>+</strong> to submit a new one.</> },
  { kind: 'tab', tab: 'support',  icon: 'chat',  title: <>Support</>,
    body: <>Open a help ticket and follow it to resolution — you can <strong>acknowledge</strong> or <strong>mark it resolved</strong> yourself when it’s sorted.</> },
  { kind: 'tab', tab: 'profile',  icon: 'me',    title: <>Me</>,
    body: <>Tap your photo to set an avatar. Edit your email, phone, time zone, and light/dark theme — and export your own attendance any time.</> },
  { kind: 'notif', title: <>Stay in the <em>loop</em>?</>,
    body: 'We can remind you to sign in / sign off, and ping you when a manager approves your PTO or a ticket updates. Change this any time in Profile.' },
];

function OnboardingTour({ me, onDone }) {
  const [step, setStep] = useState(0);
  const cur = ONBOARD_STEPS[step];
  const isLast = step === ONBOARD_STEPS.length - 1;
  const next = () => setStep(s => Math.min(s + 1, ONBOARD_STEPS.length - 1));
  const back = () => setStep(s => Math.max(s - 1, 0));
  const skip = () => onDone({ enabledNotifs: false });
  const enable = async () => {
    localStorage.setItem('m_notif_optin', '1');
    try { await m_initNotifications(me, { requestPerms: true }); } catch (_) {}
    onDone({ enabledNotifs: true });
  };
  const finishNoNotifs = () => {
    localStorage.setItem('m_notif_optout', '1');
    onDone({ enabledNotifs: false });
  };

  // On mount, kick off the Synapse avatar sync so the user lands with
  // their real chat.k9.ms photo instead of the gradient initial. Quiet
  // failure if the avatar isn't set on Matrix.
  useEffect(() => {
    try {
      fetch('/api/auth/me/avatar/sync', { method:'POST', credentials:'include' })
        .catch(() => {});
    } catch (_) {}
  }, []);

  return (
    <div className="m-onboard m-dark">
      <div className="m-onboard-frame">
        <div className="m-onboard-progress">
          {ONBOARD_STEPS.map((_, i) => (
            <span key={i} className={`m-onboard-dot ${i <= step ? 'on' : ''}`} />
          ))}
        </div>

        {cur.kind === 'hero' && (
          <>
            <div className="m-onboard-logo m-onboard-mascot">
              <PulseMascot size={132} />
            </div>
            <div className="m-onboard-hi">Hi, {m_first(me?.display_name) || 'there'}.</div>
            <h1 className="m-onboard-title">{cur.title}</h1>
            <p className="m-onboard-body">{cur.body}</p>
          </>
        )}

        {cur.kind === 'tab' && (
          <>
            <div className="m-onboard-tabicon">
              {cur.tab === 'today'    && <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.4"><path d="M3 12 12 4l9 8"/><path d="M5 11v9h14v-9"/></svg>}
              {cur.tab === 'requests' && <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.4"><rect x="4" y="5" width="16" height="14" rx="2"/><path d="M8 3v4M16 3v4M4 11h16"/></svg>}
              {cur.tab === 'oncall'   && <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.4"><circle cx="12" cy="12" r="8"/><path d="M12 8v5l3 2"/></svg>}
              {cur.tab === 'support'  && <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.4"><path d="M21 11.5a8.4 8.4 0 0 1-9 8.4l-5 1 1-5A8.5 8.5 0 1 1 21 11.5z"/></svg>}
              {cur.tab === 'profile'  && <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.4"><circle cx="12" cy="8" r="4"/><path d="M4 20c1.5-3.5 5-5 8-5s6.5 1.5 8 5"/></svg>}
            </div>
            <div className="m-onboard-tabname">{(cur.tab[0].toUpperCase() + cur.tab.slice(1))} tab</div>
            <h1 className="m-onboard-title">{cur.title}</h1>
            <p className="m-onboard-body">{cur.body}</p>
          </>
        )}

        {cur.kind === 'notif' && (
          <>
            <div className="m-onboard-logo" style={{ background: 'linear-gradient(135deg,#7c3aed,#5b21b6)' }}>
              <svg width="38" height="38" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="1.6">
                <path d="M6 16V11a6 6 0 1 1 12 0v5l1.5 2H4.5L6 16z"/>
                <path d="M10 20a2 2 0 0 0 4 0"/>
              </svg>
            </div>
            <h1 className="m-onboard-title">{cur.title}</h1>
            <p className="m-onboard-body">{cur.body}</p>
          </>
        )}

        <div className="m-onboard-nav">
          {step > 0 && (
            <button className="m-onboard-back" onClick={back}>← back</button>
          )}
          {!isLast ? (
            <button className="m-onboard-cta" onClick={next}>
              {step === 0 ? 'Show me around →' : 'Next →'}
            </button>
          ) : (
            <>
              <button className="m-onboard-cta" onClick={enable}>Enable notifications →</button>
              <button className="m-onboard-skip" onClick={finishNoNotifs}>Not now</button>
            </>
          )}
        </div>
      </div>
    </div>
  );
}

// ── Face ID lock screen ──────────────────────────────────────────────
// Shown on cold app-open until biometric unlock succeeds. Sits in front of
// EVERY authed render path (live session cookie OR trusted-device login).
function MobileLockScreen({ err, busy, onRetry, onUseCode }) {
  return (
    <div className="m-lock" style={{
      position:'fixed', inset:0, zIndex:99999,
      background:'radial-gradient(900px 600px at 80% -10%,#2a1149,transparent 55%),#07030d',
      color:'#efe9f7', display:'flex', flexDirection:'column',
      alignItems:'center', justifyContent:'center', gap:22,
      padding:'max(24px, env(safe-area-inset-top)) 24px max(24px, env(safe-area-inset-bottom))',
      textAlign:'center',
    }}>
      <PulseMascot size={132} />
      <div style={{ fontFamily:'"Fraunces",Georgia,serif', fontSize:26, fontWeight:600, letterSpacing:'-0.01em' }}>
        {t('K9 Pulse is locked')}
      </div>
      <div style={{ fontSize:14, color:'#c8bedd', maxWidth:300, lineHeight:1.5 }}>
        {t('Unlock with Face ID to continue.')}
      </div>
      {err ? <div style={{ fontSize:13, color:'#ff9a9a' }}>{err}</div> : null}
      <button
        onClick={onRetry} disabled={busy}
        style={{
          marginTop:6, border:'none', cursor:'pointer', opacity: busy ? 0.6 : 1,
          background:'linear-gradient(135deg,#7c3aed,#5b21b6)', color:'#fff',
          borderRadius:14, padding:'14px 30px', fontSize:16, fontWeight:600,
          fontFamily:'inherit', display:'inline-flex', alignItems:'center', gap:10,
          WebkitTapHighlightColor:'transparent', boxShadow:'0 8px 24px #5b21b655',
        }}>
        <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
          <path d="M4 8V6a2 2 0 0 1 2-2h2"/><path d="M4 16v2a2 2 0 0 0 2 2h2"/>
          <path d="M16 4h2a2 2 0 0 1 2 2v2"/><path d="M16 20h2a2 2 0 0 0 2-2v-2"/>
          <path d="M9 9h.01"/><path d="M15 9h.01"/><path d="M9 15c1 1 5 1 6 0"/>
        </svg>
        {busy ? t('Scanning…') : t('Unlock with Face ID')}
      </button>
      <button
        onClick={onUseCode} disabled={busy}
        style={{
          background:'none', border:'none', cursor:'pointer', color:'#9a8dc0',
          fontSize:13.5, fontFamily:'inherit', textDecoration:'underline', marginTop:2,
        }}>
        {t('Use a code instead')}
      </button>
    </div>
  );
}

function MobileApp({ me }) {
  // ── Face ID app-lock — scan on every cold open BEFORE revealing the app.
  //    Covers both entry paths (live session cookie AND trusted-device login)
  //    because every authed render funnels through MobileApp. Opt-out via the
  //    Profile toggle (m_face_lock='off'); on web / no-biometric hardware the
  //    native gate reports `missing` and we pass straight through.
  const lockOn = (() => {
    try { return localStorage.getItem('m_face_lock') !== 'off'; } catch (_) { return false; }
  })();
  const [locked, setLocked]     = useState(lockOn);
  const [lockErr, setLockErr]   = useState('');
  const [lockBusy, setLockBusy] = useState(false);
  // First-run gate — show OnboardingTour until the user finishes it.
  const [onboarded, setOnboarded] = useState(() =>
    localStorage.getItem('m_onboarded') === '1'
  );

  const unlock = async () => {
    setLockErr(''); setLockBusy(true);
    let passed = true;
    try {
      const gate = await K9.biometricGate('Unlock K9 Pulse');
      // Explicit failure (cancel / no-match) blocks; `missing` (no hardware /
      // web / un-enrolled) passes through so we never dead-end a user.
      if (gate && gate.ok === false && !gate.missing) passed = false;
    } catch (_) { passed = true; }
    setLockBusy(false);
    if (passed) setLocked(false);
    else setLockErr(t('Face ID failed — try again.'));
  };

  // Scan immediately on mount when the lock is on.
  useEffect(() => {
    if (!lockOn) { setLocked(false); return; }
    unlock();
    // eslint-disable-next-line
  }, []);

  if (locked) {
    return (
      <MobileLockScreen
        err={lockErr} busy={lockBusy} onRetry={unlock}
        onUseCode={async () => {
          // Fallback: drop the session so the OTP login screen appears.
          try { await fetch('/api/auth/logout', { method:'POST', credentials:'include' }); } catch (_) {}
          try { localStorage.setItem('m_signed_out', '1'); } catch (_) {}
          window.location.reload();
        }}
      />
    );
  }
  if (!onboarded) {
    return (
      <OnboardingTour me={me} onDone={() => {
        localStorage.setItem('m_onboarded', '1');
        setOnboarded(true);
      }} />
    );
  }
  return <MobileAppShell me={me} />;
}

function MobileAppShell({ me }) {
  const [tab, setTab] = useState('today');
  const [theme, setTheme] = useState(
    () => localStorage.getItem('m_theme') || 'dark'
  );
  const applyTheme = (t) => {
    setTheme(t);
    localStorage.setItem('m_theme', t);
    const dark = t === 'dark';
    const bg = dark ? '#000000' : '#faf8f3';
    // Paint EVERY layer iOS could show in the safe-area. CSS-only via
    // `:has(.m-light)` was unreliable in older WKWebView builds — set
    // both html + body + the theme-color meta directly. Add a class on
    // <html> so CSS rules that need a non-:has fallback can target it.
    try { document.documentElement.style.background = bg; } catch (_) {}
    try { document.body.style.background = bg; } catch (_) {}
    try {
      document.documentElement.classList.toggle('m-theme-dark',  dark);
      document.documentElement.classList.toggle('m-theme-light', !dark);
    } catch (_) {}
    const meta = document.querySelector('meta[name="theme-color"]');
    if (meta) meta.setAttribute('content', bg);
  };
  useEffect(() => { applyTheme(theme); }, []); // eslint-disable-line
  useEffect(() => { m_initNotifications(me); }, [me?.user_id]);

  // v4.3.1 — "Hey Pulse" wake word. Opt-in (localStorage m_pulse_wake='on').
  // The listener is (re)wired whenever the wake word turns on — at launch AND
  // when the Profile toggle flips it on (previously only wired at launch, so
  // enabling it mid-session did nothing until relaunch). The native recogniser
  // and Pulse's own mic can't both hold the device mic, so on a hit we PAUSE
  // the native listener, open Pulse + listen, then RESUME when Pulse closes.
  useEffect(() => {
    let handle = null;
    let statusHandle = null;
    let starting = false;
    const onHit = () => {
      try { K9.stopWakeWord(); } catch (_) {}          // hand the mic to Pulse
      try { window.dispatchEvent(new CustomEvent('pulse-wake')); } catch (_) {}
    };
    const start = async () => {
      if (starting) return;
      if (localStorage.getItem('m_pulse_wake') !== 'on') return;
      starting = true;
      try {
        if (!statusHandle) statusHandle = K9.onWakeStatus((s) => {
          try { window.dispatchEvent(new CustomEvent('pulse-wake-status', { detail: s || {} })); } catch (_) {}
        });
        const r = await K9.startWakeWord();
        if (!(r && r.missing) && !handle) handle = K9.onWakeWord(onHit);
      } finally { starting = false; }
    };
    const stop = () => {
      try { handle && handle.remove && handle.remove(); } catch (_) {}
      try { statusHandle && statusHandle.remove && statusHandle.remove(); } catch (_) {}
      handle = null; statusHandle = null;
      try { K9.stopWakeWord(); } catch (_) {}
    };
    start();                                             // launch
    const onSet = (e) => { if (e && e.detail && e.detail.on) start(); else stop(); };
    const onResume = () => { setTimeout(start, 250); };  // Pulse closed → listen again
    window.addEventListener('pulse-wake-set', onSet);
    window.addEventListener('pulse-wake-resume', onResume);
    // iOS suspends the audio session in the background; re-arm on return.
    const onVis = () => { if (document.visibilityState === 'visible') start(); };
    document.addEventListener('visibilitychange', onVis);
    return () => {
      window.removeEventListener('pulse-wake-set', onSet);
      window.removeEventListener('pulse-wake-resume', onResume);
      document.removeEventListener('visibilitychange', onVis);
      stop();
    };
  }, []);

  // Notifications inbox state — lifted to MobileApp so the bell badge
  // is consistent across every tab.
  const notifs = useMyNotifications();
  const [notifOpen, setNotifOpen] = useState(false);
  const [askOpen,   setAskOpen]   = useState(false);

  // Pull-to-refresh — fires a window-level event that every useApi()
  // hook listens for (already does for attendance-updated), plus
  // explicitly reloads the notifications inbox.
  const onRefresh = useCallback(async () => {
    try { window.dispatchEvent(new CustomEvent('attendance-updated')); } catch (_) {}
    try { await notifs.reload(); } catch (_) {}
    await new Promise(r => setTimeout(r, 400));  // small delay so the spinner is visible
  }, [notifs]);
  const ptr = usePullToRefresh(onRefresh);
  const markRead = async (id) => {
    try { await fetch(`/api/me/notifications/${id}/read`, { method:'POST', credentials:'include' }); } catch (_) {}
    notifs.reload();
  };
  const markAllRead = async () => {
    try { await fetch('/api/me/notifications/read-all', { method:'POST', credentials:'include' }); } catch (_) {}
    notifs.reload();
  };
  const pulseNavigate = (tab, hint) => {
    if (!tab) return;
    try { if (hint) window.__pulseHint = { tab, hint, t: Date.now() }; } catch (_) {}
    try { window.dispatchEvent(new CustomEvent('pulse-nav', { detail: { tab, hint } })); } catch (_) {}
    setTab(tab);
  };
  const navigateLink = (link) => {
    if (!link) return;
    if (link === '/requests') setTab('requests');
    else if (link === '/support') setTab('support');
    else if (link === '/oncall') setTab('oncall');
    else if (link === '/profile') setTab('profile');
    else setTab('today');
  };
  // expose to pages so the same bell can sit in any MPageHd
  const bell = <NotificationsBell count={notifs.unread} onTap={() => setNotifOpen(true)} />;

  return (
    <div className={`m-app m-${theme}`}>
      <div className="m-page-wrap" key={tab}>
        {tab === 'today'    && <MobileToday    me={me} bell={bell} />}
        {tab === 'requests' && <MobileRequests me={me} bell={bell} />}
        {tab === 'oncall'   && <MobileOnCall   me={me} bell={bell} />}
        {tab === 'support'  && <MobileSupport  me={me} bell={bell} />}
        {tab === 'profile'  && <MobileProfile  me={me} bell={bell} theme={theme} setTheme={applyTheme} />}
      </div>
      <MobileTabBar tab={tab} setTab={setTab} />
      <PulseRoot me={me} onNavigate={pulseNavigate} />
      <PullToRefreshIndicator pull={ptr.pull} refreshing={ptr.refreshing} threshold={ptr.threshold} />
      {notifOpen && (
        <NotificationsSheet
          items={notifs.items}
          onClose={() => setNotifOpen(false)}
          onMarkRead={markRead}
          onMarkAllRead={markAllRead}
          onNavigate={navigateLink}
        />
      )}
    </div>
  );
}

const TAB_DEFS = [
  { id: 'today',    label: t('Today'),    icon: <svg viewBox="0 0 24 24"><path d="M3 12 12 4l9 8"/><path d="M5 11v9h14v-9"/></svg> },
  { id: 'requests', label: t('Requests'), icon: <svg viewBox="0 0 24 24"><rect x="4" y="5" width="16" height="14" rx="2"/><path d="M8 3v4M16 3v4M4 11h16"/></svg> },
  { id: 'oncall',   label: t('On-call'),  icon: <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/><path d="M12 8v5l3 2"/></svg> },
  { id: 'support',  label: t('Support'),  icon: <svg viewBox="0 0 24 24"><path d="M21 11.5a8.4 8.4 0 0 1-9 8.4l-5 1 1-5A8.5 8.5 0 1 1 21 11.5z"/></svg> },
  { id: 'profile',  label: t('Me'),       icon: <svg viewBox="0 0 24 24"><circle cx="12" cy="8" r="4"/><path d="M4 20c1.5-3.5 5-5 8-5s6.5 1.5 8 5"/></svg> },
];
function MobileTabBar({ tab, setTab }) {
  return (
    <nav className="m-tabbar">
      {TAB_DEFS.map(t => (
        <button key={t.id}
          className={`m-tab ${tab === t.id ? 'on' : ''}`}
          onClick={() => setTab(t.id)}
          aria-label={t.label}>
          {t.icon}<span>{t.label}</span>
        </button>
      ))}
    </nav>
  );
}

function MPageHd({ crumb, title, sub, right }) {
  return (
    <div className="m-pagehd">
      <div style={{ display:'flex', alignItems:'flex-start', justifyContent:'space-between', gap:12 }}>
        <div style={{ minWidth: 0, flex: 1 }}>
          {crumb && <div className="m-crumb">{crumb}</div>}
          <h1 className="m-title">{title}</h1>
        </div>
        {right}
      </div>
      {sub && <div className="m-sub">{sub}</div>}
    </div>
  );
}

// ── Notifications inbox ─────────────────────────────────────────────
function useMyNotifications() {
  const [items, setItems] = useState([]);
  const [loading, setLoading] = useState(true);
  const load = useCallback(async () => {
    try {
      const r = await fetch('/api/me/notifications?limit=50', { credentials: 'include' });
      const j = r.ok ? await r.json() : [];
      setItems(Array.isArray(j) ? j : []);
    } catch (_) {}
    setLoading(false);
  }, []);
  useEffect(() => {
    load();
    const t = setInterval(load, 30000);
    const onVis = () => { if (!document.hidden) load(); };
    document.addEventListener('visibilitychange', onVis);
    window.addEventListener('focus', onVis);
    window.addEventListener('attendance-updated', load);
    return () => {
      clearInterval(t);
      document.removeEventListener('visibilitychange', onVis);
      window.removeEventListener('focus', onVis);
      window.removeEventListener('attendance-updated', load);
    };
  }, [load]);
  const unread = items.filter(n => !n.read_at).length;
  return { items, unread, loading, reload: load };
}

function NotificationsBell({ count, onTap }) {
  return (
    <button onClick={onTap} aria-label="Notifications" style={{
      width: 36, height: 36, borderRadius: '50%',
      display: 'flex', alignItems: 'center', justifyContent: 'center',
      background: 'transparent',
      border: '1px solid rgba(127,127,127,0.25)',
      color: 'inherit', position: 'relative', cursor: 'pointer',
      flexShrink: 0,
    }}>
      <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7">
        <path d="M6 16V11a6 6 0 1 1 12 0v5l1.5 2H4.5L6 16z"/>
        <path d="M10 20a2 2 0 0 0 4 0"/>
      </svg>
      {count > 0 && (
        <span style={{
          position: 'absolute', top: -3, right: -3,
          minWidth: 18, height: 18, padding: '0 5px',
          borderRadius: 9, background: '#ff4d4d', color: '#fff',
          fontFamily: "'JetBrains Mono', monospace",
          fontSize: 10, fontWeight: 600,
          display: 'flex', alignItems: 'center', justifyContent: 'center',
          boxShadow: '0 2px 6px -2px #ff4d4daa',
        }}>{count > 99 ? '99+' : count}</span>
      )}
    </button>
  );
}

// v3.8.x — a single notification row. Bot-originated notifications get an
// inline "Reply" box that posts to /api/me/bot-message (the bot replies in
// your Matrix DM). Other rows tap through to their linked page.
const BOT_REPLY_KINDS = new Set([
  'strike', 'strike_escalation', 'format_violation', 'no_progress',
  'bot', 'bot_error', 'reminder', 'no_signin', 'no_signoff',
]);
function NotifRow({ n, labelFor, onMarkRead, onNavigate, onClose }) {
  const [replyOpen, setReplyOpen] = useState(false);
  const [text, setText] = useState('');
  const [sending, setSending] = useState(false);
  const [sent, setSent] = useState(false);
  const canReply = BOT_REPLY_KINDS.has(n.kind);
  const send = async () => {
    if (!text.trim() || sending) return;
    setSending(true);
    try {
      const r = await fetch('/api/me/bot-message', {
        method:'POST', credentials:'include',
        headers:{'Content-Type':'application/json'},
        body: JSON.stringify({ text: text.trim() }),
      });
      if (r.ok) { setSent(true); setText(''); setReplyOpen(false); onMarkRead(n.id); }
    } catch (_) {}
    finally { setSending(false); }
  };
  return (
    <div className={`m-notif-row ${n.read_at ? 'read' : 'unread'}`} style={{ display:'block' }}>
      <div style={{ display:'flex', gap:10, cursor: n.link ? 'pointer' : 'default' }}
        onClick={() => { onMarkRead(n.id); if (n.link) { onNavigate(n.link); onClose(); } }}>
        <div className="m-notif-dot" />
        <div className="m-notif-body">
          <div className="m-notif-top">
            <span className="m-notif-kind">{labelFor(n.kind)}</span>
            <span className="m-notif-when">{m_ago(n.created_at)}</span>
          </div>
          <div className="m-notif-title">{n.title}</div>
          {n.body && <div className="m-notif-sub">{n.body}</div>}
        </div>
      </div>
      {canReply && !sent && (
        <div style={{ marginTop:8, marginLeft:18 }}>
          {!replyOpen ? (
            <button className="m-reply-link" onClick={() => setReplyOpen(true)}>↩ Reply to bot</button>
          ) : (
            <div style={{ display:'flex', gap:6, alignItems:'flex-end' }}>
              <textarea className="m-textarea" rows={2} autoFocus value={text}
                onChange={e => setText(e.target.value)} placeholder="Reply to the bot…"
                style={{ flex:1, fontSize:13, touchAction:'manipulation' }} />
              <button className="m-reply-send" onClick={send} disabled={sending || !text.trim()}>
                {sending ? '…' : 'Send'}
              </button>
            </div>
          )}
        </div>
      )}
      {sent && <div style={{ marginTop:6, marginLeft:18, fontSize:11.5, color:'#8fe0ab' }}>Sent — the bot will reply in your K9 chat.</div>}
    </div>
  );
}

function NotificationsSheet({ items, onClose, onMarkRead, onMarkAllRead, onNavigate }) {
  const labelFor = (kind) => ({
    pto_approved:      'time off',
    pto_declined:      'time off',
    ticket_assigned:   'ticket',
    ticket_resolved:   'ticket',
    format_violation:  'compliance',
    strike_escalation: 'strike',
    no_progress:       'progress',
    bot_error:         'bot',
  }[kind] || (kind || 'notice').replace(/_/g, ' '));
  return (
    <div className="m-sheet-back" onClick={onClose}>
      <div className="m-sheet" onClick={e => e.stopPropagation()}>
        <div className="m-sheet-grab" />
        <div style={{ display:'flex', alignItems:'baseline', justifyContent:'space-between', gap:8 }}>
          <div className="m-sheet-title">Your <em>notifications</em></div>
          {items.some(n => !n.read_at) && (
            <button onClick={onMarkAllRead} style={{
              background:'none', border:'none', color:'inherit',
              opacity:0.65, cursor:'pointer',
              fontFamily:"'JetBrains Mono', monospace",
              fontSize:10.5, letterSpacing:'0.12em', textTransform:'uppercase',
              padding:'4px 8px',
            }}>Mark all read</button>
          )}
        </div>
        {items.length === 0 ? (
          <MEmpty title="All caught up" sub="Nothing new since you last checked." />
        ) : (
          <div className="m-notif-list">
            {items.map(n => (
              <NotifRow key={n.id} n={n} labelFor={labelFor}
                onMarkRead={onMarkRead} onNavigate={onNavigate} onClose={onClose} />
            ))}
          </div>
        )}
        <button className="m-cta" onClick={onClose} style={{
          marginTop:10, background:'transparent', color:'inherit',
          boxShadow:'none', border:'1px solid currentColor', opacity:0.7,
        }}>Close</button>
      </div>
    </div>
  );
}

function MAvatar({ me, size = 36 }) {
  const url = me?.avatar_url || me?.synapse_avatar_url;
  if (url) {
    return <img src={url} alt={me?.display_name || ''}
                style={{ width:size, height:size, borderRadius:'50%', objectFit:'cover' }} />;
  }
  return (
    <div style={{
      width: size, height: size, borderRadius:'50%',
      background:'linear-gradient(135deg, #7c3aed, #5b21b6)',
      color:'#fff', display:'flex', alignItems:'center', justifyContent:'center',
      fontFamily:"'Fraunces',serif", fontStyle:'italic', fontWeight:300,
      fontSize: size * 0.45,
    }}>{m_initial(me?.display_name)}</div>
  );
}

// ────────────────────────────────────────────────────────────────────
//  TODAY
// ────────────────────────────────────────────────────────────────────
function MobileToday({ me, bell }) {
  const [ck, reloadCk] = useApi('/api/me/checkin');
  const [kp]           = useApi('/api/today/kpis');
  // v3.7.2 — PERSONAL weekly stats. /api/today/kpis is org-wide (all users);
  // using it here showed the whole company's hours/strikes as if they were
  // the logged-in user's. /api/me/weekly is strictly this user's own.
  const [wk]           = useApi('/api/me/weekly');
  const [recap]        = useApi('/api/me/weekly-recap');
  const [recapOpen, setRecapOpen] = useState(false);
  const [calOpen, setCalOpen] = useState(false);   // v3.11.0 attendance calendar
  const [ptoSheet, setPtoSheet] = useState(null);  // v3.12 PTO popup: null | 'pto' | 'sick'
  const [bal]          = useApi('/api/pto/balances');
  const [audit]        = useApi('/api/audit?limit=15');
  // v3.4.0 — when the user lands on Today and they're already at the
  // office IP (per bot's IP learning) but haven't signed in yet, we
  // show a one-tap suggestion banner.
  const [ipclass]      = useApi('/api/me/ip-class');
  const [busy, setBusy] = useState(false);
  const [err, setErr]   = useState('');
  const [strikeSheetOpen, setStrikeSheetOpen] = useState(false);
  useTicker(30000);

  useEffect(() => {
    const h = () => reloadCk();
    window.addEventListener('attendance-updated', h);
    return () => window.removeEventListener('attendance-updated', h);
  }, [reloadCk]);

  const checkin = ck.value || {};
  const kpis    = kp.value  || {};
  const weekly  = wk.value  || {};
  const balanceList = bal.value || [];
  const myBalance = balanceList.find(b => b.user_id === me?.user_id);

  const myAudit = (audit.value || [])
    .filter(a => a.user_display_name === me?.display_name || a.actor === me?.display_name)
    .slice(0, 6);

  const [signInSheetOpen, setSignInSheetOpen] = useState(false);
  // v3.6.0 — history scrollback + I'm stuck.
  const [historyOpen, setHistoryOpen] = useState(false);
  const [blockerOpen, setBlockerOpen] = useState(false);
  // v3.8.x — sign-off recap sheet + one-tap absence confirmation notice.
  const [signOffSheetOpen, setSignOffSheetOpen] = useState(false);
  const [notice, setNotice] = useState('');
  // v3.7.1 — Face ID / native-bridge diagnostic (native iOS only)
  const [faceDiag, setFaceDiag] = useState('');
  const isNative = typeof window !== 'undefined' && window.Capacitor &&
    typeof window.Capacitor.isNativePlatform === 'function' && window.Capacitor.isNativePlatform();
  const runFaceTest = async () => {
    setFaceDiag('running…');
    const readout = await k9NativeDiagnostic();
    let gate;
    try { gate = await K9.biometricGate('Face ID test'); }
    catch (e) { gate = { thrown: String((e && e.message) || e) }; }
    setFaceDiag(readout + '\nbiometricGate() => ' + JSON.stringify(gate));
  };

  // v3.7.0 — keep the iOS Home Screen widget + Lock Screen widget in
  // sync with whatever the API just returned, and start/stop the
  // Live Activity in lock-step with the checkin state.
  useEffect(() => {
    if (!checkin) return;
    const signedInIso = checkin.sign_in_at || null;
    K9.syncWidgetState({
      signedIn: !!checkin.signed_in && !checkin.signed_off,
      signedInAt: signedInIso,
      // PERSONAL weekly stats (was org-wide kpis — wrong on the widget/watch).
      weeklyHours: Number(weekly.hours_this_week || 0),
      weeklyTarget: Number(weekly.weekly_target || 40),
      strikes: Number(weekly.strikes_this_week || 0),
    });
    if (checkin.signed_in && !checkin.signed_off && signedInIso) {
      K9.startLiveActivity({
        signedInAt: signedInIso,
        targetHours: Number(kpis.weekly_target_daily || 8),
        hasBlocker: !!checkin.has_blocker,
        name: me?.display_name || '',
      });
    } else if (checkin.signed_off || !checkin.signed_in) {
      K9.endLiveActivity();
    }
  }, [checkin?.signed_in, checkin?.signed_off, checkin?.sign_in_at, checkin?.has_blocker, weekly.hours_this_week, weekly.strikes_this_week]);

  const submitSignIn = async ({ yesterday, today, issues, location }) => {
    setBusy(true); setErr('');
    try {
      // v3.7.0 — Face ID / Touch ID confirmation before the API write.
      // No-op on web / Android. On iOS the OS shows the system prompt;
      // we treat user-cancel as the only hard stop.
      const gate = await K9.biometricGate('Confirm sign-in to K9 Pulse');
      if (gate && gate.ok === false && !gate.missing) {
        setErr('Sign-in cancelled.');
        setBusy(false);
        return;
      }
      const yest = (yesterday || '').trim();
      const tod  = (today || '').trim();
      const iss  = (issues || '').trim() || 'none';
      // Compose the canonical standup text the bot expects so the
      // backend parser + AI progress scorer see the same shape whether
      // the user signs in from chat or from the app.
      const text = [
        yest && `Yesterday: ${yest}`,
        tod  && `Today: ${tod}`,
        `Issues: ${iss}`,
      ].filter(Boolean).join('\n');
      const r = await fetch('/api/me/checkin', {
        method:'POST', credentials:'include',
        headers:{'Content-Type':'application/json'},
        body: JSON.stringify({ text, yesterday: yest, today: tod, issues: iss, location: location || '' }),
      });
      if (!r.ok) throw new Error(await m_parseErr(r));
      reloadCk();
      window.dispatchEvent(new CustomEvent('attendance-updated'));
    } catch (e) { setErr(String(e.message || e)); }
    finally { setBusy(false); }
  };

  // Lock Screen widget intent — AppDelegate dispatches 'k9-widget-intent'
  // when the user taps Sign-in / Sign-off on the widget. Auto-open the
  // format form so they never get a blank-record sign-in.
  useEffect(() => {
    const onIntent = (e) => {
      const kind = e && e.detail && e.detail.kind;
      if (kind === 'signin' || kind === 'signoff') {
        setSignInSheetOpen(true);
      }
    };
    window.addEventListener('k9-widget-intent', onIntent);
    if (window.__pendingWidgetIntent) {
      const pending = window.__pendingWidgetIntent;
      delete window.__pendingWidgetIntent;
      setTimeout(() => onIntent({ detail: { kind: pending } }), 80);
    }
    return () => window.removeEventListener('k9-widget-intent', onIntent);
  }, []);
  const doSignOff = async (recap) => {
    setBusy(true); setErr('');
    try {
      // v3.7.0 — confirm sign-off with Face ID / Touch ID on iOS.
      const gate = await K9.biometricGate('Confirm sign-off from K9 Pulse');
      if (gate && gate.ok === false && !gate.missing) {
        setErr('Sign-off cancelled.');
        setBusy(false);
        return;
      }
      const r = await fetch('/api/me/checkout', {
        method:'POST', credentials:'include',
        headers:{'Content-Type':'application/json'},
        body: JSON.stringify({ recap: (recap || '').trim() }),
      });
      if (!r.ok) throw new Error(await m_parseErr(r));
      reloadCk();
      window.dispatchEvent(new CustomEvent('attendance-updated'));
      // Optimistic — end Live Activity now so Dynamic Island clears fast
      K9.endLiveActivity();
      setSignOffSheetOpen(false);
    } catch (e) { setErr(String(e.message || e)); }
    finally { setBusy(false); }
  };

  // v3.8.x — one-tap "out today": files a same-day Sick / PTO request
  // (pending a manager) so a missed day doesn't become a false absence/strike.
  // v3.12 — full PTO request (type + reason + date range), routed to the
  // team PTO room server-side. Returns true on success so the sheet can close.
  const submitAbsence = async ({ ptoType, reason, start, end }) => {
    if (busy) return false;
    setBusy(true); setErr(''); setNotice('');
    try {
      const r = await fetch('/api/me/pto-request', {
        method:'POST', credentials:'include',
        headers:{'Content-Type':'application/json'},
        body: JSON.stringify({
          pto_type: ptoType, start_date: start, end_date: end,
          reason: (reason || '').trim() || null,
        }),
      });
      if (!r.ok) throw new Error(await m_parseErr(r));
      window.dispatchEvent(new CustomEvent('attendance-updated'));
      const label = { vacation:'Vacation', sick:'Sick leave', personal:'Personal day', holiday:'Holiday', pto:'PTO' }[ptoType] || 'Time off';
      setNotice(`${label} request sent${start===end?' for today':''} — pending your manager. Your team room was notified.`);
      return true;
    } catch (e) { setErr(String(e.message || e)); return false; }
    finally { setBusy(false); }
  };

  const inAt  = m_fmt12(checkin.sign_in_at);
  const outAt = m_fmt12(checkin.sign_off_at);
  const elapsedH = (() => {
    if (!checkin.sign_in_at) return null;
    const start = new Date(checkin.sign_in_at);
    const end   = checkin.sign_off_at ? new Date(checkin.sign_off_at) : new Date();
    return Math.max(0, (end - start) / 3600000).toFixed(1);
  })();

  const weeklyHours  = weekly.hours_this_week ?? null;   // PERSONAL (was org-wide kpis)
  const weeklyTarget = weekly.weekly_target || 40;
  const pct = weeklyHours != null
    ? Math.min(100, Math.round((+weeklyHours / weeklyTarget) * 100))
    : 0;
  const myStrikes = weekly.strikes_this_week ?? 0;   // PERSONAL (was org-wide kpis)

  return (
    <>
      <MPageHd
        crumb={m_today_str()}
        title={checkin.signed_in
          ? (checkin.signed_off ? 'Today' : 'You’re signed in')
          : <>{m_greet()},<br/><em style={{fontWeight:300}}>{m_first(me?.display_name) || 'there'}.</em></>}
        right={<><div style={{display:"flex",gap:8,alignItems:"center"}}>{bell}<MAvatar me={me} size={42} /></div></>}
      />

      {isNative && (
        <div style={{ margin:'0 0 12px' }}>
          <button
            onClick={runFaceTest}
            style={{
              width:'100%', padding:'10px 12px', borderRadius:12,
              border:'1px solid #7c3aed55', background:'#7c3aed14',
              color:'#a98bff', fontFamily:'Manrope, sans-serif', fontSize:13,
              fontWeight:600, cursor:'pointer', touchAction:'manipulation',
            }}>
            🔒 Test Face ID / native bridge
          </button>
          {faceDiag && (
            <pre style={{
              marginTop:8, padding:'10px 12px', borderRadius:10,
              background:'#0c0518', color:'#cfc6ff', fontSize:11,
              lineHeight:1.5, whiteSpace:'pre-wrap', wordBreak:'break-word',
              border:'1px solid #ffffff14', fontFamily:'JetBrains Mono, monospace',
            }}>{faceDiag}</pre>
          )}
        </div>
      )}

      {ck.loading ? (
        <div className="m-hero m-hero-load"><div className="m-skel" /></div>
      ) : checkin.signed_off ? (
        <div className="m-hero m-hero-done">
          <span className="m-heyebrow m-heyebrow-done">Day complete</span>
          <div className="m-htime">{outAt?.h}:{outAt?.m}<small>{outAt?.ap}</small></div>
          <div className="m-hstate">Signed off · see <em>you</em> tomorrow.</div>
          <div className="m-hrow">
            <span className="m-hlbl">{elapsedH}h · in {inAt?.h}:{inAt?.m}{inAt?.ap}</span>
            <button className="m-hbtn" onClick={() => setSignInSheetOpen(true)} disabled={busy}>Sign in again</button>
          </div>
          <div className="m-stuck-row">
            <button className="m-hist" onClick={() => setHistoryOpen(true)}>
              History →
            </button>
          </div>
        </div>
      ) : checkin.signed_in ? (
        <div className="m-hero m-hero-on">
          <span className="m-heyebrow m-heyebrow-on">Signed in at {inAt?.h}:{inAt?.m}{inAt?.ap}</span>
          <div className="m-htime">{elapsedH}<small>h elapsed</small></div>
          <div className="m-hstate">target <em>8 hours</em> · you&rsquo;re doing it.</div>
          {/* v3.8.x geofence sign-off nudge — you've left the office but
              haven't signed off yet. Gentle, never automatic. */}
          {ipclass.value?.classification === 'home' && (
            <div className="m-loc-hint" style={{ marginTop: 6 }}>
              <span style={{ display:'inline-flex', alignItems:'center', gap:6 }}>
                <span style={{ width:6, height:6, borderRadius:'50%', background:'#a3eeff',
                               boxShadow:'0 0 8px #a3eeff' }} />
                You&rsquo;re <em>home</em> — don&rsquo;t forget to sign off.
              </span>
            </div>
          )}
          <div className="m-hrow">
            <span className="m-hlbl">Day in progress</span>
            <button className="m-hbtn m-hbtn-off" onClick={() => setSignOffSheetOpen(true)} disabled={busy}>
              {busy ? '…' : 'Sign off →'}
            </button>
          </div>
          {/* v3.6.0 — quick blocker raise. Lives under the hero so it's
              never confused with sign-off but always one tap away. */}
          <div className="m-stuck-row">
            <button className="m-stuck" onClick={() => setBlockerOpen(true)} disabled={busy}>
              <span className="m-stuck-icon" aria-hidden="true">!</span>
              {t("I'm stuck")}
            </button>
            <button className="m-hist" onClick={() => setHistoryOpen(true)}>
              History →
            </button>
          </div>
        </div>
      ) : (
        <div className="m-hero m-hero-go">
          {ipclass.value?.classification === 'office' && (
            <div className="m-loc-hint">
              <span style={{ display:'inline-flex', alignItems:'center', gap:6 }}>
                <span style={{ width:6, height:6, borderRadius:'50%', background:'#71d893',
                               boxShadow:'0 0 8px #71d893' }} />
                You're at the <em>office</em>.
              </span>
            </div>
          )}
          {ipclass.value?.classification === 'home' && (
            <div className="m-loc-hint">
              <span style={{ display:'inline-flex', alignItems:'center', gap:6 }}>
                <span style={{ width:6, height:6, borderRadius:'50%', background:'#a3eeff',
                               boxShadow:'0 0 8px #a3eeff' }} />
                You're <em>home</em>.
              </span>
            </div>
          )}
          <span className="m-heyebrow">Ready when you are</span>
          <div className="m-htime-small">{new Date().toLocaleTimeString([], {hour:'numeric', minute:'2-digit'}).toLowerCase()}</div>
          <button className="m-hcta" onClick={() => setSignInSheetOpen(true)} disabled={busy}>
            {busy ? '…' : ipclass.value?.classification === 'office'
              ? 'Sign in at the office →'
              : 'Sign in to your day →'}
          </button>
          {/* v3.8.x — one-tap "out today" so a sick/PTO day is logged
              correctly instead of becoming a false absence. */}
          <div className="m-out-row">
            <button className="m-out-btn" onClick={() => setPtoSheet('sick')} disabled={busy}>
              🤒 {t('Out sick today')}
            </button>
            <button className="m-out-btn" onClick={() => setPtoSheet('pto')} disabled={busy}>
              🌴 {t('Request time off')}
            </button>
          </div>
        </div>
      )}
      {err && <div className="m-err">{err}</div>}
      {notice && <div className="m-notice">{notice}</div>}

      {myStrikes > 0 && (
        <button className="m-banner m-banner-warn m-banner-tap" onClick={() => setStrikeSheetOpen(true)}>
          <strong>{myStrikes} {myStrikes === 1 ? 'strike' : 'strikes'} this week.</strong>
          {' '}Tap to see what triggered them.
          <span style={{ marginLeft: 'auto', opacity: 0.55 }}>›</span>
        </button>
      )}
      {strikeSheetOpen && <StrikeSheet onClose={() => setStrikeSheetOpen(false)} />}

      <div className="m-card">
        <div className="m-ctop">
          <span className="m-clbl">This week</span>
          {weeklyHours != null && (
            <span className="m-cval">{(+weeklyHours).toFixed(1)}<em>/{weeklyTarget}</em></span>
          )}
        </div>
        <div className="m-prog">
          <div className="m-prog-bar" style={{width: `${pct}%`}} />
        </div>
        {/* v3.8.x — on-time streak + this-month attendance, self-motivation. */}
        {(weekly.on_time_streak > 0 || weekly.attendance_pct_month != null) && (
          <div className="m-statline">
            {weekly.on_time_streak > 0 && (
              <span className="m-stat"><span className="m-stat-ic">🔥</span>{weekly.on_time_streak}-day on-time streak</span>
            )}
            {weekly.attendance_pct_month != null && (
              <span className="m-stat"><span className="m-stat-ic">📅</span>{weekly.attendance_pct_month}% this month</span>
            )}
          </div>
        )}
      </div>

      {/* v3.8.x — your own Friday weekly recap, in-app */}
      {recap.value?.available && (
        <button className="m-card m-card-tap" onClick={() => setRecapOpen(true)}
          style={{ width:'100%', textAlign:'left', cursor:'pointer' }}>
          <div className="m-ctop">
            <span className="m-clbl">📋 Weekly recap</span>
            <span className="m-cval" style={{ fontSize:13, opacity:0.6 }}>week of {recap.value.week_start} ›</span>
          </div>
          <div className="m-card-sub">Tap to read your summary for the week.</div>
        </button>
      )}
      {recapOpen && recap.value?.available && (
        <RecapSheet recap={recap.value} onClose={() => setRecapOpen(false)} />
      )}

      {/* v3.11.0 — attendance calendar */}
      <button className="m-card m-card-tap" onClick={() => setCalOpen(true)}
        style={{ width:'100%', textAlign:'left', cursor:'pointer' }}>
        <div className="m-ctop">
          <span className="m-clbl">📅 My attendance</span>
          <span className="m-cval" style={{ fontSize:13, opacity:0.6 }}>this month ›</span>
        </div>
        <div className="m-card-sub">Tap for a calendar of your present / late / remote / PTO days.</div>
      </button>
      {calOpen && <CalendarSheet onClose={() => setCalOpen(false)} />}
      {ptoSheet && (
        <PtoRequestSheet
          preset={ptoSheet}
          busy={busy}
          onClose={() => setPtoSheet(null)}
          onSubmit={async (payload) => { const ok = await submitAbsence(payload); if (ok) setPtoSheet(null); }}
        />
      )}

      {myBalance && (
        <div className="m-card">
          <div className="m-ctop">
            <span className="m-clbl">Time-off · {myBalance.year}</span>
            <span className="m-cval">{myBalance.remaining}<em>/{myBalance.quota}</em></span>
          </div>
          <div className="m-card-sub">{myBalance.used} used so far this year</div>
        </div>
      )}

      {checkin.signed_in && checkin.text && (
        <div className="m-note">
          &ldquo;{checkin.text}&rdquo;
          <span className="m-note-who">— your sign-in note</span>
        </div>
      )}

      {myAudit.length > 0 && (
        <div className="m-card">
          <div className="m-ctop"><span className="m-clbl">Your recent activity</span></div>
          <div className="m-activity">
            {myAudit.map(a => (
              <div key={a.id} className="m-arow">
                <span className="m-ats">{m_ago(a.occurred_at)}</span>
                <span className="m-atxt">{a.action_label || a.action_type || a.trigger || 'event'}</span>
              </div>
            ))}
          </div>
        </div>
      )}

      {signInSheetOpen && (
        <SignInSheet
          busy={busy}
          onClose={() => setSignInSheetOpen(false)}
          onSubmit={async (note) => {
            await submitSignIn(note);
            setSignInSheetOpen(false);
          }}
        />
      )}
      {historyOpen && (
        <HistorySheet onClose={() => setHistoryOpen(false)} />
      )}
      {signOffSheetOpen && (
        <SignOffSheet
          busy={busy}
          elapsedH={elapsedH}
          onClose={() => setSignOffSheetOpen(false)}
          onSubmit={(recap) => doSignOff(recap)}
        />
      )}
      {blockerOpen && (
        <BlockerSheet
          busy={busy}
          existing={checkin}
          onClose={() => setBlockerOpen(false)}
          onSubmit={async (blockerText) => {
            // Re-posting REPLACES the day's Checkin, so preserve the
            // existing yesterday/today so we don't blow them away.
            await submitSignIn({
              yesterday: checkin?.yesterday_text || '',
              today: checkin?.today_text || '',
              issues: blockerText,
            });
            setBlockerOpen(false);
          }}
        />
      )}
    </>
  );
}

// ────────────────────────────────────────────────────────────────────
//  REQUESTS
// ────────────────────────────────────────────────────────────────────
function MobileRequests({ me, bell }) {
  const [seg, setSeg] = useState('pto');
  const [pto, reloadPto] = useApi('/api/pto');
  const [bal]            = useApi('/api/pto/balances');
  const [tix, reloadTix] = useApi('/api/support/tickets?mine=1');
  const [sheetOpen, setSheetOpen] = useState(false);

  const myPto = (pto.value || []).filter(r => r.user_id === me?.user_id);
  const pending  = myPto.filter(r => r.approved === null || r.approved === undefined);
  const approved = myPto.filter(r => r.approved === true);
  const denied   = myPto.filter(r => r.approved === false);

  const myBalance = (bal.value || []).find(b => b.user_id === me?.user_id);
  const myTickets = tix.value || [];

  return (
    <>
      <MPageHd
        crumb={seg === 'pto' ? `${myPto.length} ${myPto.length===1?'request':'requests'}` : `${myTickets.length} tickets`}
        title={t('Requests')}
        sub={t("Time off you’ve asked for and tickets you’ve raised.")}
        right={<><div style={{display:"flex",gap:8,alignItems:"center"}}>{bell}<MAvatar me={me} size={36} /></div></>}
      />
      <div className="m-seg2">
        <button className={`m-seg ${seg==='pto'?'on':''}`}     onClick={() => setSeg('pto')}>{t('Time off')}</button>
        <button className={`m-seg ${seg==='tickets'?'on':''}`} onClick={() => setSeg('tickets')}>{t('Tickets')}</button>
      </div>

      {seg === 'pto' && (
        pto.loading ? <MSkel rows={3} /> :
        <>
          {myBalance && (
            <div className="m-mini-stats">
              <div className="m-ms"><div className="m-ms-v">{myBalance.remaining}</div><div className="m-ms-l">{t('days left')}</div></div>
              <div className="m-ms"><div className="m-ms-v">{myBalance.used}</div><div className="m-ms-l">{t('used')}</div></div>
              <div className="m-ms"><div className="m-ms-v">{pending.length}</div><div className="m-ms-l">{t('pending')}</div></div>
            </div>
          )}
          {myPto.length === 0
            ? <MEmpty title={t("No time-off requests")} sub={t("Tap + to ask for time off.")}/>
            : (
              <>
                {pending.length > 0 && <div className="m-section-hd">{t('Pending')}</div>}
                {pending.map(p => <PtoCard key={p.id} p={p} status="pn"/>)}
                {approved.length > 0 && <div className="m-section-hd">{t('Approved')}</div>}
                {approved.map(p => <PtoCard key={p.id} p={p} status="ok"/>)}
                {denied.length > 0 && <div className="m-section-hd">{t('Denied')}</div>}
                {denied.map(p => <PtoCard key={p.id} p={p} status="no"/>)}
              </>
            )}
        </>
      )}

      {seg === 'tickets' && (
        tix.loading ? <MSkel rows={3} /> :
        myTickets.length === 0
          ? <MEmpty title={t("No tickets")} sub={t("Tap + to open a new one.")}/>
          : myTickets.map(t => <TicketCard key={t.id} t={t} />)
      )}

      <button className="m-fab" onClick={() => setSheetOpen(true)}
        aria-label={seg==='pto'?'New time off':'New ticket'}>+</button>
      {sheetOpen && (
        seg === 'pto'
          ? <NewPtoSheet onClose={() => { setSheetOpen(false); reloadPto(); }} />
          : <NewTicketSheet onClose={() => { setSheetOpen(false); reloadTix(); }} />
      )}
    </>
  );
}

function PtoCard({ p, status }) {
  const range = (() => {
    const a = p.start_date, b = p.end_date;
    if (a && b && a !== b) return `${m_dateShort(a)} — ${m_dateShort(b)}`;
    return m_dateShort(a);
  })();
  const days = (() => {
    if (!p.start_date) return null;
    const a = new Date(p.start_date), b = new Date(p.end_date || p.start_date);
    return Math.max(1, Math.round((b - a) / 86400000) + 1);
  })();
  const kindLabel = (p.pto_type || 'pto').replace(/_/g, ' ');
  return (
    <div className="m-reqcard">
      <div className="m-rwhen">{range}</div>
      <span className={`m-pill m-pill-${status}`}>{status === 'ok' ? t('approved') : status === 'no' ? t('denied') : t('pending')}</span>
      <div className="m-rwhy">
        {kindLabel}{days ? ` · ${days} ${days===1?'day':'days'}` : ''}
        {p.reason ? ` · ${p.reason}` : ''}
      </div>
    </div>
  );
}

// ────────────────────────────────────────────────────────────────────
//  ON-CALL
// ────────────────────────────────────────────────────────────────────
// v3.11.0 — an upcoming on-call shift with a one-tap "request cover".
function UpcomingShiftRow({ s }) {
  const [state, setState] = useState('idle'); // idle | sending | sent
  const requestCover = async () => {
    if (state !== 'idle') return;
    setState('sending');
    try {
      const r = await fetch(`/api/me/oncall-shifts/${s.id}/cover-request`, {
        method:'POST', credentials:'include',
        headers:{'Content-Type':'application/json'},
        body: JSON.stringify({ note: '' }),
      });
      setState(r.ok ? 'sent' : 'idle');
    } catch (_) { setState('idle'); }
  };
  return (
    <div className="m-shift mine" style={{ flexWrap:'wrap' }}>
      <span className="m-shift-name">
        {m_dateShort(s.start_at || s.start_date)}
        {s.end_at ? ` → ${m_dateShort(s.end_at)}` : ''}
      </span>
      <span className="m-shift-when">{s.team_name}</span>
      <span className="m-shift-role">{s.is_primary ? 'primary' : (s.role || 'backup')}</span>
      {state === 'sent'
        ? <span className="m-cover-sent">✓ {t('cover requested')}</span>
        : <button className="m-cover-btn" onClick={requestCover} disabled={state==='sending'}>
            {state==='sending' ? '…' : t('Request cover')}
          </button>}
    </div>
  );
}

function MobileOnCall({ me, bell }) {
  const team = encodeURIComponent(me?.team || 'VPSie');
  const [now]   = useApi(`/api/oncall/current?team=${team}`);
  const [list]  = useApi('/api/oncall/active');
  // v3.0.5: dedicated endpoint that returns every shift the current
  // user has been assigned, past + future. Replaces filtering /active
  // (which was missing past shifts and shifts outside the team's
  // current rotation window).
  const [mine]  = useApi('/api/me/oncall-shifts?days_back=14&days_fwd=180');

  const oncall = now.value?.oncall || null;
  const allMine = mine.value || (list.value || []).filter(s => s.user_id === me?.user_id);
  const myUpcoming = allMine.filter(s =>
    new Date(s.end_at || s.end_date || s.start_at || s.start_date).getTime() > Date.now() - 24*3600000
  );
  const myPast = allMine.filter(s =>
    new Date(s.end_at || s.end_date || s.start_at || s.start_date).getTime() <= Date.now() - 24*3600000
  );
  const nextShift = myUpcoming
    .sort((a, b) => new Date(a.start_at || a.start_date) - new Date(b.start_at || b.start_date))
    [0];
  // For backward compat with later code referencing myShifts
  const myShifts = allMine;

  return (
    <>
      <MPageHd
        crumb={me?.team ? `team · ${me.team}` : 'rotation'}
        title={t('On call')}
        sub={t("Who has the pager, when you’re next.")}
        right={<><div style={{display:"flex",gap:8,alignItems:"center"}}>{bell}<MAvatar me={me} size={36} /></div></>}
      />

      {now.loading ? (
        <div className="m-now"><div className="m-skel" style={{height:60,background:'transparent'}}/></div>
      ) : oncall ? (
        <div className="m-now">
          <span className="m-now-eb">Right now · {oncall.team_name}</span>
          <div className="m-now-name">
            {oncall.display_name}
            {oncall.is_primary ? '' : <em> · backup</em>}
          </div>
          <div className="m-now-meta">
            {oncall.role || (oncall.is_primary ? 'primary' : 'backup')}
            {oncall.end_at && ` · until ${m_dateShort(oncall.end_at)}`}
          </div>
          <div className="m-avs">
            <span className="m-av" style={{background:'#a3eeff'}}>{m_initial(oncall.display_name)}</span>
          </div>
        </div>
      ) : (
        <div className="m-now">
          <span className="m-now-eb">{t('No one on call')}</span>
          <div className="m-now-meta">{t("Your team has no active rotation right now.")}</div>
        </div>
      )}

      <div className="m-card">
        <div className="m-ctop"><span className="m-clbl">{t('Your next shift')}</span></div>
        {list.loading ? <div className="m-skel" style={{height:36, background:'transparent'}}/> :
         nextShift ? (
          <>
            <div className="m-card-big">
              <em>{m_dateShort(nextShift.start_at || nextShift.start_date)}</em>
              {' · '}
              {Math.max(0, Math.ceil(
                (new Date(nextShift.start_at || nextShift.start_date) - Date.now()) / 86400000
              ))} days
            </div>
            <div className="m-card-sub">
              {(nextShift.role || (nextShift.is_primary ? 'primary' : 'backup'))}
              {nextShift.end_at && ` · until ${m_dateShort(nextShift.end_at)}`}
              {nextShift.team_name && ` · ${nextShift.team_name}`}
            </div>
          </>
        ) : (
          <div className="m-card-sub" style={{opacity:.55}}>{t("none scheduled · you’re free.")}</div>
        )}
      </div>

      {/* All my upcoming shifts (not just the next one) — what the
          employee explicitly asked for: 'if anything is scheduled for me
          on the on-call calendar, show it here'. */}
      {myUpcoming.length > 0 && (
        <div className="m-card">
          <div className="m-ctop">
            <span className="m-clbl">{t('Upcoming · your shifts')}</span>
            <span className="m-row-r">{myUpcoming.length}</span>
          </div>
          <div className="m-shift-list">
            {myUpcoming
              .sort((a,b) => new Date(a.start_at || a.start_date) - new Date(b.start_at || b.start_date))
              .map(s => <UpcomingShiftRow key={s.id} s={s} />)}
          </div>
        </div>
      )}
      {myPast.length > 0 && (
        <div className="m-card" style={{ opacity: 0.85 }}>
          <div className="m-ctop">
            <span className="m-clbl">{t('Past · your shifts')}</span>
            <span className="m-row-r">{myPast.length}</span>
          </div>
          <div className="m-shift-list">
            {myPast
              .sort((a,b) => new Date(b.start_at || b.start_date) - new Date(a.start_at || a.start_date))
              .slice(0, 10)
              .map(s => (
                <div key={s.id} className="m-shift" style={{ opacity: 0.8 }}>
                  <span className="m-shift-name">
                    {m_dateShort(s.start_at || s.start_date)}
                    {s.end_at ? ` → ${m_dateShort(s.end_at)}` : ''}
                  </span>
                  <span className="m-shift-when">{s.team_name}</span>
                  <span className="m-shift-role">{s.is_primary ? 'primary' : (s.role || 'backup')}</span>
                </div>
              ))}
          </div>
        </div>
      )}

      {(list.value || []).filter(s => s.team_name === me?.team && s.user_id !== me?.user_id).length > 0 && (
        <div className="m-card">
          <div className="m-ctop"><span className="m-clbl">{me?.team} rotation</span></div>
          <div className="m-shift-list">
            {(list.value || [])
              .filter(s => s.team_name === me?.team)
              .sort((a,b) => new Date(a.start_at || a.start_date) - new Date(b.start_at || b.start_date))
              .slice(0, 10)
              .map(s => (
                <div key={s.id} className={`m-shift ${s.user_id === me?.user_id ? 'mine' : ''}`}>
                  <span className="m-shift-name">{s.display_name}</span>
                  <span className="m-shift-when">
                    {m_dateShort(s.start_at || s.start_date)}
                    {s.end_at ? ` → ${m_dateShort(s.end_at)}` : ''}
                  </span>
                  <span className="m-shift-role">{s.is_primary ? 'primary' : (s.role || 'backup')}</span>
                </div>
              ))}
          </div>
        </div>
      )}
    </>
  );
}

// ────────────────────────────────────────────────────────────────────
//  SUPPORT
// ────────────────────────────────────────────────────────────────────
function MobileSupport({ me, bell }) {
  // v3.12 — show ALL VPSie-Support tickets (open / in-progress / done this
  // week), matching the status.k9.ms support tab, not just the user's own.
  const [tix, reloadTix] = useApi('/api/support/tickets?status=all&limit=200');
  const [stats]          = useApi('/api/support/tickets/stats');
  const [sheetOpen, setSheetOpen] = useState(false);

  const items = tix.value || [];
  const isDone = (t) => /resolved|closed/i.test(t.status || '');
  const isInProg = (t) => /acknowledg|in.?progress|wait/i.test(t.status || '');
  // Monday 00:00 of the current week.
  const ws = (() => { const d = new Date(); const day = (d.getDay()+6)%7; d.setHours(0,0,0,0); d.setDate(d.getDate()-day); return d.getTime(); })();
  const openNew   = items.filter(t => !isDone(t) && !isInProg(t));
  const inProg    = items.filter(t => !isDone(t) && isInProg(t));
  const doneWeek  = items.filter(t => isDone(t) && new Date(t.resolved_at || t.updated_at || 0).getTime() >= ws);
  const s = stats.value || {};

  return (
    <>
      <MPageHd
        crumb={`${openNew.length} open · ${inProg.length} in progress`}
        title={t('Support')}
        sub={t("All VPSie-Support tickets — open, in progress, done this week.")}
        right={<><div style={{display:"flex",gap:8,alignItems:"center"}}>{bell}<MAvatar me={me} size={36} /></div></>}
      />
      {(s.open || s.in_progress || s.resolved_this_week) ? (
        <div className="m-mini-stats">
          <div className="m-ms"><div className="m-ms-v">{s.open ?? openNew.length}</div><div className="m-ms-l">{t('open')}</div></div>
          <div className="m-ms"><div className="m-ms-v">{s.in_progress ?? inProg.length}</div><div className="m-ms-l">{t('in progress')}</div></div>
          <div className="m-ms"><div className="m-ms-v">{s.resolved_this_week ?? doneWeek.length}</div><div className="m-ms-l">{t('done this wk')}</div></div>
        </div>
      ) : null}

      {tix.loading ? <MSkel rows={3} /> : (
        <>
          {items.length === 0
            ? <MEmpty title={t("No tickets")} sub={t("Tap + to open one — IT, hardware, access, anything.")} />
            : (
              <>
                {openNew.length > 0 && <div className="m-section-hd">{t('Open')}</div>}
                {openNew.map(t => <TicketCard key={t.id} t={t} />)}
                {inProg.length > 0 && <div className="m-section-hd">{t('In progress')}</div>}
                {inProg.map(t => <TicketCard key={t.id} t={t} />)}
                {doneWeek.length > 0 && <div className="m-section-hd">{t('Done this week')}</div>}
                {doneWeek.map(t => <TicketCard key={t.id} t={t} dim />)}
              </>
            )}
        </>
      )}
      <button className="m-fab" onClick={() => setSheetOpen(true)} aria-label="New ticket">+</button>
      {sheetOpen && <NewTicketSheet onClose={() => { setSheetOpen(false); reloadTix(); }} />}
    </>
  );
}

function TicketCard({ t, dim }) {
  const [status, setStatus] = useState(String(t.status || 'open').toLowerCase());
  const [busy, setBusy] = useState(false);
  const kind = /resolved|closed/.test(status) ? 'ok'
             : /in.?progress|acknowledged|wait/.test(status) ? 'pn'
             : 'pn';
  const sev = String(t.severity || '').toLowerCase();
  const isOpen = !/resolved|closed/.test(status);
  // v3.8.x — submitter can acknowledge / resolve their own ticket inline.
  const patch = async (newStatus) => {
    if (busy) return;
    setBusy(true);
    try {
      const r = await fetch(`/api/support/tickets/${t.id}`, {
        method:'PATCH', credentials:'include',
        headers:{'Content-Type':'application/json'},
        body: JSON.stringify({ status: newStatus }),
      });
      if (r.ok) { setStatus(newStatus); window.dispatchEvent(new CustomEvent('attendance-updated')); }
    } catch (_) {}
    finally { setBusy(false); }
  };
  return (
    <div className={`m-ticket ${dim ? 'm-ticket-dim' : ''}`}>
      <div className="m-ttop">
        <span className="m-ttitle">{t.issue || i18n('Untitled')}</span>
        <span className="m-tcode">{t.ticket_number || ('#' + t.id)}</span>
      </div>
      <div className="m-tmeta">
        <span className={`m-pill m-pill-${kind}`}>{status.replace('_', ' ')}</span>
        {sev && <><span className="m-sep">·</span><span style={{textTransform:'lowercase'}}>{sev}</span></>}
        {t.team_name && <><span className="m-sep">·</span><span>{t.team_name}</span></>}
        <span className="m-sep">·</span>
        <span>{t.resolved_at ? `resolved ${m_ago(t.resolved_at)}` : `updated ${m_ago(t.updated_at || t.created_at)}`}</span>
      </div>
      {t.assigned_to_name && (
        <div style={{ fontSize: 10.5, opacity: 0.55, fontFamily:"'Manrope'", marginTop: 2 }}>
          assigned to {t.assigned_to_name}
        </div>
      )}
      {isOpen && (
        <div className="m-tactions">
          {status !== 'acknowledged' && (
            <button className="m-tbtn" onClick={() => patch('acknowledged')} disabled={busy}>{i18n('Acknowledge')}</button>
          )}
          <button className="m-tbtn m-tbtn-ok" onClick={() => patch('resolved')} disabled={busy}>
            {busy ? '…' : i18n('Mark resolved')}
          </button>
        </div>
      )}
    </div>
  );
}

// ────────────────────────────────────────────────────────────────────
//  PROFILE
// ────────────────────────────────────────────────────────────────────
function MobileProfile({ me, bell, theme, setTheme }) {
  const [editField, setEditField] = useState(null); // null | 'display_name' | 'private_email' | 'cell_phone' | 'alt_phone' | 'timezone'
  const [savedMe, setSavedMe] = useState(me);
  useEffect(() => { setSavedMe(me); }, [me]);
  const [exporting, setExporting] = useState(false);
  const [avatarBusy, setAvatarBusy] = useState(false);
  // v3.8.x — upload a profile photo (max 2 MB). Posts multipart to
  // /api/users/me/avatar, then refreshes the cached profile so it shows.
  const uploadAvatar = async (file) => {
    if (!file || avatarBusy) return;
    if (file.size > 2 * 1024 * 1024) { alert('Image too large (max 2 MB).'); return; }
    setAvatarBusy(true);
    try {
      const fd = new FormData();
      fd.append('file', file);
      const r = await fetch('/api/users/me/avatar', { method:'POST', credentials:'include', body: fd });
      if (r.ok) {
        const d = await r.json().catch(() => ({}));
        const u = d.url || d.avatar_url;
        // cache-bust so the new image shows immediately
        setSavedMe(m => ({ ...m, avatar_url: u ? `${u}${u.includes('?')?'&':'?'}_t=${Date.now()}` : (m && m.avatar_url) }));
        window.dispatchEvent(new CustomEvent('attendance-updated'));
      } else {
        alert('Upload failed. Try a smaller image.');
      }
    } catch (_) { alert('Upload failed.'); }
    finally { setAvatarBusy(false); }
  };
  // v3.8.x — export the user's own attendance/standups as CSV. Native iOS
  // routes through the Share Sheet (Files/Mail/etc); web triggers a download.
  const exportAttendance = async (format) => {
    if (exporting) return;
    setExporting(true);
    try {
      const isPdf = format === 'pdf';
      const r = await fetch(`/api/me/export?days=90&format=${isPdf ? 'pdf' : 'csv'}`, { credentials:'include' });
      if (!r.ok) throw new Error('export failed');
      const blob = await r.blob();
      const fname = isPdf ? 'my-attendance.pdf' : 'my-attendance.csv';
      const cap = window.Capacitor;
      const FS = cap?.Plugins?.Filesystem;
      const SH = cap?.Plugins?.Share;
      if (cap?.isNativePlatform?.() && FS && SH) {
        // Filesystem wants base64 for binary; read blob -> dataURL -> strip prefix.
        const b64 = await new Promise((res) => {
          const fr = new FileReader(); fr.onloadend = () => res(String(fr.result).split(',')[1] || ''); fr.readAsDataURL(blob);
        });
        const w = await FS.writeFile({ path: fname, data: b64, directory:'CACHE' });
        await SH.share({ title:'My attendance', text:'My K9 attendance export', url: w.uri });
      } else {
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a'); a.href = url; a.download = fname; a.click();
        setTimeout(() => URL.revokeObjectURL(url), 1000);
      }
    } catch (_) { alert('Export failed.'); }
    finally { setExporting(false); }
  };
  const [sys, setSys] = useState(() => localStorage.getItem('m_theme_sys') === '1');
  const flipSys = () => {
    const v = !sys; setSys(v);
    localStorage.setItem('m_theme_sys', v ? '1' : '0');
    if (v) setTheme(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
  };
  // v4.4.0 — Face ID app-lock (on by default; scan on every cold open).
  const [faceLockOn, setFaceLockOn] = useState(() => {
    try { return localStorage.getItem('m_face_lock') !== 'off'; } catch (_) { return true; }
  });
  const flipFaceLock = () => {
    const next = !faceLockOn;
    try { localStorage.setItem('m_face_lock', next ? 'on' : 'off'); } catch (_) {}
    setFaceLockOn(next);
  };
  // v4.1.0 — "Hey Pulse" wake word (opt-in, native, on-device).
  const [wakeOn, setWakeOn] = useState(() => localStorage.getItem('m_pulse_wake') === 'on');
  const [wakeNote, setWakeNote] = useState(localStorage.getItem('m_pulse_wake') === 'on' ? 'Listening for “Hey Pulse” whenever the app is open.' : '');
  // Reflect the live native recogniser status (build 51+ emits 'wakestatus').
  useEffect(() => {
    const onStatus = (e) => {
      const d = (e && e.detail) || {};
      if (localStorage.getItem('m_pulse_wake') !== 'on') return;
      if (d.state === 'listening') setWakeNote('🟢 Listening for “Hey Pulse”…');
      else if (d.state === 'error') setWakeNote('⚠️ Mic issue: ' + (d.detail || 'unknown') + '. Check iOS Settings → K9 Pulse → Microphone & Speech.');
    };
    window.addEventListener('pulse-wake-status', onStatus);
    return () => window.removeEventListener('pulse-wake-status', onStatus);
  }, []);
  const flipWake = async () => {
    if (!wakeOn) {
      const r = await K9.wakeWordAvailable();
      if (r && r.missing) {
        const cap = (typeof window !== 'undefined') && window.Capacitor;
        const pb = (cap && cap.Plugins && cap.Plugins.K9Bridge) || {};
        const nat = !!(cap && cap.isNativePlatform && cap.isNativePlatform());
        setWakeNote('Diag → native:' + nat + ' · resolver:' + K9.how()
          + ' · bio:' + (typeof pb.biometricGate) + ' · wake:' + (typeof pb.wakeWordAvailable)
          + ' · err:' + (r.error || 'method-not-found')
          + (nat ? ' — if wake:undefined, the installed binary predates build 49; update in TestFlight.' : ' — open this inside the K9 app, not a browser.'));
        return; }
      if (r && r.available === false) { setWakeNote('Allow microphone + speech access in iOS Settings → K9 Pulse, then try again.'); return; }
      localStorage.setItem('m_pulse_wake', 'on'); setWakeOn(true);
      setWakeNote('Listening for “Hey Pulse” whenever the app is open.');
      // (Re)wire the listener + start the recogniser via the app-level handler.
      try { window.dispatchEvent(new CustomEvent('pulse-wake-set', { detail: { on: true } })); } catch (_) {}
    } else {
      localStorage.setItem('m_pulse_wake', 'off'); setWakeOn(false); setWakeNote('');
      try { window.dispatchEvent(new CustomEvent('pulse-wake-set', { detail: { on: false } })); } catch (_) {}
    }
  };
  const signOut = async () => {
    // 0. Tell server to forget our APNs token so this device stops
    //    receiving pushes for this user.
    try {
      const t = localStorage.getItem('m_push_token');
      if (t) {
        await fetch(`/api/me/push-token?token=${encodeURIComponent(t)}`, {
          method:'DELETE', credentials:'include',
        });
      }
    } catch (_) {}
    // Cancel any scheduled local reminders so the next user on this
    // device doesn't get my reminders.
    try {
      const Local = window.Capacitor?.Plugins?.LocalNotifications;
      if (Local) { try { await Local.cancel({ notifications: [{id:901},{id:902}] }); } catch(_){} }
    } catch (_) {}
    // 1. Tell server to clear the session cookie.
    try { await fetch('/api/auth/logout', { method:'POST', credentials:'include' }); } catch (_) {}
    // 2. v3.12 — KEEP the trusted-device token so the user can sign back in
    //    with one tap (no OTP). We only set an intentional-logout flag so the
    //    auto-trust on next mount lands them on the login screen instead of
    //    silently re-authenticating. The login screen then offers a one-tap
    //    "Log in" / Face ID using the preserved token.
    try {
      localStorage.setItem('m_signed_out', '1');
      localStorage.removeItem('m_force');
    } catch (_) {}
    location.reload();
  };

  return (
    <>
      <div className="m-profhd">
        <label className="m-avatar-edit" aria-label="Change profile photo"
          style={{ width: 96, height: 96, position:'relative', cursor:'pointer', display:'inline-block' }}>
          <MAvatar me={savedMe} size={96} />
          <span className="m-avatar-cam" aria-hidden="true">{avatarBusy ? '…' : '📷'}</span>
          <input type="file" accept="image/*" style={{ display:'none' }}
            onChange={e => uploadAvatar(e.target.files && e.target.files[0])} />
        </label>
        <div className="m-pname">{me?.display_name || 'You'}</div>
        <div className="m-prole">
          {(me?.org_role || 'employee').replace(/_/g, ' ')}
          {me?.team && <> · {me.team}</>}
        </div>
        <div className="m-mono" style={{ fontSize:10.5, opacity:.55, letterSpacing:'.08em' }}>
          {me?.matrix_id}
        </div>
      </div>

      <div className="m-section-hd">Appearance</div>
      <div className="m-list">
        <div className="m-row">
          <span className="m-row-l">{t('Theme')}</span>
          <div className="m-theme-seg">
            <button className={theme==='dark'?'on':''}  onClick={() => { setSys(false); localStorage.setItem('m_theme_sys','0'); setTheme('dark'); }}>{t('Dark')}</button>
            <button className={theme==='light'?'on':''} onClick={() => { setSys(false); localStorage.setItem('m_theme_sys','0'); setTheme('light'); }}>{t('Light')}</button>
          </div>
        </div>
        <div className="m-row">
          <span className="m-row-l">{t('Language')}</span>
          <div className="m-theme-seg">
            <button className={m_lang()==='en'?'on':''} onClick={() => m_setLang('en')}>English</button>
            <button className={m_lang()==='ar'?'on':''} onClick={() => m_setLang('ar')}>العربية</button>
          </div>
        </div>
        <div className="m-row">
          <span className="m-row-l">{t('Use system theme')}</span>
          <span className={`m-toggle ${sys?'':'off'}`} onClick={flipSys}/>
        </div>
        <div className="m-row">
          <span className="m-row-l">{t('Face ID lock')}</span>
          <span className={`m-toggle ${faceLockOn?'':'off'}`} onClick={flipFaceLock}/>
        </div>
        <div className="m-row">
          <span className="m-row-l">&ldquo;Hey&nbsp;Pulse&rdquo; wake word</span>
          <span className={`m-toggle ${wakeOn?'':'off'}`} onClick={flipWake}/>
        </div>
        {wakeNote && <div className="m-row"><span className="m-row-l" style={{fontSize:'12.5px',color:'#b6aacf'}}>{wakeNote}</span></div>}
      </div>

      <div className="m-section-hd">Account · tap to edit</div>
      <div className="m-list">
        <EditableRow label="Display name" value={savedMe?.display_name}
          onTap={() => setEditField('display_name')} />
        <EditableRow label="Email"        value={savedMe?.private_email}
          mono onTap={() => setEditField('private_email')} placeholder="add an email"/>
        <EditableRow label="Cell"         value={savedMe?.cell_phone}
          mono onTap={() => setEditField('cell_phone')} placeholder="add a number"/>
        <EditableRow label="Other phone"  value={savedMe?.alt_phone}
          mono onTap={() => setEditField('alt_phone')} placeholder="optional"/>
        <EditableRow label="Timezone"     value={savedMe?.timezone}
          onTap={() => setEditField('timezone')} placeholder="e.g. America/New_York"/>
        <div className="m-row">
          <span className="m-row-l">Team</span>
          <span className="m-row-r">{savedMe?.team || '—'}</span>
        </div>
        <div className="m-row">
          <span className="m-row-l">App version</span>
          <span className="m-row-r m-mono">4.3.0 · 52</span>
        </div>
      </div>

      <div className="m-section-hd">Team &amp; rooms</div>
      <MyRoomsSection />

      <div className="m-section-hd">Notifications</div>
      <NotifSection />

      <div className="m-section-hd">What you’re notified about</div>
      <NotifPrefsSection />

      <div className="m-section-hd">Your data</div>
      <div className="m-row" style={{ gap: 8 }}>
        <span className="m-row-l">⬇ Export my attendance</span>
        <span style={{ display:'flex', gap:8, marginLeft:'auto' }}>
          <button className="m-export-btn" onClick={() => exportAttendance('pdf')} disabled={exporting}>
            {exporting ? '…' : 'PDF'}
          </button>
          <button className="m-export-btn" onClick={() => exportAttendance('csv')} disabled={exporting}>
            {exporting ? '…' : 'CSV'}
          </button>
        </span>
      </div>

      <button className="m-signout" onClick={signOut}>Sign out</button>

      {editField && (
        <ProfileEditSheet
          field={editField}
          current={savedMe?.[editField] || ''}
          onClose={(updated) => {
            setEditField(null);
            if (updated) setSavedMe(updated);
          }}
        />
      )}
    </>
  );
}

function EditableRow({ label, value, onTap, placeholder, mono }) {
  return (
    <button type="button" className="m-row m-row-tap" onClick={onTap}>
      <span className="m-row-l">{label}</span>
      <span className={`m-row-r ${mono ? 'm-mono' : ''}`} style={{
        color: value ? undefined : 'var(--text-3, #888)',
        opacity: value ? 0.85 : 0.4,
      }}>
        {value || placeholder || '—'}
        <span style={{ marginLeft: 8, opacity: 0.45 }}>›</span>
      </span>
    </button>
  );
}

const FIELD_META = {
  display_name:  { title: 'Display name', kind: 'text',  ph: 'Your full name' },
  private_email: { title: 'Email',        kind: 'email', ph: 'you@example.com' },
  cell_phone:    { title: 'Cell phone',   kind: 'tel',   ph: '+1 555 123 4567' },
  alt_phone:     { title: 'Other phone',  kind: 'tel',   ph: '+1 555 987 6543' },
  timezone:      { title: 'Timezone',     kind: 'text',  ph: 'America/New_York' },
};
// v3.12 — the user's team + the Matrix rooms they're a member of.
function MyRoomsSection() {
  const [data] = useApi('/api/me/rooms');
  const [showAll, setShowAll] = useState(false);
  const d = data.value || {};
  const rooms = d.rooms || [];
  if (data.loading) return <MSkel rows={2} />;
  const named = rooms.filter(r => r.name);
  const shown = showAll ? named : named.slice(0, 8);
  return (
    <div className="m-list">
      <div className="m-row">
        <span className="m-row-l">Team</span>
        <span className="m-row-r">{d.team || '—'}</span>
      </div>
      <div className="m-row">
        <span className="m-row-l">Rooms you’re in</span>
        <span className="m-row-r">{d.count || 0}</span>
      </div>
      {shown.map(r => (
        <div key={r.room_id} className="m-row">
          <span className="m-row-l" style={{ fontWeight:500 }}>{r.name}</span>
          {r.type && <span className="m-row-r" style={{ opacity:0.6, fontSize:12 }}>{r.type}</span>}
        </div>
      ))}
      {named.length > 8 && (
        <button className="m-row m-row-tap" onClick={() => setShowAll(s => !s)}
          style={{ width:'100%', border:'none', background:'transparent', cursor:'pointer' }}>
          <span className="m-row-l" style={{ opacity:0.7 }}>{showAll ? 'Show fewer' : `Show all ${named.length}`}</span>
          <span className="m-row-r">{showAll ? '▲' : '▼'}</span>
        </button>
      )}
    </div>
  );
}

function NotifPrefsSection() {
  // GET /api/me/notif-prefs returns the merged dict; PUT writes back.
  const [prefs, setPrefs]   = useState(null);
  const [saving, setSaving] = useState(false);
  useEffect(() => {
    fetch('/api/me/notif-prefs', { credentials: 'include' })
      .then(r => r.ok ? r.json() : null)
      .then(p => setPrefs(p))
      .catch(() => setPrefs({}));
  }, []);
  const toggle = (key) => {
    const next = { ...(prefs || {}), [key]: !(prefs?.[key] ?? true) };
    setPrefs(next);
    setSaving(true);
    fetch('/api/me/notif-prefs', {
      method: 'PUT', credentials: 'include',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ prefs: next }),
    }).finally(() => setSaving(false));
  };
  const labels = [
    ['pto_approved',     'PTO approved'],
    ['pto_declined',     'PTO declined'],
    ['ticket_assigned',  'Ticket assigned'],
    ['ticket_resolved',  'Ticket resolved'],
    ['strike',           'Strikes / violations'],
    ['weekly_recap',     'Friday weekly recap'],
    ['reminder_signin',  'Morning sign-in reminder'],
    ['reminder_signoff', 'Evening sign-off reminder'],
  ];
  if (prefs == null) return <MSkel rows={3} />;
  return (
    <div className="m-list">
      {labels.map(([k, l]) => (
        <div key={k} className="m-row">
          <span className="m-row-l">{l}</span>
          <span className={`m-toggle ${prefs?.[k] === false ? 'off' : ''}`}
                onClick={() => toggle(k)} />
        </div>
      ))}
    </div>
  );
}

function NotifSection() {
  const [optedOut, setOptedOut] = useState(() => localStorage.getItem('m_notif_optout') === '1');
  const [inH, setInH]   = useState(() => parseInt(localStorage.getItem('m_remind_in_hour')  || '9',  10));
  const [outH, setOutH] = useState(() => parseInt(localStorage.getItem('m_remind_out_hour') || '17', 10));
  const pushToken = (typeof window !== 'undefined' && localStorage.getItem('m_push_token')) || '';
  const reschedule = async (newInH, newOutH, optOut) => {
    try {
      const Local = window.Capacitor?.Plugins?.LocalNotifications;
      if (!Local) return;
      try { await Local.cancel({ notifications: [{id: 901}, {id: 902}] }); } catch (_) {}
      if (optOut) return;
      await Local.schedule({ notifications: [
        { id: 901, title: 'Good morning, sign in', body: 'Open K9 Pulse to start your day.',
          sound: 'reminder.caf', schedule: { on: { hour: newInH, minute: 0 }, allowWhileIdle: true, every: 'day' } },
        { id: 902, title: 'Wrap up — sign off', body: 'Take a moment to sign off your day.',
          sound: 'reminder.caf', schedule: { on: { hour: newOutH, minute: 0 }, allowWhileIdle: true, every: 'day' } },
      ]});
    } catch (_) {}
  };
  const flipOpt = async () => {
    const v = !optedOut;
    setOptedOut(v);
    localStorage.setItem('m_notif_optout', v ? '1' : '0');
    await reschedule(inH, outH, v);
  };
  const updHour = (key, setter) => async (e) => {
    const v = Math.max(0, Math.min(23, parseInt(e.target.value, 10) || 0));
    setter(v);
    localStorage.setItem(key, String(v));
    await reschedule(
      key === 'm_remind_in_hour'  ? v : inH,
      key === 'm_remind_out_hour' ? v : outH,
      optedOut,
    );
  };
  const fmtHour = (h) => {
    const ap = h >= 12 ? 'pm' : 'am';
    const hh = h % 12 || 12;
    return `${hh}:00 ${ap}`;
  };
  return (
    <div className="m-list">
      <div className="m-row">
        <span className="m-row-l">Reminders &amp; pushes</span>
        <span className={`m-toggle ${optedOut?'off':''}`} onClick={flipOpt}/>
      </div>
      {!optedOut && (
        <>
          <div className="m-row">
            <span className="m-row-l">Sign-in reminder</span>
            <input type="number" min="0" max="23" value={inH} onChange={updHour('m_remind_in_hour', setInH)}
              style={{ width: 64, background: 'transparent', border:'1px solid currentColor', borderColor:'#ffffff22', borderRadius: 8, padding:'6px 10px', textAlign:'right', fontFamily:"'JetBrains Mono',monospace", color:'inherit', fontSize: 12 }} />
          </div>
          <div className="m-row">
            <span className="m-row-l">Sign-off reminder</span>
            <input type="number" min="0" max="23" value={outH} onChange={updHour('m_remind_out_hour', setOutH)}
              style={{ width: 64, background: 'transparent', border:'1px solid #ffffff22', borderRadius: 8, padding:'6px 10px', textAlign:'right', fontFamily:"'JetBrains Mono',monospace", color:'inherit', fontSize: 12 }} />
          </div>
          <div className="m-row">
            <span className="m-row-l">Daily schedule</span>
            <span className="m-row-r m-mono">{fmtHour(inH)} · {fmtHour(outH)}</span>
          </div>
          <div className="m-row">
            <span className="m-row-l">Server pushes</span>
            <span className="m-row-r m-mono" style={{ color: pushToken ? '#92e7a8' : '#ff9b9b' }}>
              {pushToken ? 'registered' : 'pending'}
            </span>
          </div>
        </>
      )}
    </div>
  );
}

function ProfileEditSheet({ field, current, onClose }) {
  const meta = FIELD_META[field] || { title: field, kind: 'text' };
  const [val, setVal]   = useState(current);
  const [busy, setBusy] = useState(false);
  const [err, setErr]   = useState('');
  const save = async () => {
    setBusy(true); setErr('');
    try {
      const r = await fetch('/api/me/profile', {
        method:'PUT', credentials:'include',
        headers:{'Content-Type':'application/json'},
        body: JSON.stringify({ [field]: val }),
      });
      if (!r.ok) throw new Error(await m_parseErr(r));
      const data = await r.json();
      // Build a merged "me" so the row updates without a refetch.
      onClose({ ...data, [field]: data[field] ?? val });
    } catch (e) { setErr(String(e.message || e)); }
    finally { setBusy(false); }
  };
  return (
    <div className="m-sheet-back" onClick={() => onClose(null)}>
      <div className="m-sheet" onClick={e => e.stopPropagation()}>
        <div className="m-sheet-grab" />
        <div className="m-sheet-title">{meta.title}</div>
        <div className="m-sheet-lbl">New value</div>
        <input className="m-input" type={meta.kind === 'email' ? 'email' : meta.kind === 'tel' ? 'tel' : 'text'}
          autoFocus value={val} placeholder={meta.ph}
          onChange={e => setVal(e.target.value)}
          onKeyDown={e => { if (e.key === 'Enter') save(); }}
          inputMode={meta.kind === 'tel' ? 'tel' : meta.kind === 'email' ? 'email' : 'text'}
          autoCapitalize={meta.kind === 'email' ? 'none' : 'words'}
          autoCorrect={meta.kind === 'email' ? 'off' : 'on'}
        />
        {err && <div className="m-err">{err}</div>}
        <button className="m-cta" onClick={save} disabled={busy}>
          {busy ? 'saving…' : 'Save →'}
        </button>
        <button className="m-login-back" onClick={() => onClose(null)}>Cancel</button>
      </div>
    </div>
  );
}

function MSkel({ rows = 3 }) {
  return (
    <div className="m-skel-list">
      {Array.from({length: rows}).map((_, i) => (
        <div key={i} className="m-skel" style={{ height: 56 }} />
      ))}
    </div>
  );
}
function MEmpty({ title, sub }) {
  return (
    <div className="m-empty">
      <div className="m-empty-t">{title}</div>
      <div className="m-empty-s">{sub}</div>
    </div>
  );
}

// ── Sheets ────────────────────────────────────────────────────────────
// ── v3.2.0 SignInSheet — optional note + mic dictation ──────────────
// Uses the browser Web Speech API (webkitSpeechRecognition) which iOS
// Safari/WKWebView 14.5+ supports out of the box. No plugin, no
// network round-trip for the transcription — handled on-device by iOS.
function SignInSheet({ busy, onClose, onSubmit }) {
  // v3.5.4 — three-field standup format: yesterday / today / issues.
  // Matches the format the bot enforces in chat (Today + tasks + issues).
  // Voice dictation drops into whichever field the user last tapped.
  const [yesterday, setYesterday] = useState('');
  const [today, setToday] = useState('');
  const [issues, setIssues] = useState('');
  const [location, setLocation] = useState('office');   // v3.11.0 — office | home
  const [listening, setListening] = useState(false);
  const [activeField, setActiveField] = useState('today');
  const recogRef = useRef(null);
  const supported = typeof window !== 'undefined' && (
    'webkitSpeechRecognition' in window || 'SpeechRecognition' in window
  );

  // v3.10.0 — carry-forward: pull the most recent prior standup so today's
  // "Yesterday" can be pre-filled with what was planned, and offer a one-tap
  // "same as yesterday" to continue ongoing work.
  const [prevToday, setPrevToday] = useState('');
  useEffect(() => {
    let cancel = false;
    (async () => {
      try {
        const r = await fetch('/api/me/standups-history?days=10', { credentials:'include' });
        if (!r.ok) return;
        const rows = await r.json();
        const todayIso = new Date().toISOString().slice(0,10);
        const prior = (Array.isArray(rows) ? rows : []).find(x => x.work_date !== todayIso);
        if (cancel || !prior) return;
        const clean = (t) => (t || '').split('\n').map(s => s.replace(/^- /, '').trim()).filter(Boolean).join('\n');
        const pt = clean(prior.today_text);
        setPrevToday(pt);
        // Pre-fill Yesterday with what you planned for that day (only if empty).
        setYesterday(prevYest => prevYest || pt);
      } catch (_) {}
    })();
    return () => { cancel = true; };
  }, []);

  // v3.10.0 — saved quick-phrases (per-device, editable). Tap to insert into
  // the last-focused field.
  const DEFAULT_PHRASES = ['Code review', 'On-call duties', 'Standup + planning', 'Continuing yesterday’s work'];
  const [phrases, setPhrases] = useState(() => {
    try { const v = JSON.parse(localStorage.getItem('m_quickphrases') || 'null'); return Array.isArray(v) && v.length ? v : DEFAULT_PHRASES; }
    catch (_) { return DEFAULT_PHRASES; }
  });
  const savePhrases = (next) => { setPhrases(next); try { localStorage.setItem('m_quickphrases', JSON.stringify(next)); } catch (_) {} };
  const insertPhrase = (p) => {
    setter(activeField)(t => { const b = (t || '').trim(); return b ? b + '\n' + p : p; });
  };
  const addCurrentAsPhrase = () => {
    const cur = (activeField === 'yesterday' ? yesterday : activeField === 'today' ? today : issues).trim();
    const firstLine = cur.split('\n').map(s=>s.trim()).filter(Boolean)[0];
    if (firstLine && !phrases.includes(firstLine)) savePhrases([...phrases, firstLine].slice(-8));
  };

  const setter = (field) => (field === 'yesterday' ? setYesterday
                            : field === 'today'    ? setToday
                            : setIssues);

  const startDictate = (fieldOverride) => {
    if (listening) return;
    const Ctor = window.SpeechRecognition || window.webkitSpeechRecognition;
    if (!Ctor) return;
    try {
      const r = new Ctor();
      r.lang = (navigator.language || 'en-US');
      r.interimResults = true;
      r.continuous = true;   // v3.10.0 — keep listening for multi-sentence input
      const targetField = fieldOverride || activeField;
      r.onresult = (e) => {
        let acc = '';
        for (let i = e.resultIndex; i < e.results.length; i++) {
          acc += e.results[i][0].transcript;
        }
        setter(targetField)(t => {
          const base = (t || '').trim();
          if (!base) return acc;
          return base + ' ' + acc.trim();
        });
      };
      r.onerror = () => setListening(false);
      r.onend = () => setListening(false);
      recogRef.current = r;
      setListening(true);
      r.start();
    } catch (_) {}
  };
  const stopDictate = () => {
    try { recogRef.current && recogRef.current.stop(); } catch (_) {}
    setListening(false);
  };

  const canSubmit = today.trim().length > 0;

  // Touch-action: manipulation on the sheet to stop iOS WKWebView's
  // double-tap-zoom misfire on tall textareas + buttons. (Was the cause
  // of "screen enlarges and I can't pinch back out".)
  const noZoom = {
    touchAction: 'manipulation',
    WebkitTapHighlightColor: 'transparent',
    WebkitTouchCallout: 'none',
  };

  const renderField = (field, label, placeholder, rows, required) => (
    <>
      <div className="m-sheet-lbl">
        {label}{required && <span style={{ color: '#a3eeff', marginLeft: 4 }}>*</span>}
      </div>
      <div style={{ position: 'relative', marginBottom: 10 }}>
        <textarea
          className="m-textarea"
          rows={rows}
          placeholder={placeholder}
          value={field === 'yesterday' ? yesterday : field === 'today' ? today : issues}
          onChange={e => setter(field)(e.target.value)}
          onFocus={() => setActiveField(field)}
          style={{ width: '100%', paddingRight: supported ? 54 : 12, ...noZoom }}
        />
        {supported && (
          <button
            type="button"
            onClick={() => { setActiveField(field); listening ? stopDictate() : startDictate(field); }}
            aria-label={listening ? 'Stop dictation' : 'Dictate'}
            style={{
              position: 'absolute', right: 8, bottom: 8,
              width: 40, height: 40, borderRadius: 20, border: 'none',
              background: listening ? '#ff4d4d' : '#7c3aed',
              color: '#fff',
              display: 'flex', alignItems: 'center', justifyContent: 'center',
              boxShadow: listening ? '0 0 0 6px #ff4d4d33' : '0 6px 16px -4px #7c3aed88',
              cursor: 'pointer', transition: 'all 200ms', ...noZoom,
            }}>
            <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" style={{ pointerEvents: 'none' }}>
              <rect x="9" y="2" width="6" height="12" rx="3"/>
              <path d="M5 11a7 7 0 0 0 14 0"/>
              <line x1="12" y1="18" x2="12" y2="22"/>
            </svg>
          </button>
        )}
      </div>
    </>
  );

  return (
    <div className="m-sheet-back" onClick={onClose} style={noZoom}>
      <div className="m-sheet" onClick={e => e.stopPropagation()} style={noZoom}>
        <div className="m-sheet-grab" />
        <div className="m-sheet-title">Sign in to <em>your day</em></div>

        {/* v3.10.0 — quick fills: carry yesterday forward + saved phrases.
            Phrases insert into the field you last tapped (defaults to Today). */}
        <div className="m-qchips">
          {prevToday && (
            <button className="m-qchip m-qchip-primary" onClick={() => setToday(prevToday)}>
              ↻ Same as yesterday
            </button>
          )}
          {phrases.map((p, i) => (
            <button key={i} className="m-qchip" onClick={() => insertPhrase(p)}
              onContextMenu={(e) => { e.preventDefault(); savePhrases(phrases.filter((_, j) => j !== i)); }}>
              {p}
            </button>
          ))}
          <button className="m-qchip m-qchip-add" onClick={addCurrentAsPhrase} title="Save current line as a phrase">+ save</button>
        </div>

        {renderField('yesterday', t("Yesterday"), "What you wrapped up yesterday (or 'PTO', 'sick')", 2, false)}
        {renderField('today',     t("Today"),     "What you're working on today",                     3, true)}
        {renderField('issues',    t("Issues"),    "Blockers? Type 'none' if all clear",               1, false)}

        {listening && (
          <div style={{ fontFamily: "'JetBrains Mono'", fontSize: 10.5,
              opacity: 0.7, letterSpacing: '0.12em', textTransform: 'uppercase' }}>
            ● listening… tap mic to stop
          </div>
        )}
        {/* v3.11.0 — where are you working from today? */}
        <div className="m-sheet-lbl">{t('Working from')}</div>
        <div className="m-loc-seg" style={noZoom}>
          <button className={location === 'office' ? 'on' : ''} onClick={() => setLocation('office')}>🏢 {t('Office')}</button>
          <button className={location === 'home' ? 'on' : ''} onClick={() => setLocation('home')}>🏠 {t('Home')}</button>
        </div>
        <button
          className="m-cta"
          onClick={() => onSubmit({ yesterday, today, issues, location })}
          disabled={busy || !canSubmit}
          style={noZoom}
        >
          {busy ? 'signing in…' : (canSubmit ? 'Sign in →' : 'Fill in Today to continue')}
        </button>
        <button className="m-login-back" onClick={onClose} style={{ ...noZoom, minHeight: 44 }}>
          Cancel
        </button>
      </div>
    </div>
  );
}

// ── v3.8.x SignOffSheet — end-of-day recap prompt ─────────────────
// Captures what the user got done (Checkout.recap_text). Recap is optional
// but encouraged; it feeds the weekly recap and closes the standup loop.
function SignOffSheet({ busy, elapsedH, onClose, onSubmit }) {
  const [recap, setRecap] = useState('');
  const noZoom = { touchAction:'manipulation', WebkitTapHighlightColor:'transparent' };
  return (
    <div className="m-sheet-back" onClick={onClose} style={noZoom}>
      <div className="m-sheet" onClick={e => e.stopPropagation()} style={noZoom}>
        <div className="m-sheet-grab" />
        <div className="m-sheet-title">Wrap up <em>your day</em></div>
        <div className="m-sheet-lbl">
          {elapsedH ? `${elapsedH}h today · ` : ''}What did you get done? (optional)
        </div>
        <textarea
          className="m-textarea"
          rows={4}
          autoFocus
          placeholder="Shipped the T07 backup config, unblocked the racking…"
          value={recap}
          onChange={e => setRecap(e.target.value)}
          style={{ width:'100%', ...noZoom }}
        />
        <button className="m-cta" onClick={() => onSubmit(recap)} disabled={busy} style={noZoom}>
          {busy ? 'signing off…' : 'Sign off →'}
        </button>
        <button className="m-login-back" onClick={onClose} style={{ ...noZoom, minHeight:44 }}>
          Cancel
        </button>
      </div>
    </div>
  );
}

// ── v3.8.x RecapSheet — read your own weekly recap ────────────────
function RecapSheet({ recap, onClose }) {
  const noZoom = { touchAction:'manipulation', WebkitTapHighlightColor:'transparent' };
  // Light markdown → lines (bold + bullets), no heavy parser.
  const lines = (recap.summary_md || '').split('\n');
  return (
    <div className="m-sheet-back" onClick={onClose} style={noZoom}>
      <div className="m-sheet m-sheet-tall" onClick={e => e.stopPropagation()} style={noZoom}>
        <div className="m-sheet-grab" />
        <div className="m-sheet-title">Your <em>week</em></div>
        <div className="m-sheet-lbl">Week of {recap.week_start}</div>
        <div className="m-recap-body">
          {lines.map((ln, i) => {
            const t = ln.trim();
            if (!t) return <div key={i} style={{ height:6 }} />;
            const bullet = /^[-*•]\s+/.test(t);
            const heading = /^#{1,3}\s+/.test(t) || /^\*\*.+\*\*$/.test(t);
            const clean = t.replace(/^#{1,3}\s+/, '').replace(/^[-*•]\s+/, '').replace(/\*\*/g, '');
            return (
              <div key={i} className={heading ? 'm-recap-h' : (bullet ? 'm-recap-li' : 'm-recap-p')}>
                {bullet ? '• ' : ''}{clean}
              </div>
            );
          })}
        </div>
        <button className="m-login-back" onClick={onClose} style={{ ...noZoom, minHeight:44 }}>Close</button>
      </div>
    </div>
  );
}

// ── v3.12 PtoRequestSheet — pick type + reason + dates ────────────
const PTO_TYPES = [
  { v:'vacation', label:'🌴 Vacation' },
  { v:'sick',     label:'🤒 Sick day' },
  { v:'personal', label:'🙋 Personal' },
  { v:'holiday',  label:'🎉 Holiday' },
];
function PtoRequestSheet({ preset, busy, onClose, onSubmit }) {
  const todayIso = (() => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; })();
  const [type, setType] = useState(preset === 'sick' ? 'sick' : 'vacation');
  const [reason, setReason] = useState('');
  const [start, setStart] = useState(todayIso);
  const [end, setEnd] = useState(todayIso);
  const noZoom = { touchAction:'manipulation', WebkitTapHighlightColor:'transparent' };
  const bad = end < start;
  return (
    <div className="m-sheet-back" onClick={onClose} style={noZoom}>
      <div className="m-sheet" onClick={e => e.stopPropagation()} style={noZoom}>
        <div className="m-sheet-grab" />
        <div className="m-sheet-title">Request <em>time off</em></div>
        <div className="m-sheet-lbl">Type</div>
        <div className="m-pto-types">
          {PTO_TYPES.map(t => (
            <button key={t.v} className={`m-pto-type ${type===t.v?'on':''}`} onClick={() => setType(t.v)} style={noZoom}>{t.label}</button>
          ))}
        </div>
        <div className="m-pto-dates">
          <label className="m-pto-date"><span>From</span>
            <input type="date" value={start} onChange={e => { setStart(e.target.value); if (e.target.value > end) setEnd(e.target.value); }} style={noZoom} />
          </label>
          <label className="m-pto-date"><span>To</span>
            <input type="date" value={end} min={start} onChange={e => setEnd(e.target.value)} style={noZoom} />
          </label>
        </div>
        <div className="m-sheet-lbl">Reason (optional)</div>
        <textarea className="m-textarea" rows={3} placeholder="A short note for your manager…" value={reason} onChange={e => setReason(e.target.value)} style={{ width:'100%', ...noZoom }} />
        {bad && <div className="m-err">End date is before the start date.</div>}
        <button className="m-cta" disabled={busy || bad} style={noZoom}
          onClick={() => onSubmit({ ptoType: type, reason, start, end })}>
          {busy ? 'sending…' : 'Send request →'}
        </button>
        <button className="m-login-back" onClick={onClose} style={{ ...noZoom, minHeight:44 }}>Cancel</button>
      </div>
    </div>
  );
}

// ── v3.11.0 CalendarSheet — month grid of your own attendance ─────
const CAL_STATUS = {
  present: { bg:'#71d893', label:'Present' },
  late:    { bg:'#ffd28a', label:'Late' },
  remote:  { bg:'#a3eeff', label:'Remote' },
  pto:     { bg:'#9d8bff', label:'PTO' },
  sick:    { bg:'#ff9b9b', label:'Sick' },
  absent:  { bg:'#ff4d4d', label:'Absent' },
  off:     { bg:'transparent', label:'Off' },
  future:  { bg:'transparent', label:'' },
};
function CalendarSheet({ onClose }) {
  const [month, setMonth] = useState(''); // '' = current
  const [data, setData] = useState(null);
  const noZoom = { touchAction:'manipulation', WebkitTapHighlightColor:'transparent' };
  useEffect(() => {
    let cancel = false;
    (async () => {
      setData(null);
      try {
        const q = month ? `?month=${month}` : '';
        const r = await fetch(`/api/me/calendar${q}`, { credentials:'include' });
        if (r.ok && !cancel) setData(await r.json());
      } catch (_) {}
    })();
    return () => { cancel = true; };
  }, [month]);
  const shift = (delta) => {
    const base = data ? data.month : new Date().toISOString().slice(0,7);
    const [y, m] = base.split('-').map(Number);
    const d = new Date(y, m - 1 + delta, 1);
    setMonth(`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}`);
  };
  const monthName = data ? new Date(data.month + '-01T00:00:00').toLocaleDateString(undefined, { month:'long', year:'numeric' }) : '…';
  // Leading blanks so day 1 lands on the right weekday (Mon-first).
  const lead = data ? data.first_weekday : 0;
  return (
    <div className="m-sheet-back" onClick={onClose} style={noZoom}>
      <div className="m-sheet m-sheet-tall" onClick={e => e.stopPropagation()} style={noZoom}>
        <div className="m-sheet-grab" />
        <div className="m-cal-hd">
          <button className="m-cal-nav" onClick={() => shift(-1)} aria-label="Previous month">‹</button>
          <div className="m-sheet-title" style={{ fontSize:24 }}>{monthName}</div>
          <button className="m-cal-nav" onClick={() => shift(1)} aria-label="Next month">›</button>
        </div>
        {!data ? <div style={{ opacity:0.6, padding:'16px 0', fontStyle:'italic' }}>Loading…</div> : (
          <>
            <div className="m-cal-grid m-cal-dow">
              {['M','T','W','T','F','S','S'].map((d,i) => <div key={i} className="m-cal-dow-c">{d}</div>)}
            </div>
            <div className="m-cal-grid">
              {Array.from({ length: lead }).map((_, i) => <div key={'b'+i} />)}
              {data.days.map(day => {
                const st = CAL_STATUS[day.status] || CAL_STATUS.off;
                const n = parseInt(day.date.slice(8), 10);
                return (
                  <div key={day.date} className="m-cal-cell" title={`${day.date} · ${st.label}${day.hours?` · ${day.hours}h`:''}`}>
                    <span className="m-cal-num">{n}</span>
                    <span className="m-cal-dot" style={{ background: st.bg, opacity: st.bg==='transparent'?0.15:1 }} />
                  </div>
                );
              })}
            </div>
            <div className="m-cal-legend">
              {['present','late','remote','pto','absent'].map(k => (
                <span key={k} className="m-cal-leg"><span className="m-cal-dot" style={{ background: CAL_STATUS[k].bg }} />{CAL_STATUS[k].label}</span>
              ))}
            </div>
            <div className="m-cal-totals">
              {data.totals.present}p · {data.totals.late} late · {data.totals.remote} remote · {data.totals.pto} PTO · {data.totals.absent} absent
            </div>
          </>
        )}
        <button className="m-login-back" onClick={onClose} style={{ ...noZoom, minHeight:44 }}>Close</button>
      </div>
    </div>
  );
}

// ── v3.6.0 BlockerSheet — single-field "I'm stuck" prompt ──────────
// Posts the new blocker text via /api/me/checkin; the parent passes the
// previous yesterday/today through so the backend's replace-on-write
// idempotency doesn't lose prior content.
function BlockerSheet({ busy, existing, onClose, onSubmit }) {
  const [text, setText] = useState(existing?.blocker_text || '');
  const noZoom = { touchAction:'manipulation', WebkitTapHighlightColor:'transparent' };
  const canSubmit = text.trim().length > 0;
  return (
    <div className="m-sheet-back" onClick={onClose} style={noZoom}>
      <div className="m-sheet" onClick={e => e.stopPropagation()} style={noZoom}>
        <div className="m-sheet-grab" />
        <div className="m-sheet-title">What&rsquo;s <em>blocking</em> you?</div>
        <div className="m-sheet-lbl">A short note · routed to your lead</div>
        <textarea
          className="m-textarea"
          rows={4}
          autoFocus
          placeholder="The thing that's slowing you down…"
          value={text}
          onChange={e => setText(e.target.value)}
          style={{ width:'100%', ...noZoom }}
        />
        <button
          className="m-cta"
          onClick={() => onSubmit(text.trim())}
          disabled={busy || !canSubmit}
          style={noZoom}
        >
          {busy ? 'flagging…' : (canSubmit ? 'Flag blocker →' : 'Type the blocker first')}
        </button>
        <button className="m-login-back" onClick={onClose} style={{ ...noZoom, minHeight:44 }}>
          Cancel
        </button>
      </div>
    </div>
  );
}

// ── v3.6.0 HistorySheet — last 30 days of your own standups ────────
function HistorySheet({ onClose }) {
  const [items, setItems] = useState(null);
  const [err, setErr] = useState('');
  const noZoom = { touchAction:'manipulation', WebkitTapHighlightColor:'transparent' };

  useEffect(() => {
    let cancel = false;
    (async () => {
      try {
        const r = await fetch('/api/me/standups-history?days=30', { credentials:'include' });
        if (!r.ok) throw new Error(await m_parseErr(r));
        const data = await r.json();
        if (!cancel) setItems(Array.isArray(data) ? data : []);
      } catch (e) { if (!cancel) setErr(String(e.message || e)); }
    })();
    return () => { cancel = true; };
  }, []);

  const fmtDate = (iso) => {
    if (!iso) return '';
    try {
      const d = new Date(iso + 'T00:00:00');
      return d.toLocaleDateString(undefined, { weekday:'short', month:'short', day:'numeric' });
    } catch (_) { return iso; }
  };
  const fmtT = (iso) => {
    const t = m_fmt12(iso);
    return t ? `${t.h}:${t.m}${t.ap}` : '—';
  };
  const scoreBadge = (s) => {
    if (s == null) return null;
    const label = s === 2 ? 'full progress' : s === 1 ? 'partial' : 'no progress';
    const color = s === 2 ? '#71d893' : s === 1 ? '#f1c97a' : '#ff9b9b';
    return (
      <span style={{
        fontFamily:'JetBrains Mono', fontSize:9.5, letterSpacing:'0.14em',
        textTransform:'uppercase', color, opacity:0.85, marginLeft:6,
      }}>{label}</span>
    );
  };

  return (
    <div className="m-sheet-back" onClick={onClose} style={noZoom}>
      <div className="m-sheet m-sheet-tall" onClick={e => e.stopPropagation()} style={noZoom}>
        <div className="m-sheet-grab" />
        <div className="m-sheet-title">Your <em>last 30 days</em></div>
        <div className="m-sheet-lbl">Pulled from your sign-in record</div>
        {err && <div className="m-err">{err}</div>}
        {items === null && !err && (
          <div style={{ opacity:0.6, padding:'14px 0', fontStyle:'italic' }}>Loading…</div>
        )}
        {items !== null && items.length === 0 && (
          <div style={{ opacity:0.6, padding:'14px 0', fontStyle:'italic' }}>
            No standups in the last 30 days. Sign in today to start your history.
          </div>
        )}
        {items && items.length > 0 && (
          <div className="m-hist-list">
            {items.map((it, idx) => (
              <div key={it.work_date + idx} className="m-hist-row">
                <div className="m-hist-head">
                  <span className="m-hist-date">{fmtDate(it.work_date)}</span>
                  <span className="m-hist-time">
                    {fmtT(it.signed_in_at)} → {it.signed_off_at ? fmtT(it.signed_off_at) : <span style={{opacity:0.5}}>open</span>}
                    {it.hours_worked > 0 && <span style={{opacity:0.55, marginLeft:6}}>· {it.hours_worked.toFixed(1)}h</span>}
                    {scoreBadge(it.progress_score)}
                  </span>
                </div>
                {it.yesterday_text && (
                  <div className="m-hist-line"><span className="m-hist-lbl">Yest</span> {it.yesterday_text.replace(/^- /gm, '').split('\n').filter(Boolean).join(' · ')}</div>
                )}
                {it.today_text && (
                  <div className="m-hist-line"><span className="m-hist-lbl">Today</span> {it.today_text.replace(/^- /gm, '').split('\n').filter(Boolean).join(' · ')}</div>
                )}
                {it.has_blocker && it.blocker_text && (
                  <div className="m-hist-line m-hist-blocker"><span className="m-hist-lbl">Blocker</span> {it.blocker_text}</div>
                )}
              </div>
            ))}
          </div>
        )}
        <button className="m-login-back" onClick={onClose} style={{ ...noZoom, minHeight:44 }}>
          Close
        </button>
      </div>
    </div>
  );
}

// ── v4.0.0 Pulse — embedded conversational assistant ───────────────
// Replaces the old Ask-AI FAB + sheet. A bottom-right launcher opens an
// EMBEDDED panel (not a tab) that answers personal-scoped questions,
// auto-navigates to the right tab, and runs confirm-first actions via
// /api/pulse/turn + /api/pulse/execute. Voice in (on-device
// webkitSpeechRecognition) + voice out (speechSynthesis, only when the
// user actually spoke to it).

function PulseMark({ size = 24, pulsing = false }) {
  // v4.1.1 — "Sentinel" compact head: robot in an orb, headphones, antenna,
  // and a teal pulse-wave visor. Reads down to ~20px for the launcher/header.
  return (
    <svg width={size} height={size} viewBox="0 0 64 64" fill="none"
         className={pulsing ? 'm-pulse-mark on' : 'm-pulse-mark'}
         style={{ pointerEvents: 'none' }}>
      <defs>
        <radialGradient id="pulseG" cx="50%" cy="34%" r="72%">
          <stop offset="0%" stopColor="#c4b5fd" />
          <stop offset="50%" stopColor="#8b5cf6" />
          <stop offset="100%" stopColor="#4c1d95" />
        </radialGradient>
      </defs>
      <circle cx="32" cy="32" r="30" fill="url(#pulseG)" />
      <line x1="32" y1="9" x2="32" y2="13.5" stroke="#bff3ff" strokeWidth="2.4" strokeLinecap="round" />
      <circle cx="32" cy="8" r="2.6" fill="#bff3ff" />
      <rect x="11.5" y="27" width="7" height="14" rx="3.5" fill="#3a1d6e" stroke="#7fe8d8" strokeWidth="1.8" />
      <rect x="45.5" y="27" width="7" height="14" rx="3.5" fill="#3a1d6e" stroke="#7fe8d8" strokeWidth="1.8" />
      <rect x="17" y="17" width="30" height="30" rx="11" fill="#6d3ad1" stroke="rgba(255,255,255,.18)" strokeWidth="1.6" />
      <rect x="21" y="23" width="22" height="17.5" rx="7" fill="#0b0422" />
      <path d="M24.5 33 H30 L31.6 28 L34.2 38 L36.2 31.5 L37.4 33 H41.5"
            fill="none" stroke="#7fe8d8" strokeWidth="2.1" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

// v4.2.0 — full "Sentinel" mascot badge (login hero / onboarding welcome).
function PulseMascot({ size = 120 }) {
  return (
    <svg width={size} height={size} viewBox="0 0 200 200" fill="none" style={{ display: 'block' }} aria-hidden="true">
      <defs>
        <radialGradient id="mBg" cx="50%" cy="36%" r="72%"><stop offset="0" stopColor="#43217f" /><stop offset="1" stopColor="#100630" /></radialGradient>
        <linearGradient id="mBody" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stopColor="#a07bf5" /><stop offset="1" stopColor="#5b21b6" /></linearGradient>
        <linearGradient id="mTeal" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stopColor="#bff3ff" /><stop offset="1" stopColor="#2dd4bf" /></linearGradient>
        <radialGradient id="mGlow" cx="50%" cy="42%" r="55%"><stop offset="0" stopColor="#2dd4bf" stopOpacity="0.5" /><stop offset="1" stopColor="#2dd4bf" stopOpacity="0" /></radialGradient>
      </defs>
      <circle cx="100" cy="100" r="97" fill="#0a0420" stroke="#21123f" strokeWidth="3" />
      <circle cx="100" cy="100" r="90" fill="url(#mBg)" />
      <circle cx="100" cy="96" r="64" fill="url(#mGlow)" />
      <g stroke="#7a55c9" strokeWidth="1.5" fill="none" opacity="0.45">
        <path d="M40 40 A85 85 0 0 1 75 20" /><path d="M160 40 A85 85 0 0 0 125 20" />
        <path d="M22 130 A85 85 0 0 0 45 158" /><path d="M178 130 A85 85 0 0 1 155 158" />
      </g>
      <g fill="#8a63d6" opacity="0.7"><circle cx="100" cy="15" r="2.6" /><circle cx="159" cy="33" r="2.1" /><circle cx="185" cy="100" r="2.6" /><circle cx="159" cy="167" r="2.1" /><circle cx="41" cy="167" r="2.1" /><circle cx="15" cy="100" r="2.6" /><circle cx="41" cy="33" r="2.1" /></g>
      <g fill="#2dd4bf" opacity="0.42"><rect x="52" y="152" width="9" height="22" rx="1" /><rect x="64" y="142" width="11" height="32" rx="1" /><rect x="79" y="158" width="8" height="16" rx="1" /><rect x="116" y="148" width="10" height="26" rx="1" /><rect x="129" y="138" width="11" height="36" rx="1" /><rect x="143" y="156" width="9" height="18" rx="1" /></g>
      <path d="M104 130 L92 156 L100 156 L96 174 L112 150 L103 150 Z" fill="url(#mTeal)" stroke="#0a0420" strokeWidth="1" />
      <path d="M64 178 q0 -38 36 -38 q36 0 36 38 Z" fill="url(#mBody)" stroke="rgba(255,255,255,.14)" strokeWidth="1.5" />
      <path d="M100 146 l13 4.5 v11 q0 10 -13 14.5 q-13 -4.5 -13 -14.5 v-11 Z" fill="#160a30" stroke="url(#mTeal)" strokeWidth="2.2" />
      <path d="M93 160 l5 5 l9 -10" fill="none" stroke="url(#mTeal)" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round" />
      <line x1="100" y1="40" x2="100" y2="52" stroke="url(#mTeal)" strokeWidth="3" strokeLinecap="round" /><circle cx="100" cy="38" r="4.5" fill="url(#mTeal)" />
      <path d="M52 92 A48 48 0 0 1 148 92" fill="none" stroke="#7a55c9" strokeWidth="4" strokeLinecap="round" opacity="0.8" />
      <rect x="58" y="52" width="84" height="70" rx="24" fill="url(#mBody)" stroke="rgba(255,255,255,.16)" strokeWidth="2" />
      <rect x="47" y="78" width="13" height="28" rx="6.5" fill="#3a1d6e" stroke="url(#mTeal)" strokeWidth="2" />
      <rect x="140" y="78" width="13" height="28" rx="6.5" fill="#3a1d6e" stroke="url(#mTeal)" strokeWidth="2" />
      <rect x="68" y="66" width="64" height="42" rx="15" fill="#0b0422" />
      <rect x="68" y="66" width="64" height="42" rx="15" fill="none" stroke="url(#mTeal)" strokeWidth="1.4" opacity="0.5" />
      <path d="M76 88 H90 L94 76 L101 100 L107 84 L110 88 H124" fill="none" stroke="url(#mTeal)" strokeWidth="3.2" strokeLinecap="round" strokeLinejoin="round" />
      <text x="116" y="95" fontFamily="Georgia, serif" fontSize="21" fontStyle="italic" fontWeight="700" fill="url(#mTeal)">P</text>
    </svg>
  );
}

function pulseTabLabel(tab) {
  return { today: 'Today', requests: 'Requests', oncall: 'On-call', support: 'Support', profile: 'Profile' }[tab] || tab;
}

// Tiny, safe markdown → HTML for Pulse bubbles (escape first, then bold /
// italic / code / bullets / line breaks).
function pulseRenderMd(s) {
  let h = String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
  h = h.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
       .replace(/(^|[^*])\*(?!\s)(.+?)\*/g, '$1<em>$2</em>')
       .replace(/`(.+?)`/g, '<code>$1</code>');
  h = h.replace(/^[\-•]\s+(.*)$/gm, '<li>$1</li>');
  if (/<li>/.test(h)) h = h.replace(/(?:<li>.*?<\/li>)+/s, m => '<ul>' + m + '</ul>');
  h = h.replace(/\n/g, '<br/>');
  return h;
}

function pulseStripMd(s) {
  return String(s || '')
    .replace(/[*_`#>]/g, '')
    .replace(/\[(.*?)\]\(.*?\)/g, '$1')
    .replace(/\n+/g, '. ')
    .trim();
}
function pulseSpeak(text) {
  try {
    const synth = window.speechSynthesis;
    if (!synth) return;
    synth.cancel();
    const u = new SpeechSynthesisUtterance(pulseStripMd(text).slice(0, 600));
    u.lang = navigator.language || 'en-US';
    u.rate = 1.04; u.pitch = 1.0;
    synth.speak(u);
  } catch (_) {}
}

function PulseLauncher({ onClick }) {
  return (
    <button className="m-pulse-launch" aria-label="Open Pulse" onClick={onClick}>
      <span className="m-pulse-ring" aria-hidden="true" />
      <PulseMark size={28} />
    </button>
  );
}

function PulsePanel({ me, onClose, onNavigate, autoListen }) {
  const firstName = me && me.display_name ? String(me.display_name).split(' ')[0] : '';
  const HELLO = {
    role: 'pulse',
    text: `Hi${firstName ? ' ' + firstName : ''} — I'm **Pulse**. Ask about your attendance, tickets, PTO or on-call — or tell me to sign you in, request time off, or flag a blocker.`,
  };
  const [msgs, setMsgs] = useState([HELLO]);
  const [q, setQ] = useState('');
  const [busy, setBusy] = useState(false);
  const [listening, setListening] = useState(false);
  const [model, setModel] = useState(() => { try { return localStorage.getItem('m_pulse_model') || ''; } catch (_) { return ''; } });
  const [models, setModels] = useState(null);
  const [showModels, setShowModels] = useState(false);
  const [pending, setPending] = useState(null);
  const [nudges, setNudges] = useState([]);   // v4.1.0 proactive prompts
  const scRef = useRef(null);
  const recogRef = useRef(null);
  const voicedRef = useRef(false);
  const histRef = useRef([]);

  useEffect(() => {
    (async () => {
      try { const r = await fetch('/api/ai/models', { credentials: 'include' }); if (r.ok) setModels(await r.json()); } catch (_) {}
    })();
    // v4.1.0 — nudges every open; daily brief once per calendar day.
    (async () => {
      try { const r = await fetch('/api/pulse/nudges', { credentials: 'include' }); if (r.ok) { const j = await r.json(); setNudges((j.nudges || []).slice(0, 4)); } } catch (_) {}
    })();
    (async () => {
      try {
        const todayKey = new Date().toISOString().slice(0, 10);
        if (localStorage.getItem('m_pulse_brief') === todayKey) return;
        const r = await fetch('/api/pulse/brief', { credentials: 'include' });
        if (!r.ok) return;
        const j = await r.json();
        const lines = (j.lines || []).map(l => `${l.icon} ${l.text}`).join('\n');
        if (lines) {
          setMsgs(prev => [...prev, { role: 'pulse', text: `**${j.greeting || "Here's your day"}**\n${lines}` }]);
          try { localStorage.setItem('m_pulse_brief', todayKey); } catch (_) {}
        }
      } catch (_) {}
    })();
    return () => { try { window.speechSynthesis && window.speechSynthesis.cancel(); } catch (_) {} };
  }, []);
  useEffect(() => { const el = scRef.current; if (el) el.scrollTop = el.scrollHeight; }, [msgs, busy, pending]);

  const canChangeModel = !models || models.user_can_change !== false;
  const pushMsg = (m) => setMsgs(prev => [...prev, m]);

  const send = async (text, fromVoice) => {
    const t = (text != null ? text : q).trim();
    if (!t || busy) return;
    setQ(''); setPending(null);
    pushMsg({ role: 'you', text: t });
    const priorHist = histRef.current.slice(-8);
    histRef.current = [...histRef.current, { role: 'user', content: t }].slice(-8);
    setBusy(true);
    try {
      const r = await fetch('/api/pulse/turn', {
        method: 'POST', credentials: 'include',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ message: t, history: priorHist, model: model || undefined, lang: m_lang() }),
      });
      const j = await r.json();
      if (j.kind === 'action' && j.action) {
        setPending(j.action);
        pushMsg({ role: 'pulse', text: j.action.summary || 'Want me to do that?', confirm: true });
        if (fromVoice) pulseSpeak(j.action.summary || 'Should I go ahead?');
      } else {
        const ans = j.text || '(no answer)';
        pushMsg({ role: 'pulse', text: ans, nav: j.nav || null });
        histRef.current = [...histRef.current, { role: 'assistant', content: ans }].slice(-8);
        if (fromVoice) pulseSpeak(ans);
        if (j.nav && j.nav.tab) setTimeout(() => { onNavigate && onNavigate(j.nav.tab, j.nav.hint); }, 700);
      }
    } catch (e) {
      pushMsg({ role: 'pulse', text: 'Sorry — I hit a snag. Try again in a sec.' });
    } finally { setBusy(false); voicedRef.current = false; }
  };

  const runAction = async (action, fromVoice) => {
    if (!action || busy) return;
    setPending(null); setBusy(true);
    pushMsg({ role: 'you', text: '✓ Confirmed' });
    try {
      const r = await fetch('/api/pulse/execute', {
        method: 'POST', credentials: 'include',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ name: action.name, args: action.args || {} }),
      });
      const j = await r.json();
      const txt = j.text || (j.ok ? 'Done.' : "Couldn't do that.");
      pushMsg({ role: 'pulse', text: txt, nav: j.nav || null });
      if (fromVoice) pulseSpeak(txt);
      try { window.dispatchEvent(new CustomEvent('attendance-updated')); } catch (_) {}
      if (j.ok && j.nav && j.nav.tab) setTimeout(() => { onNavigate && onNavigate(j.nav.tab, j.nav.hint); }, 750);
    } catch (e) {
      pushMsg({ role: 'pulse', text: "That didn't go through — try again." });
    } finally { setBusy(false); voicedRef.current = false; }
  };

  const micSupported = typeof window !== 'undefined' && ('webkitSpeechRecognition' in window || 'SpeechRecognition' in window);
  const startMic = () => {
    if (listening) { stopMic(); return; }
    const Ctor = window.SpeechRecognition || window.webkitSpeechRecognition;
    if (!Ctor) return;
    try {
      const r = new Ctor();
      r.lang = navigator.language || 'en-US';
      r.interimResults = true; r.continuous = false;
      let finalText = '';
      r.onresult = (e) => {
        let acc = '';
        for (let i = e.resultIndex; i < e.results.length; i++) {
          acc += e.results[i][0].transcript;
          if (e.results[i].isFinal) finalText += e.results[i][0].transcript;
        }
        setQ(finalText || acc);
      };
      r.onerror = () => setListening(false);
      r.onend = () => {
        setListening(false);
        const said = (finalText || '').trim();
        if (said) { voicedRef.current = true; send(said, true); }
      };
      recogRef.current = r;
      setListening(true);
      try { window.speechSynthesis && window.speechSynthesis.cancel(); } catch (_) {}
      r.start();
    } catch (_) { setListening(false); }
  };
  const stopMic = () => { try { recogRef.current && recogRef.current.stop(); } catch (_) {} setListening(false); };

  // Opened by "Hey Pulse" → start listening immediately (after a beat so the
  // native wake-word session has released the mic).
  useEffect(() => {
    if (autoListen && micSupported) {
      const t = setTimeout(() => { try { startMic(); } catch (_) {} }, 400);
      return () => clearTimeout(t);
    }
  }, []);

  const SUGGEST = [t('How many hours did I work this week?'), t('Show my resolved tickets'), t('Request a sick day tomorrow'), t('Am I on-call this week?')];

  return (
    <div className="m-pulse-panel" role="dialog" aria-label="Pulse assistant">
      <div className="m-pulse-hd">
        <div className="m-pulse-hd-l"><PulseMark size={22} pulsing={busy || listening} /><span>Pulse</span></div>
        <div className="m-pulse-hd-r">
          {canChangeModel && (
            <button className="m-pulse-ic" aria-label="Choose model" onClick={() => setShowModels(v => !v)}>
              <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7"><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" /></svg>
            </button>
          )}
          <button className="m-pulse-ic" aria-label="Close Pulse" onClick={onClose}>
            <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="6" y1="6" x2="18" y2="18" /><line x1="18" y1="6" x2="6" y2="18" /></svg>
          </button>
        </div>
      </div>
      {showModels && canChangeModel && models && (
        <div className="m-pulse-models">
          <select value={model} onChange={e => { setModel(e.target.value); try { localStorage.setItem('m_pulse_model', e.target.value); } catch (_) {} setShowModels(false); }}>
            <option value="">{t('Auto (recommended)')}</option>
            {(models.groups || []).map((g, gi) => (
              <optgroup key={gi} label={g.label}>
                {(g.options || []).map((o, oi) => (<option key={oi} value={o.value}>{o.label}</option>))}
              </optgroup>
            ))}
          </select>
        </div>
      )}
      <div className="m-pulse-chat" ref={scRef}>
        {msgs.map((m, i) => (
          <div key={i} className={`m-pulse-row m-pulse-${m.role}`}>
            <div className="m-pulse-bubble" dangerouslySetInnerHTML={{ __html: pulseRenderMd(m.text) }} />
            {m.confirm && pending && i === msgs.length - 1 && (
              <div className="m-pulse-confirm">
                <button className="m-pulse-yes" onClick={() => runAction(pending, voicedRef.current)} disabled={busy}>{t('Confirm')}</button>
                <button className="m-pulse-no" onClick={() => { setPending(null); pushMsg({ role: 'pulse', text: t('No worries — cancelled.') }); }} disabled={busy}>{t('Cancel')}</button>
              </div>
            )}
            {m.nav && m.nav.tab && (
              <button className="m-pulse-goto" onClick={() => onNavigate && onNavigate(m.nav.tab, m.nav.hint)}>
                Open {pulseTabLabel(m.nav.tab)} →
              </button>
            )}
          </div>
        ))}
        {busy && <div className="m-pulse-row m-pulse-pulse"><div className="m-pulse-bubble m-pulse-typing"><span /><span /><span /></div></div>}
        {nudges.length > 0 && !busy && (
          <div className="m-pulse-nudges">
            {nudges.map((n, i) => (
              <button key={i} className="m-pulse-nudge" onClick={() => {
                setNudges(ns => ns.filter((_, j) => j !== i));
                if (n.nav && n.nav.tab) { onNavigate && onNavigate(n.nav.tab, n.nav.hint); }
                else if (n.action === 'signin') { send('Sign me in for today', false); }
                else { send(n.text, false); }
              }}>
                <span className="m-pulse-nudge-dot" aria-hidden="true" />{n.text}
              </button>
            ))}
          </div>
        )}
        {msgs.length <= 1 && !busy && (
          <div className="m-pulse-suggest">
            {SUGGEST.map((s, i) => (<button key={i} onClick={() => send(s, false)}>{s}</button>))}
          </div>
        )}
      </div>
      <div className="m-pulse-input">
        {micSupported && (
          <button className={`m-pulse-mic${listening ? ' on' : ''}`} aria-label="Talk to Pulse" onClick={startMic}>
            <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8"><rect x="9" y="2" width="6" height="12" rx="3" /><path d="M5 11a7 7 0 0 0 14 0" /><line x1="12" y1="18" x2="12" y2="22" /></svg>
          </button>
        )}
        <input value={q} onChange={e => setQ(e.target.value)} placeholder={listening ? t('Listening…') : t('Ask Pulse or say a command…')}
          onKeyDown={e => { if (e.key === 'Enter') send(); }} />
        <button className="m-pulse-send" aria-label="Send" onClick={() => send()} disabled={!q.trim() || busy}>
          <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="22" y1="2" x2="11" y2="13" /><polygon points="22 2 15 22 11 13 2 9 22 2" /></svg>
        </button>
      </div>
    </div>
  );
}

function PulseRoot({ me, onNavigate }) {
  const [open, setOpen] = useState(false);
  const [autoListen, setAutoListen] = useState(false);
  // "Hey Pulse" wake word → open the panel and immediately start listening.
  useEffect(() => {
    const onWake = () => { setAutoListen(true); setOpen(true); };
    window.addEventListener('pulse-wake', onWake);
    return () => window.removeEventListener('pulse-wake', onWake);
  }, []);
  const closePulse = () => {
    setOpen(false); setAutoListen(false);
    // Resume "Hey Pulse" listening now that Pulse has released the mic.
    try { window.dispatchEvent(new CustomEvent('pulse-wake-resume')); } catch (_) {}
  };
  return (
    <>
      {!open && <PulseLauncher onClick={() => { setAutoListen(false); setOpen(true); }} />}
      {open && <PulsePanel me={me} autoListen={autoListen} onClose={closePulse} onNavigate={(tab, hint) => { onNavigate && onNavigate(tab, hint); }} />}
    </>
  );
}

function StrikeSheet({ onClose }) {
  const [data] = useApi('/api/me/strikes');
  const v = data.value || {};
  const recent = v.recent || [];
  const labelFor = (a) => {
    const t = String(a || '').replace(/_/g, ' ');
    if (t === 'format strike')         return 'Sign-in format strike';
    if (t === 'format violation')      return 'Sign-in format issue';
    if (t === 'admin manual violation')return 'Admin-issued strike';
    if (t === 'no signin violation')   return 'No sign-in today';
    if (t === 'no signoff violation')  return 'No sign-off today';
    return t;
  };
  return (
    <div className="m-sheet-back" onClick={onClose}>
      <div className="m-sheet" onClick={e => e.stopPropagation()}>
        <div className="m-sheet-grab" />
        <div className="m-sheet-title">Your <em>strikes</em></div>
        <div className="m-sheet-sub" style={{
          fontFamily:"'Fraunces',serif", fontStyle:'italic', fontWeight:300,
          fontSize:14, opacity:.7, lineHeight:1.4, marginBottom:14,
        }}>
          {data.loading ? 'loading…' :
            v.warning_count > 0
              ? <>You have <strong>{v.warning_count}</strong> {v.warning_count === 1 ? 'strike' : 'strikes'} on file (<strong>{v.this_week}</strong> this week).</>
              : 'No strikes on file. Nice.'}
        </div>
        {data.loading ? <MSkel rows={3} /> :
         recent.length === 0
           ? <MEmpty title="No recent details" sub="No strike events in the last 30 days." />
           : (
             <div className="m-strike-list">
               {recent.map(r => (
                 <div key={r.id} className="m-strike-row">
                   <div className="m-strike-top">
                     <span className="m-strike-when">{m_ago(r.occurred_at)}</span>
                     <span className="m-pill m-pill-no">{labelFor(r.action_type)}</span>
                   </div>
                   {r.body && <div className="m-strike-body">{r.body}</div>}
                 </div>
               ))}
             </div>
           )}
        <button className="m-cta" style={{ marginTop: 10, background:'transparent', color:'inherit', boxShadow:'none', border:'1px solid currentColor', opacity:.85 }} onClick={onClose}>Close</button>
      </div>
    </div>
  );
}

function NewPtoSheet({ onClose }) {
  const [type, setType] = useState('pto');
  const [from, setFrom] = useState('');
  const [to, setTo]     = useState('');
  const [reason, setReason] = useState('');
  const [busy, setBusy] = useState(false);
  const [err, setErr]   = useState('');
  const submit = async () => {
    if (!from) { setErr('Pick a start date'); return; }
    setBusy(true); setErr('');
    try {
      // Mobile employees POST to /api/me/pto-request (creates PENDING).
      // /api/pto is manager-only and was 502'ing for employees.
      const r = await fetch('/api/me/pto-request', {
        method:'POST', credentials:'include',
        headers:{'Content-Type':'application/json'},
        body: JSON.stringify({
          pto_type: type, start_date: from, end_date: to || from, reason,
        }),
      });
      if (!r.ok) throw new Error(await m_parseErr(r));
      onClose();
    } catch (e) { setErr(String(e.message || e)); }
    finally { setBusy(false); }
  };
  return (
    <div className="m-sheet-back" onClick={onClose}>
      <div className="m-sheet" onClick={e => e.stopPropagation()}>
        <div className="m-sheet-grab" />
        <div className="m-sheet-title">New <em>time off</em></div>
        <div className="m-sheet-lbl">Type</div>
        <div className="m-chips">
          {[
            {v:'pto', l:'vacation'},
            {v:'sick', l:'sick'},
            {v:'personal', l:'personal'},
            {v:'unpaid', l:'unpaid'},
          ].map(o => (
            <button key={o.v} className={`m-chip ${type===o.v?'on':''}`} onClick={() => setType(o.v)}>{o.l}</button>
          ))}
        </div>
        <div className="m-sheet-lbl">Dates</div>
        <div className="m-grid2">
          <label className="m-datebox">
            <span>FROM</span>
            <input type="date" value={from} onChange={e => setFrom(e.target.value)} />
          </label>
          <label className="m-datebox">
            <span>TO</span>
            <input type="date" value={to} onChange={e => setTo(e.target.value)} />
          </label>
        </div>
        <div className="m-sheet-lbl">Reason (optional)</div>
        <textarea className="m-textarea" rows="3" placeholder="Anything we should know…"
          value={reason} onChange={e => setReason(e.target.value)} />
        {err && <div className="m-err">{err}</div>}
        <button className="m-cta" onClick={submit} disabled={busy}>
          {busy ? 'sending…' : 'Request time off →'}
        </button>
      </div>
    </div>
  );
}

function NewTicketSheet({ onClose }) {
  const [severity, setSeverity] = useState('normal');
  const [issue, setIssue]       = useState('');
  const [busy, setBusy]         = useState(false);
  const [err, setErr]           = useState('');
  const submit = async () => {
    if (!issue.trim()) { setErr('Describe what’s wrong'); return; }
    setBusy(true); setErr('');
    try {
      const r = await fetch('/api/support/tickets', {
        method:'POST', credentials:'include',
        headers:{'Content-Type':'application/json'},
        body: JSON.stringify({ severity, issue }),
      });
      if (!r.ok) throw new Error(await m_parseErr(r));
      onClose();
    } catch (e) { setErr(String(e.message || e)); }
    finally { setBusy(false); }
  };
  return (
    <div className="m-sheet-back" onClick={onClose}>
      <div className="m-sheet" onClick={e => e.stopPropagation()}>
        <div className="m-sheet-grab" />
        <div className="m-sheet-title">New <em>ticket</em></div>
        <div className="m-sheet-lbl">Severity</div>
        <div className="m-chips">
          {['low','normal','high','urgent'].map(v => (
            <button key={v} className={`m-chip ${severity===v?'on':''}`} onClick={() => setSeverity(v)}>{v}</button>
          ))}
        </div>
        <div className="m-sheet-lbl">What’s happening?</div>
        <textarea className="m-textarea" rows="5" placeholder="Describe the issue with as much detail as you can…"
          value={issue} onChange={e => setIssue(e.target.value)} />
        {err && <div className="m-err">{err}</div>}
        <button className="m-cta" onClick={submit} disabled={busy}>
          {busy ? 'sending…' : 'Send to support →'}
        </button>
      </div>
    </div>
  );
}

// ────────────────────────────────────────────────────────────────────
//  MOBILE LOGIN PAGE — editorial style, OTP-only, auto-trust on cold
//  start. Renders when authed === false AND M_isMobileSurface().
// ────────────────────────────────────────────────────────────────────
// MUST match shell.jsx's LOGIN_LAST_ID_KEY so auto-trust on the mobile
// login can find the matrix_id stored by a previous successful login.
// Previously we used 'login_last_id' which never matched — so every cold
// start asked for the OTP code again.
const LOGIN_LAST_ID_KEY = 'status_last_matrix_id';
function m_normalizeMx(input) {
  let s = String(input || '').trim().toLowerCase();
  if (!s) return '';
  s = s.replace(/^@/, '');
  if (!s.includes(':')) s = `${s}:k9.ms`;
  return `@${s}`;
}
function m_deviceKey(mx) { return `status_device_${mx}`; }

// Visible diagnostic for the login screen — shows whether the device is
// actually remembered so we can tell whether a re-prompt for the OTP is
// because storage was lost or because the typed ID doesn't match the
// remembered one.
function DeviceTrustDiag({ chatId }) {
  const stored = (typeof window !== 'undefined' && localStorage.getItem(LOGIN_LAST_ID_KEY)) || '';
  const lookupId = m_normalizeMx(chatId) || stored;
  const token = lookupId ? localStorage.getItem(m_deviceKey(lookupId)) : '';
  const expiry = lookupId ? localStorage.getItem(`${m_deviceKey(lookupId)}_expiry`) : '';
  const expiryOk = expiry && new Date(expiry) > new Date();
  return (
    <div style={{
      marginTop: 14,
      padding: '10px 12px',
      borderRadius: 10,
      border: '1px dashed #ffffff22',
      background: '#ffffff05',
      fontFamily: "'JetBrains Mono', monospace",
      fontSize: 10,
      lineHeight: 1.6,
      letterSpacing: 0.04,
      opacity: 0.75,
    }}>
      <div style={{ opacity: 0.55, textTransform: 'uppercase', letterSpacing: '0.18em', marginBottom: 4 }}>
        device trust
      </div>
      <div>last id: <span style={{ color: stored ? '#a3eeff' : '#ff9b9b' }}>{stored || '(none)'}</span></div>
      <div>token  : <span style={{ color: token && expiryOk ? '#92e7a8' : '#ff9b9b' }}>
        {!token ? '(none)' : !expiryOk ? '(expired)' : '✓ saved · expires ' + (expiry || '').slice(0, 10)}
      </span></div>
      {stored && token && expiryOk && (
        <div style={{ marginTop: 4, opacity: 0.7 }}>
          → you should be auto-signed-in. tap 'Send code' to re-try device login.
        </div>
      )}
    </div>
  );
}

function MobileLoginPage({ onLogin }) {
  const [step, setStep] = useState('id'); // 'id' | 'otp' | 'auto'
  const [chatId, setChatId] = useState(() => localStorage.getItem(LOGIN_LAST_ID_KEY) || '');
  const [code, setCode] = useState('');
  const [busy, setBusy] = useState(false);
  const [err, setErr]   = useState('');
  const [autoTrying, setAutoTrying] = useState(() => !!localStorage.getItem(LOGIN_LAST_ID_KEY));
  const idInputRef = useRef(null);
  const codeInputRef = useRef(null);

  const rememberDevice = (mx, token) => {
    if (!mx || !token) return;
    const expiry = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString();
    localStorage.setItem(m_deviceKey(mx), token);
    localStorage.setItem(`${m_deviceKey(mx)}_expiry`, expiry);
    localStorage.setItem(LOGIN_LAST_ID_KEY, mx);
  };
  const rememberedToken = (mx) => {
    if (!mx) return null;
    const k = m_deviceKey(mx);
    const token = localStorage.getItem(k);
    const expiry = localStorage.getItem(`${k}_expiry`);
    if (!token || !expiry || new Date(expiry) < new Date()) {
      localStorage.removeItem(k); localStorage.removeItem(`${k}_expiry`);
      return null;
    }
    return token;
  };

  // Shared "try device-login" helper used by both auto-trust on mount AND
  // the user clicking 'Send code'. Returns true if logged in, false if
  // the stored token is missing / expired / rejected by the server.
  const tryDeviceLogin = async (mx) => {
    const normalized = m_normalizeMx(mx);
    if (!normalized) return false;
    const token = rememberedToken(normalized);
    try {
      console.info('[mobile login] tryDeviceLogin', normalized, 'token?', !!token);
    } catch (_) {}
    if (!token) return false;
    try {
      const r = await fetch('/api/auth/device-login', {
        method:'POST', credentials:'include',
        headers:{'Content-Type':'application/json'},
        body: JSON.stringify({ matrix_id: normalized, device_token: token }),
      });
      if (r.ok) {
        const data = await r.json();
        // Refresh the stored token if the server rotated it.
        if (data.device_token) rememberDevice(data.matrix_id || normalized, data.device_token);
        else localStorage.setItem(LOGIN_LAST_ID_KEY, data.matrix_id || normalized);
        try { localStorage.removeItem('m_signed_out'); } catch (_) {}
        onLogin(data);
        return true;
      }
      // Token rejected (rotated server-side / expired). Wipe so we don't
      // keep retrying a dead token on every attempt.
      localStorage.removeItem(m_deviceKey(normalized));
      localStorage.removeItem(`${m_deviceKey(normalized)}_expiry`);
      try { console.warn('[mobile login] device-login rejected', r.status); } catch (_) {}
    } catch (e) {
      try { console.warn('[mobile login] device-login network error', e); } catch (_) {}
    }
    return false;
  };

  // Auto-trust on mount — but NOT right after an intentional sign-out, so
  // logout lands on the login screen. The token is preserved either way, so
  // the user gets a one-tap "Log in" / Face ID button below.
  useEffect(() => {
    let cancelled = false;
    (async () => {
      const last = localStorage.getItem(LOGIN_LAST_ID_KEY) || '';
      const signedOut = localStorage.getItem('m_signed_out') === '1';
      if (!last || signedOut) { setAutoTrying(false); return; }
      const ok = await tryDeviceLogin(last);
      if (!cancelled && !ok) setAutoTrying(false);
    })();
    return () => { cancelled = true; };
    // eslint-disable-next-line
  }, []);

  // Pure check: is there a still-valid trusted token for this id? (no mutation)
  const tokenForId = (mx) => {
    const n = m_normalizeMx(mx); if (!n) return null;
    const t = localStorage.getItem(m_deviceKey(n));
    const exp = localStorage.getItem(`${m_deviceKey(n)}_expiry`);
    if (!t || !exp || new Date(exp) < new Date()) return null;
    return t;
  };
  const hasTrusted = !!tokenForId(chatId);
  // One-tap trusted login (optionally gated by Face ID / Touch ID on native).
  const trustedLogin = async (useBiometric) => {
    const mx = m_normalizeMx(chatId);
    if (!mx || busy) return;
    setBusy(true); setErr('');
    try {
      if (useBiometric) {
        const gate = await K9.biometricGate('Sign in to K9 Pulse');
        // Proceed when biometrics are unavailable/absent (web, no enrolled
        // Face ID) — the trusted-device token is the real credential. Only stop
        // on a genuine biometric failure; the plain “Log in” button is the fallback.
        if (gate && gate.ok === false && !gate.missing && gate.fallback !== 'unavailable') {
          setErr('Face ID didn\u2019t complete — tap again, or use “Log in as…” below.');
          setBusy(false); return;
        }
      }
      const ok = await tryDeviceLogin(mx);
      if (!ok) setErr('Trusted sign-in expired — please use a code.');
    } catch (e) { setErr(String(e.message || e)); }
    finally { setBusy(false); }
  };

  const sendCode = async () => {
    const mx = m_normalizeMx(chatId);
    if (!mx || busy) return;
    setBusy(true); setErr('');
    // SECOND CHANCE auto-trust — match desktop send(): if we have a token
    // for this ID, log in silently instead of bothering the user with
    // another OTP. This catches the case where the on-mount auto-trust
    // wasn't tried (e.g. user typed a different ID than last_id stored)
    // or failed for a transient reason.
    try {
      if (await tryDeviceLogin(mx)) return;
    } catch (_) {}
    try {
      const r = await fetch('/api/auth/request-otp', {
        method:'POST', credentials:'include',
        headers:{'Content-Type':'application/json'},
        body: JSON.stringify({ matrix_id: mx }),
      });
      if (!r.ok) throw new Error(await m_parseErr(r, `Could not send code (${r.status})`));
      setChatId(mx);
      localStorage.setItem(LOGIN_LAST_ID_KEY, mx);
      setStep('otp');
      setTimeout(() => codeInputRef.current && codeInputRef.current.focus(), 100);
    } catch (e) { setErr(String(e.message || e)); }
    finally { setBusy(false); }
  };

  const verify = async () => {
    const mx = m_normalizeMx(chatId);
    if (!mx || !code || busy) return;
    setBusy(true); setErr('');
    try {
      const r = await fetch('/api/auth/verify-otp', {
        method:'POST', credentials:'include',
        headers:{'Content-Type':'application/json'},
        body: JSON.stringify({ matrix_id: mx, otp: code, remember_device: true }),
      });
      if (!r.ok) throw new Error(await m_parseErr(r, 'Invalid code'));
      const data = await r.json();
      if (data.device_token) rememberDevice(data.matrix_id || mx, data.device_token);
      try { localStorage.removeItem('m_signed_out'); } catch (_) {}
      onLogin(data);
    } catch (e) { setErr(String(e.message || e)); }
    finally { setBusy(false); }
  };

  // Auto-trust frame
  if (autoTrying) {
    return (
      <div className="m-login m-dark">
        <div className="m-login-frame">
          <div className="m-login-logo m-login-mascot"><PulseMascot size={124} /></div>
          <div className="m-login-title"><em>welcome back…</em></div>
          <div className="m-login-sub">signing you in</div>
        </div>
      </div>
    );
  }

  return (
    <div className="m-login m-dark">
      <div className="m-login-frame">
        <div className="m-login-logo m-login-mascot"><PulseMascot size={124} /></div>
        <div className="m-login-eyebrow">K9 attendance</div>
        <div className="m-login-title">
          {step === 'id' ? <>Sign in to <em>your day</em>.</> : <>Enter the <em>code</em>.</>}
        </div>
        <div className="m-login-sub">
          {step === 'id'
            ? (hasTrusted
                ? <>Welcome back. Tap to sign in — no code needed.</>
                : "We'll DM you a 6-digit code in K9 Chat. No passwords, ever.")
            : <>Sent to <span className="m-mono">{chatId}</span>. Check your DMs.</>}
        </div>

        {step === 'id' ? (
          <>
            <label className="m-login-lbl">K9 Chat ID</label>
            <input
              ref={idInputRef}
              className="m-login-input"
              autoFocus={!hasTrusted}
              autoCapitalize="none"
              autoCorrect="off"
              autoComplete="username"
              placeholder="@you:k9.ms"
              value={chatId}
              onChange={e => setChatId(e.target.value)}
              onKeyDown={e => { if (e.key === 'Enter') (hasTrusted ? trustedLogin(false) : sendCode()); }}
            />
            {err && <div className="m-err">{err}</div>}
            {hasTrusted ? (
              <>
                <button className="m-login-cta" onClick={() => trustedLogin(false)} disabled={busy}>
                  {busy ? 'signing in…' : `Log in as ${m_first(chatId) || chatId} →`}
                </button>
                {K9.available() && (
                  <button className="m-login-faceid" onClick={() => trustedLogin(true)} disabled={busy}>
                    🔒 Sign in with Face ID
                  </button>
                )}
                <button className="m-login-back" onClick={sendCode} disabled={busy}>
                  use a code instead
                </button>
              </>
            ) : (
              <button className="m-login-cta" onClick={sendCode} disabled={!chatId.trim() || busy}>
                {busy ? 'sending…' : 'Send code →'}
              </button>
            )}
            <div className="m-login-foot">
              <span style={{opacity:.5}}>✓</span> Trusted devices stay signed in for 90 days
            </div>
            <DeviceTrustDiag chatId={chatId} />
          </>
        ) : (
          <>
            <label className="m-login-lbl">Verification code</label>
            <input
              ref={codeInputRef}
              className="m-login-input m-login-code"
              inputMode="numeric"
              autoComplete="one-time-code"
              maxLength="6"
              pattern="[0-9]*"
              placeholder="• • • • • •"
              value={code}
              onChange={e => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
              onKeyDown={e => { if (e.key === 'Enter') verify(); }}
            />
            {err && <div className="m-err">{err}</div>}
            <button className="m-login-cta" onClick={verify} disabled={code.length < 6 || busy}>
              {busy ? 'verifying…' : 'Sign in →'}
            </button>
            <button className="m-login-back" onClick={() => { setStep('id'); setCode(''); setErr(''); }}>
              ← use a different ID
            </button>
          </>
        )}
      </div>
    </div>
  );
}
window.MobileLoginPage = MobileLoginPage;

// ── globals ──────────────────────────────────────────────────────────
window.MobileApp = MobileApp;
window.M_isMobileSurface = function() {
  try {
    const sp = new URLSearchParams(window.location.search || '');
    if (sp.get('m') === '1' || sp.get('mobile') === '1') {
      try { localStorage.setItem('m_force', '1'); } catch (_) {}
    }
    if (sp.get('m') === '0') {
      try { localStorage.removeItem('m_force'); } catch (_) {}
    }
    if (localStorage.getItem('m_force') === '1') return true;
    const cap = window.Capacitor;
    const onCap = !!(
      cap && (
        (typeof cap.isNativePlatform === 'function' && cap.isNativePlatform()) ||
        (typeof cap.getPlatform === 'function' && cap.getPlatform() !== 'web')
      )
    );
    if (onCap) return true;
    if (window.matchMedia('(max-width: 768px)').matches &&
        window.matchMedia('(pointer: coarse)').matches) return true;
  } catch (_) {}
  return false;
};
try {
  console.info('[mobile.jsx v3.0.2] surface=', window.M_isMobileSurface(),
    'cap=', !!window.Capacitor, 'force=', localStorage.getItem('m_force'));
} catch (_) {}
