/* 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= — 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 (
{refreshing ? ( ) : ( )}
); } // ── 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 K9 Pulse., 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 — Yesterday, Today, and any blockers. 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 🤒 Out sick or 🌴 PTO today 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 I’m stuck to flag a blocker to your lead. Tap History to scroll your last 30 days, and watch your 🔥 on-time streak 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 + 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 acknowledge or mark it resolved 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 loop?, 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 (
{ONBOARD_STEPS.map((_, i) => ( ))}
{cur.kind === 'hero' && ( <>
Hi, {m_first(me?.display_name) || 'there'}.

{cur.title}

{cur.body}

)} {cur.kind === 'tab' && ( <>
{cur.tab === 'today' && } {cur.tab === 'requests' && } {cur.tab === 'oncall' && } {cur.tab === 'support' && } {cur.tab === 'profile' && }
{(cur.tab[0].toUpperCase() + cur.tab.slice(1))} tab

{cur.title}

{cur.body}

)} {cur.kind === 'notif' && ( <>

{cur.title}

{cur.body}

)}
{step > 0 && ( )} {!isLast ? ( ) : ( <> )}
); } // ── 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 (
{t('K9 Pulse is locked')}
{t('Unlock with Face ID to continue.')}
{err ?
{err}
: null}
); } 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 ( { // 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 ( { localStorage.setItem('m_onboarded', '1'); setOnboarded(true); }} /> ); } return ; } 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 // 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 = setNotifOpen(true)} />; return (
{tab === 'today' && } {tab === 'requests' && } {tab === 'oncall' && } {tab === 'support' && } {tab === 'profile' && }
{notifOpen && ( setNotifOpen(false)} onMarkRead={markRead} onMarkAllRead={markAllRead} onNavigate={navigateLink} /> )}
); } const TAB_DEFS = [ { id: 'today', label: t('Today'), icon: }, { id: 'requests', label: t('Requests'), icon: }, { id: 'oncall', label: t('On-call'), icon: }, { id: 'support', label: t('Support'), icon: }, { id: 'profile', label: t('Me'), icon: }, ]; function MobileTabBar({ tab, setTab }) { return ( ); } function MPageHd({ crumb, title, sub, right }) { return (
{crumb &&
{crumb}
}

{title}

{right}
{sub &&
{sub}
}
); } // ── 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 ( ); } // 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 (
{ onMarkRead(n.id); if (n.link) { onNavigate(n.link); onClose(); } }}>
{labelFor(n.kind)} {m_ago(n.created_at)}
{n.title}
{n.body &&
{n.body}
}
{canReply && !sent && (
{!replyOpen ? ( ) : (