// 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 (
);
}
Object.assign(window, { fmtExpiresIn, sortByExpiry, toast, ToastHost, PendingTabBadge, Skeleton });