/** * earth-now.co * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai * File: apps/web/components/Dashboard.tsx * Purpose: Client dashboard orchestrator — live-first layout (hero, live grid by tick speed, session strip, country ranking, realtime, reference strip) */ "use client"; import { useEffect, useMemo, useState } from "react"; import { formatValue, type CounterModel } from "@earth-now/counter"; import { fetchQuakes, sseStreamUrl, type Locale, type MetricSummary, type QuakesResponse, } from "@/lib/api"; import { resolveModel, resolvePrimaryWindow } from "@/lib/derived"; import { I18nProvider, useI18n } from "@/lib/i18n"; import { formatDateUtc, isLocale } from "@/lib/messages"; import { CONTINENT_PREFIX, HERO_METRIC_ID, liveSortKey, tierFor, type DisplayTier } from "@/lib/tiers"; import CountryRanking from "./CountryRanking"; import YearProgress from "./YearProgress"; import LiveCounter, { type PreviousModel } from "./LiveCounter"; const LOCALE_STORAGE_KEY = "earth-now.locale"; const THEME_STORAGE_KEY = "earth-now.theme"; type Theme = "light" | "dark"; /** Catalog §14 vedette absolue — the "since you arrived" metrics, session window. */ const SESSION_METRIC_IDS = [ "births_ytd", "deaths_ytd", "co2_emissions_ytd", "forest_loss_ytd", "earth_orbit_ytd", "solar_installed_ytd", ] as const; interface ModelSlot { model: CounterModel; prev?: PreviousModel; } export interface DashboardProps { metrics: MetricSummary[]; initialModels: Record; } function toSlots(models: Record): Record { const slots: Record = {}; for (const [id, model] of Object.entries(models)) slots[id] = { model }; return slots; } function sameModel(a: CounterModel, b: CounterModel): boolean { return ( a.modelVersion === b.modelVersion && a.anchorTime === b.anchorTime && a.anchorValue === b.anchorValue && a.observedAt === b.observedAt ); } export default function Dashboard({ metrics, initialModels }: DashboardProps) { const [locale, setLocaleState] = useState("fr"); const [theme, setThemeState] = useState("light"); const [slots, setSlots] = useState>(() => toSlots(initialModels)); // User arrival instant — anchors every "since you arrived" counter. const sessionStart = useMemo(() => Date.now(), []); useEffect(() => { const savedLocale = window.localStorage.getItem(LOCALE_STORAGE_KEY); if (isLocale(savedLocale)) setLocaleState(savedLocale); if (window.localStorage.getItem(THEME_STORAGE_KEY) === "dark") setThemeState("dark"); }, []); useEffect(() => { document.documentElement.lang = locale; }, [locale]); useEffect(() => { if (theme === "dark") document.documentElement.dataset.theme = "dark"; else delete document.documentElement.dataset.theme; }, [theme]); const setLocale = (next: Locale) => { setLocaleState(next); window.localStorage.setItem(LOCALE_STORAGE_KEY, next); }; const toggleTheme = () => { setThemeState((current) => { const next: Theme = current === "light" ? "dark" : "light"; window.localStorage.setItem(THEME_STORAGE_KEY, next); return next; }); }; // SSE: the server pushes MODELS, never per-tick values. Replacements are // cross-faded over 60 s by LiveCounter (prev + swapStartMs) — no visible jump. useEffect(() => { const applyModel = (incoming: CounterModel, receivedAt: number) => { setSlots((current) => { const existing = current[incoming.metricId]; if (existing !== undefined && sameModel(existing.model, incoming)) return current; const slot: ModelSlot = existing !== undefined ? { model: incoming, prev: { model: existing.model, swapStartMs: receivedAt } } : { model: incoming }; return { ...current, [incoming.metricId]: slot }; }); }; const source = new EventSource(sseStreamUrl()); source.addEventListener("models", (event) => { try { const payload = JSON.parse((event as MessageEvent).data) as | Record | { models: Record }; const models = "models" in payload && typeof payload.models === "object" ? (payload as { models: Record }).models : (payload as Record); const now = Date.now(); for (const model of Object.values(models)) applyModel(model, now); } catch { // Malformed frame — keep the current models (never break a running counter). } }); source.addEventListener("model", (event) => { try { const model = JSON.parse((event as MessageEvent).data) as CounterModel; applyModel(model, Date.now()); } catch { // Malformed frame — ignored. } }); return () => source.close(); }, []); return ( ); } function DashboardBody({ metrics, slots, sessionStart, theme, onToggleTheme, }: { metrics: MetricSummary[]; slots: Record; sessionStart: number; theme: Theme; onToggleTheme: () => void; }) { const { locale, setLocale, t } = useI18n(); const models = useMemo(() => { const map: Record = {}; for (const [id, slot] of Object.entries(slots)) map[id] = slot.model; return map; }, [slots]); // Live-first information architecture: tiers computed from the models // themselves (tick speed of the last visible digit) — not hand-picked. const tiers = useMemo(() => { const now = Date.now(); const byTier: Record = { hero: [], live: [], country: [], realtime: [], reference: [], }; for (const metric of metrics) { byTier[tierFor(metric, resolveModel(metric, models), now)].push(metric); } byTier.live.sort( (a, b) => liveSortKey(a, resolveModel(a, models), now) - liveSortKey(b, resolveModel(b, models), now), ); return byTier; }, [metrics, models]); const hero = metrics.find((m) => m.id === HERO_METRIC_ID); const sessionMetrics = SESSION_METRIC_IDS.map((id) => metrics.find((m) => m.id === id)).filter( (m): m is MetricSummary => m !== undefined, ); const slotFor = (metric: MetricSummary): ModelSlot | undefined => { const own = slots[metric.id]; if (own !== undefined) return own; const inputId = metric.derived?.inputs[0]?.id; return inputId !== undefined ? slots[inputId] : undefined; }; const counterProps = (metric: MetricSummary) => ({ metric, model: resolveModel(metric, models), prev: slotFor(metric)?.prev, sessionStart, }); return (

{t("site.title")}

{t("live.title")}

{t("site.tagline")}

{hero !== undefined && (
)} {tiers.live.length > 0 && (
{tiers.live.map((metric) => ( ))}
)} {sessionMetrics.length > 0 && (

{t("since.title")}

{t("since.subtitle")}

{sessionMetrics.map((metric) => ( ))}
)} {tiers.country.filter((m) => !m.id.startsWith(CONTINENT_PREFIX)).length > 0 && (
!m.id.startsWith(CONTINENT_PREFIX))} models={models} />
)} {tiers.country.filter((m) => m.id.startsWith(CONTINENT_PREFIX)).length > 0 && (
m.id.startsWith(CONTINENT_PREFIX))} models={models} />
)} {tiers.realtime.length > 0 && (
{tiers.realtime.map((metric) => ( ))}
)} {tiers.reference.length > 0 && (
{tiers.reference.map((metric) => ( ))}
)}
); } function Section({ id, title, subtitle, children, }: { id: string; title: string; subtitle: string; children: React.ReactNode; }) { return (

{title}

{subtitle}

{children}
); } /** True event-driven realtime (USGS): one fetch on mount, no interpolation. */ function QuakesLine() { const { locale, t } = useI18n(); const [quakes, setQuakes] = useState(null); useEffect(() => { let cancelled = false; fetchQuakes() .then((q) => { if (!cancelled) setQuakes(q); }) .catch(() => { // Realtime extras are best-effort — the counter cards remain authoritative. }); return () => { cancelled = true; }; }, []); if (quakes === null || quakes.lastMajor === null) return null; const magnitude = formatValue(quakes.lastMajor.mag, { decimals: 1, unit: "" }, { locale }); return (

{t("quakes.lastMajor")} : M {magnitude} — {quakes.lastMajor.place} ( {formatDateUtc(quakes.lastMajor.timeIso, locale)})

); }