spb/company-atlas
Public
Python 66.3%
TypeScript 22.7%
JavaScript 8.6%
HTML 1.4%
CSS 0.7%
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