'use client'; import { useEffect, useRef, useState } from 'react'; import { LiveStatus } from '@/components/ui/live'; import { cn } from '@/lib/cn'; import { fmtInt } from '@/lib/format'; import type { Stats } from '@/lib/types'; const nf = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }); /** Number that counts up to `value` on mount (700 ms, eased) and ticks green when the value changes later. */ export function CountUp({ value, className, duration = 700 }: { value: number | null | undefined; className?: string; duration?: number }) { const [shown, setShown] = useState(value ?? null); const [tick, setTick] = useState(0); const prev = useRef(null); useEffect(() => { if (value === null || value === undefined) return; const from = prev.current ?? (value > 50 ? Math.round(value * 0.92) : 0); const to = value; if (prev.current !== null && prev.current !== value) setTick((t) => t + 1); prev.current = value; if (typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches) { setShown(to); return; } const t0 = performance.now(); let raf = 0; const step = (t: number) => { const p = Math.min(1, (t - t0) / duration); const e = 1 - Math.pow(1 - p, 3); setShown(Math.round(from + (to - from) * e)); if (p < 1) raf = requestAnimationFrame(step); }; raf = requestAnimationFrame(step); return () => cancelAnimationFrame(raf); }, [value, duration]); return ( 0 && 'counter-tick', className)} suppressHydrationWarning> {shown === null ? '—' : nf.format(shown)} ); } const COUNTERS: { key: keyof Stats; label: string; hint?: (s: Stats) => string | null }[] = [ { key: 'companies', label: 'Companies', hint: (s) => (s.companies_active ? `${fmtInt(s.companies_active)} active` : null) }, { key: 'sensors', label: 'Sensors', hint: (s) => (s.sensors_active ? `${fmtInt(s.sensors_active)} active` : null) }, { key: 'observations', label: 'Observations', hint: (s) => (s.observations_today ? `+${fmtInt(s.observations_today)} today` : null) }, { key: 'changes', label: 'Changes', hint: (s) => (s.meaningful_changes ? `${fmtInt(s.meaningful_changes)} meaningful` : null) }, { key: 'events', label: 'Structured events', hint: (s) => (s.events_today ? `+${fmtInt(s.events_today)} today` : null) }, ]; /** Hero counters: server value first, then live refresh from `/api/v1/stats` every 60 s. */ export function LiveCounters({ stats: initial, className }: { stats: Stats | null; className?: string }) { const [stats, setStats] = useState(initial); const [updatedAt, setUpdatedAt] = useState(null); const [ok, setOk] = useState(true); useEffect(() => { setUpdatedAt(Date.now()); let cancelled = false; const refresh = async () => { try { const res = await fetch('/api/v1/stats', { headers: { accept: 'application/json' }, cache: 'no-store' }); if (!res.ok) throw new Error(String(res.status)); const s = (await res.json()) as Stats; if (!cancelled) { setStats(s); setUpdatedAt(Date.now()); setOk(true); } } catch { if (!cancelled) setOk(false); } }; const first = setTimeout(refresh, 1500); const t = setInterval(refresh, 60_000); return () => { cancelled = true; clearTimeout(first); clearInterval(t); }; }, []); if (!stats) return (
Platform counters are temporarily unavailable.
); return (
{COUNTERS.map((c) => { const v = stats[c.key]; const hint = c.hint?.(stats); return (

{c.label}

{hint ?? ''}

); })}
{stats.last_observation_at && last observation recorded at {new Date(stats.last_observation_at).toISOString().replace('T', ' ').slice(0, 19)} UTC}
); }