// Shared plumbing: htm binding, hash router, polling fetch, formatters. const { createElement, useState, useEffect, useRef, useCallback } = React; export const html = htm.bind(createElement); export { useState, useEffect, useRef, useCallback }; export async function api(path, options = {}) { const response = await fetch(path, { ...options, headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }, body: options.body ? JSON.stringify(options.body) : undefined, }); const text = await response.text(); let parsed = null; try { parsed = text ? JSON.parse(text) : null; } catch (error) { // A non JSON body from an API that always speaks JSON means something upstream // failed, so surface the raw text rather than a parse error nobody can act on. console.error('[ops] non-JSON response from', path, error.message); throw new Error(text.slice(0, 200) || `HTTP ${response.status}`); } if (!response.ok) throw new Error((parsed && parsed.error) || `HTTP ${response.status}`); return parsed; } // Poll without the spinner flash: keep showing the previous payload while the next // one is in flight, and never let a slow response overwrite a newer one. export function usePoll(path, intervalMs = 5000) { const [state, setState] = useState({ data: null, error: null, loading: true, at: null }); const seq = useRef(0); const alive = useRef(true); const refresh = useCallback(async () => { const mine = ++seq.current; try { const data = await api(path); if (!alive.current || mine !== seq.current) return; setState({ data, error: null, loading: false, at: Date.now() }); } catch (error) { if (!alive.current || mine !== seq.current) return; console.error('[ops] poll failed for', path, error.message); setState((prev) => ({ ...prev, error: error.message, loading: false })); } }, [path]); useEffect(() => { alive.current = true; refresh(); const timer = setInterval(refresh, intervalMs); const onVisible = () => { if (!document.hidden) refresh(); }; document.addEventListener('visibilitychange', onVisible); return () => { alive.current = false; clearInterval(timer); document.removeEventListener('visibilitychange', onVisible); }; }, [refresh, intervalMs]); return { ...state, refresh }; } export function useHashRoute(fallback) { const read = () => (location.hash || '').replace(/^#\/?/, '') || fallback; const [route, setRoute] = useState(read); useEffect(() => { const onHash = () => setRoute(read()); addEventListener('hashchange', onHash); return () => removeEventListener('hashchange', onHash); }, []); return route; } /* ---------- formatting ---------- */ export const num = (value) => (value === null || value === undefined || Number.isNaN(Number(value)) ? '—' : Number(value).toLocaleString()); export function compact(value) { const n = Number(value); if (!Number.isFinite(n)) return '—'; if (Math.abs(n) >= 1e9) return (n / 1e9).toFixed(2) + 'B'; if (Math.abs(n) >= 1e6) return (n / 1e6).toFixed(2) + 'M'; if (Math.abs(n) >= 1e3) return (n / 1e3).toFixed(1) + 'k'; return String(n); } export const pct = (value, digits = 1) => (Number.isFinite(Number(value)) ? `${(Number(value) * 100).toFixed(digits)}%` : '—'); export function ago(minutes) { if (minutes === null || minutes === undefined || !Number.isFinite(Number(minutes))) return 'unknown'; const m = Number(minutes); if (m < 1) return 'just now'; if (m < 60) return `${Math.round(m)}m ago`; if (m < 60 * 24) return `${(m / 60).toFixed(1)}h ago`; return `${(m / 1440).toFixed(1)}d ago`; } export function whenDate(value) { if (!value) return '—'; const stamp = String(value).slice(0, 10); const days = Math.round((Date.parse(`${stamp}T00:00:00Z`) - Date.now()) / 86400000); if (!Number.isFinite(days)) return stamp; if (days === 0) return `${stamp} (today)`; return days > 0 ? `${stamp} (in ${days}d)` : `${stamp} (${-days}d ago)`; } export function health(minutes, threshold) { if (minutes === null || minutes === undefined) return 'idle'; if (!Number.isFinite(Number(threshold))) return 'ok'; const m = Number(minutes); if (m <= threshold) return 'ok'; return m <= threshold * 3 ? 'warn' : 'bad'; }