SPB Git forge
28commits 1branches 0releases
7.7 MBsize
maindefault branch
10 days agolast push
Python 66.3% TypeScript 22.7% JavaScript 8.6% HTML 1.4% CSS 0.7%
4.8 KB · 111 lines tsx
Raw Blame History
1'use client';2import { useEffect, useRef, useState } from 'react';3import { LiveStatus } from '@/components/ui/live';4import { cn } from '@/lib/cn';5import { fmtInt } from '@/lib/format';6import type { Stats } from '@/lib/types';78const nf = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 });910/** Number that counts up to `value` on mount (700 ms, eased) and ticks green when the value changes later. */11export function CountUp({ value, className, duration = 700 }: { value: number | null | undefined; className?: string; duration?: number }) {12  const [shown, setShown] = useState<number | null>(value ?? null);13  const [tick, setTick] = useState(0);14  const prev = useRef<number | null>(null);15  useEffect(() => {16    if (value === null || value === undefined) return;17    const from = prev.current ?? (value > 50 ? Math.round(value * 0.92) : 0);18    const to = value;19    if (prev.current !== null && prev.current !== value) setTick((t) => t + 1);20    prev.current = value;21    if (typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {22      setShown(to);23      return;24    }25    const t0 = performance.now();26    let raf = 0;27    const step = (t: number) => {28      const p = Math.min(1, (t - t0) / duration);29      const e = 1 - Math.pow(1 - p, 3);30      setShown(Math.round(from + (to - from) * e));31      if (p < 1) raf = requestAnimationFrame(step);32    };33    raf = requestAnimationFrame(step);34    return () => cancelAnimationFrame(raf);35  }, [value, duration]);36  return (37    <span key={tick} className={cn('tnum', tick > 0 && 'counter-tick', className)} suppressHydrationWarning>38      {shown === null ? '—' : nf.format(shown)}39    </span>40  );41}4243const COUNTERS: { key: keyof Stats; label: string; hint?: (s: Stats) => string | null }[] = [44  { key: 'companies', label: 'Companies', hint: (s) => (s.companies_active ? `${fmtInt(s.companies_active)} active` : null) },45  { key: 'sensors', label: 'Sensors', hint: (s) => (s.sensors_active ? `${fmtInt(s.sensors_active)} active` : null) },46  { key: 'observations', label: 'Observations', hint: (s) => (s.observations_today ? `+${fmtInt(s.observations_today)} today` : null) },47  { key: 'changes', label: 'Changes', hint: (s) => (s.meaningful_changes ? `${fmtInt(s.meaningful_changes)} meaningful` : null) },48  { key: 'events', label: 'Structured events', hint: (s) => (s.events_today ? `+${fmtInt(s.events_today)} today` : null) },49];5051/** Hero counters: server value first, then live refresh from `/api/v1/stats` every 60 s. */52export function LiveCounters({ stats: initial, className }: { stats: Stats | null; className?: string }) {53  const [stats, setStats] = useState<Stats | null>(initial);54  const [updatedAt, setUpdatedAt] = useState<number | null>(null);55  const [ok, setOk] = useState(true);56  useEffect(() => {57    setUpdatedAt(Date.now());58    let cancelled = false;59    const refresh = async () => {60      try {61        const res = await fetch('/api/v1/stats', { headers: { accept: 'application/json' }, cache: 'no-store' });62        if (!res.ok) throw new Error(String(res.status));63        const s = (await res.json()) as Stats;64        if (!cancelled) {65          setStats(s);66          setUpdatedAt(Date.now());67          setOk(true);68        }69      } catch {70        if (!cancelled) setOk(false);71      }72    };73    const first = setTimeout(refresh, 1500);74    const t = setInterval(refresh, 60_000);75    return () => {76      cancelled = true;77      clearTimeout(first);78      clearInterval(t);79    };80  }, []);81  if (!stats)82    return (83      <div className={cn('border-y border-rule py-4 text-sm text-ink-3', className)} role="status">84        Platform counters are temporarily unavailable.85      </div>86    );87  return (88    <div className={className} data-live-counters>89      <div className="grid grid-cols-2 gap-x-6 border-y border-rule sm:grid-cols-3 lg:grid-cols-5 [&>*]:border-b [&>*]:border-rule lg:[&>*]:border-b-0">90        {COUNTERS.map((c) => {91          const v = stats[c.key];92          const hint = c.hint?.(stats);93          return (94            <div key={c.key} className="min-w-0 py-3 md:py-4">95              <p className="eyebrow">{c.label}</p>96              <p className="mt-1 text-[26px] font-semibold leading-none tracking-tight md:text-[34px]">97                <CountUp value={typeof v === 'number' ? v : null} />98              </p>99              <p className="mt-1.5 min-h-4 text-xs text-ink-3">{hint ?? ''}</p>100            </div>101          );102        })}103      </div>104      <div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-ink-3">105        <LiveStatus updatedAt={updatedAt} connected={ok} />106        {stats.last_observation_at && <span>last observation recorded at {new Date(stats.last_observation_at).toISOString().replace('T', ' ').slice(0, 19)} UTC</span>}107      </div>108    </div>109  );110}111