// Shared UI primitives: toast notifications, an expiry-countdown helper, and // the browser-tab pending badge. Loaded after data.jsx so every view can call // window.toast(...) and window.fmtExpiresIn(...). Mounted once via // and in the app shell (index.html). // ---- Expiry countdown ---------------------------------------------------- // Pending Tommy approvals auto-expire 24h after creation (renewed by // escalate()). Surface "expires in Xh" with an urgency level so staff attack // the ones about to die — the queue's ~80% expiry rate is the real problem. // level: 'normal' | 'soon' (<8h) | 'critical' (<3h) | 'expired'. function fmtExpiresIn(expiresAt) { const d = parseDate(expiresAt); if (!d) return null; const ms = d.getTime() - Date.now(); if (ms <= 0) return { text: 'expired', level: 'expired', minutes: 0 }; const minutes = Math.round(ms / 60000); if (minutes < 60) return { text: `expires in ${minutes}m`, level: 'critical', minutes }; const hours = Math.floor(minutes / 60); const level = hours < 3 ? 'critical' : hours < 8 ? 'soon' : 'normal'; return { text: `expires in ${hours}h`, level, minutes }; } // Sort helper: soonest-to-expire first, nulls last. Pure; returns a new array. function sortByExpiry(list) { const key = a => { const d = parseDate(a && a.expires_at); return d ? d.getTime() : Infinity; }; return [...(list || [])].sort((a, b) => key(a) - key(b)); } // ---- Toasts -------------------------------------------------------------- // Non-blocking replacement for alert()/confirm(). Fire from anywhere with // window.toast(msg, { kind, action: { label, onClick }, duration }). // An `action` (e.g. Undo) keeps the toast up longer. let _toastSeq = 0; function toast(message, opts = {}) { const id = ++_toastSeq; window.dispatchEvent(new CustomEvent('raw:toast', { detail: { id, message, kind: opts.kind || 'info', action: opts.action, duration: opts.duration }, })); return id; } function ToastHost() { const [toasts, setToasts] = React.useState([]); const timers = React.useRef({}); const remove = React.useCallback((id) => { if (timers.current[id]) { clearTimeout(timers.current[id]); delete timers.current[id]; } setToasts(prev => prev.filter(t => t.id !== id)); }, []); React.useEffect(() => { function onToast(e) { const t = e.detail; setToasts(prev => [...prev, t]); const duration = t.duration || (t.action ? 6000 : 4000); timers.current[t.id] = setTimeout(() => remove(t.id), duration); } window.addEventListener('raw:toast', onToast); return () => { window.removeEventListener('raw:toast', onToast); Object.values(timers.current).forEach(clearTimeout); }; }, [remove]); function fireAction(t) { try { t.action && t.action.onClick && t.action.onClick(); } catch (err) { /* no-op */ } remove(t.id); } if (toasts.length === 0) return null; return (
{toasts.map(t => (
{t.message} {t.action && ( )}
))}
); } // ---- Browser-tab pending badge ------------------------------------------ // Prefix the tab title with the count of pending Tommy approvals so staff // working in another tab still notice the queue. Refreshes on realtime // events and a slow 60s poll as a safety net. function PendingTabBadge() { const baseTitle = React.useRef(document.title.replace(/^\(\d+\)\s*/, '')); React.useEffect(() => { let alive = true; async function refresh() { try { const r = await api.get('/api/v2/tommy/pending-approvals'); if (!alive) return; const n = (r && r.approvals ? r.approvals : []).length; document.title = n > 0 ? `(${n}) ${baseTitle.current}` : baseTitle.current; } catch (e) { /* leave the title as-is on error */ } } refresh(); function onEv() { refresh(); } window.addEventListener('raw:event', onEv); const iv = setInterval(refresh, 60000); return () => { alive = false; window.removeEventListener('raw:event', onEv); clearInterval(iv); document.title = baseTitle.current; }; }, []); return null; } // ---- Skeleton loader ---- // Lightweight shimmer placeholder for loading states (replaces bare "Loading…"). function Skeleton({ w, h, r, style }) { return (