htm passes props through untouched and react rejects a style string with error #62, so every view rendered an empty page. Converting once in the createElement wrapper keeps plain css in the templates instead of style objects everywhere. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WnNxwxfXSbeNtjvtz5gayb
137 lines
5.2 KiB
JavaScript
137 lines
5.2 KiB
JavaScript
// Shared plumbing: htm binding, hash router, polling fetch, formatters.
|
|
const { createElement, useState, useEffect, useRef, useCallback } = React;
|
|
|
|
// htm hands props straight through, and React refuses a style string (error #62).
|
|
// Writing style objects everywhere in tagged templates is miserable, so convert
|
|
// once here instead and let the views keep using plain css.
|
|
function cssToObject(css) {
|
|
const style = {};
|
|
for (const declaration of String(css).split(';')) {
|
|
const split = declaration.indexOf(':');
|
|
if (split === -1) continue;
|
|
const property = declaration.slice(0, split).trim();
|
|
const value = declaration.slice(split + 1).trim();
|
|
if (!property || !value) continue;
|
|
// custom properties have to stay verbatim, the rest camelCase
|
|
style[property.startsWith('--') ? property : property.replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = value;
|
|
}
|
|
return style;
|
|
}
|
|
|
|
function h(type, props, ...children) {
|
|
if (props && typeof props.style === 'string') {
|
|
return createElement(type, { ...props, style: cssToObject(props.style) }, ...children);
|
|
}
|
|
return createElement(type, props, ...children);
|
|
}
|
|
|
|
export const html = htm.bind(h);
|
|
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';
|
|
}
|