spb/earth-now Public License
earth-now.co — real-time planetary dashboard: live world metrics modeled, not streamed.
TypeScript 93%
Shell 2.3%
SQL 1.4%
JavaScript 1.3%
Dockerfile 1.2%
CSS 0.8%
1/**2 * earth-now.co3 * Author: Simon-Pierre Boucher4 * Contact: contact@spboucher.ai5 * File: apps/web/components/Dashboard.tsx6 * Purpose: Client dashboard orchestrator — live-first layout (hero, live grid by tick speed, session strip, country ranking, realtime, reference strip)7 */89"use client";1011import { useEffect, useMemo, useState } from "react";12import { formatValue, type CounterModel } from "@earth-now/counter";13import {14 fetchQuakes,15 sseStreamUrl,16 type Locale,17 type MetricSummary,18 type QuakesResponse,19} from "@/lib/api";20import { resolveModel, resolvePrimaryWindow } from "@/lib/derived";21import { I18nProvider, useI18n } from "@/lib/i18n";22import { formatDateUtc, isLocale } from "@/lib/messages";23import { CONTINENT_PREFIX, HERO_METRIC_ID, liveSortKey, tierFor, type DisplayTier } from "@/lib/tiers";24import CountryRanking from "./CountryRanking";25import YearProgress from "./YearProgress";26import LiveCounter, { type PreviousModel } from "./LiveCounter";2728const LOCALE_STORAGE_KEY = "earth-now.locale";29const THEME_STORAGE_KEY = "earth-now.theme";3031type Theme = "light" | "dark";3233/** Catalog §14 vedette absolue — the "since you arrived" metrics, session window. */34const SESSION_METRIC_IDS = [35 "births_ytd",36 "deaths_ytd",37 "co2_emissions_ytd",38 "forest_loss_ytd",39 "earth_orbit_ytd",40 "solar_installed_ytd",41] as const;4243interface ModelSlot {44 model: CounterModel;45 prev?: PreviousModel;46}4748export interface DashboardProps {49 metrics: MetricSummary[];50 initialModels: Record<string, CounterModel>;51}5253function toSlots(models: Record<string, CounterModel>): Record<string, ModelSlot> {54 const slots: Record<string, ModelSlot> = {};55 for (const [id, model] of Object.entries(models)) slots[id] = { model };56 return slots;57}5859function sameModel(a: CounterModel, b: CounterModel): boolean {60 return (61 a.modelVersion === b.modelVersion &&62 a.anchorTime === b.anchorTime &&63 a.anchorValue === b.anchorValue &&64 a.observedAt === b.observedAt65 );66}6768export default function Dashboard({ metrics, initialModels }: DashboardProps) {69 const [locale, setLocaleState] = useState<Locale>("fr");70 const [theme, setThemeState] = useState<Theme>("light");71 const [slots, setSlots] = useState<Record<string, ModelSlot>>(() => toSlots(initialModels));72 // User arrival instant — anchors every "since you arrived" counter.73 const sessionStart = useMemo(() => Date.now(), []);7475 useEffect(() => {76 const savedLocale = window.localStorage.getItem(LOCALE_STORAGE_KEY);77 if (isLocale(savedLocale)) setLocaleState(savedLocale);78 if (window.localStorage.getItem(THEME_STORAGE_KEY) === "dark") setThemeState("dark");79 }, []);8081 useEffect(() => {82 document.documentElement.lang = locale;83 }, [locale]);8485 useEffect(() => {86 if (theme === "dark") document.documentElement.dataset.theme = "dark";87 else delete document.documentElement.dataset.theme;88 }, [theme]);8990 const setLocale = (next: Locale) => {91 setLocaleState(next);92 window.localStorage.setItem(LOCALE_STORAGE_KEY, next);93 };94 const toggleTheme = () => {95 setThemeState((current) => {96 const next: Theme = current === "light" ? "dark" : "light";97 window.localStorage.setItem(THEME_STORAGE_KEY, next);98 return next;99 });100 };101102 // SSE: the server pushes MODELS, never per-tick values. Replacements are103 // cross-faded over 60 s by LiveCounter (prev + swapStartMs) — no visible jump.104 useEffect(() => {105 const applyModel = (incoming: CounterModel, receivedAt: number) => {106 setSlots((current) => {107 const existing = current[incoming.metricId];108 if (existing !== undefined && sameModel(existing.model, incoming)) return current;109 const slot: ModelSlot =110 existing !== undefined111 ? { model: incoming, prev: { model: existing.model, swapStartMs: receivedAt } }112 : { model: incoming };113 return { ...current, [incoming.metricId]: slot };114 });115 };116117 const source = new EventSource(sseStreamUrl());118 source.addEventListener("models", (event) => {119 try {120 const payload = JSON.parse((event as MessageEvent<string>).data) as121 | Record<string, CounterModel>122 | { models: Record<string, CounterModel> };123 const models =124 "models" in payload && typeof payload.models === "object"125 ? (payload as { models: Record<string, CounterModel> }).models126 : (payload as Record<string, CounterModel>);127 const now = Date.now();128 for (const model of Object.values(models)) applyModel(model, now);129 } catch {130 // Malformed frame — keep the current models (never break a running counter).131 }132 });133 source.addEventListener("model", (event) => {134 try {135 const model = JSON.parse((event as MessageEvent<string>).data) as CounterModel;136 applyModel(model, Date.now());137 } catch {138 // Malformed frame — ignored.139 }140 });141 return () => source.close();142 }, []);143144 return (145 <I18nProvider locale={locale} setLocale={setLocale}>146 <DashboardBody147 metrics={metrics}148 slots={slots}149 sessionStart={sessionStart}150 theme={theme}151 onToggleTheme={toggleTheme}152 />153 </I18nProvider>154 );155}156157function DashboardBody({158 metrics,159 slots,160 sessionStart,161 theme,162 onToggleTheme,163}: {164 metrics: MetricSummary[];165 slots: Record<string, ModelSlot>;166 sessionStart: number;167 theme: Theme;168 onToggleTheme: () => void;169}) {170 const { locale, setLocale, t } = useI18n();171 const models = useMemo(() => {172 const map: Record<string, CounterModel> = {};173 for (const [id, slot] of Object.entries(slots)) map[id] = slot.model;174 return map;175 }, [slots]);176177 // Live-first information architecture: tiers computed from the models178 // themselves (tick speed of the last visible digit) — not hand-picked.179 const tiers = useMemo(() => {180 const now = Date.now();181 const byTier: Record<DisplayTier, MetricSummary[]> = {182 hero: [],183 live: [],184 country: [],185 realtime: [],186 reference: [],187 };188 for (const metric of metrics) {189 byTier[tierFor(metric, resolveModel(metric, models), now)].push(metric);190 }191 byTier.live.sort(192 (a, b) =>193 liveSortKey(a, resolveModel(a, models), now) -194 liveSortKey(b, resolveModel(b, models), now),195 );196 return byTier;197 }, [metrics, models]);198199 const hero = metrics.find((m) => m.id === HERO_METRIC_ID);200 const sessionMetrics = SESSION_METRIC_IDS.map((id) => metrics.find((m) => m.id === id)).filter(201 (m): m is MetricSummary => m !== undefined,202 );203204 const slotFor = (metric: MetricSummary): ModelSlot | undefined => {205 const own = slots[metric.id];206 if (own !== undefined) return own;207 const inputId = metric.derived?.inputs[0]?.id;208 return inputId !== undefined ? slots[inputId] : undefined;209 };210211 const counterProps = (metric: MetricSummary) => ({212 metric,213 model: resolveModel(metric, models),214 prev: slotFor(metric)?.prev,215 sessionStart,216 });217218 return (219 <div className="mx-auto max-w-6xl px-4 pb-16">220 <header className="sticky top-0 z-30 -mx-4 mb-4 border-b bg-page/85 px-4 py-3 backdrop-blur-md">221 <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">222 <div className="flex items-center gap-3">223 <h1 className="text-xl font-semibold tracking-tight text-ink">{t("site.title")}</h1>224 <span className="flex items-center gap-1.5 rounded-full bg-wash px-2.5 py-0.5 text-[11px] font-medium uppercase tracking-wide text-accent">225 <span aria-hidden className="live-dot" />226 {t("live.title")}227 </span>228 </div>229 <div className="flex items-center gap-2">230 <button231 type="button"232 onClick={onToggleTheme}233 className="rounded-full border px-3 py-1 text-xs text-ink2 transition-colors hover:border-ink/30"234 >235 {theme === "light" ? t("theme.toDark") : t("theme.toLight")}236 </button>237 <button238 type="button"239 onClick={() => setLocale(locale === "fr" ? "en" : "fr")}240 className="rounded-full border px-3 py-1 text-xs text-ink2 transition-colors hover:border-ink/30"241 >242 {t("locale.toggle")}243 </button>244 </div>245 </div>246 </header>247 <p className="mb-4 max-w-2xl text-sm text-ink2">{t("site.tagline")}</p>248 <nav aria-label="sections" className="mb-5 flex flex-wrap gap-2 text-xs">249 {[250 ["#section-live", t("live.title")],251 ["#since-arrival", t("since.title")],252 ["#section-countries", t("countries.title")],253 ["#section-realtime", t("realtime.title")],254 ["#section-reference", t("reference.title")],255 ].map(([href, label]) => (256 <a257 key={href}258 href={href}259 className="rounded-full border px-3 py-1 text-ink2 transition-colors hover:border-ink/30 hover:text-ink"260 >261 {label}262 </a>263 ))}264 </nav>265 <YearProgress />266267 {hero !== undefined && (268 <section aria-labelledby="hero-figure" className="mb-10">269 <LiveCounter {...counterProps(hero)} window="total" size="display" live />270 </section>271 )}272273 {tiers.live.length > 0 && (274 <Section id="live" title={t("live.title")} subtitle={t("live.subtitle")}>275 <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">276 {tiers.live.map((metric) => (277 <LiveCounter278 key={metric.id}279 {...counterProps(metric)}280 window={resolvePrimaryWindow(metric)}281 live282 />283 ))}284 </div>285 </Section>286 )}287288 {sessionMetrics.length > 0 && (289 <section290 aria-labelledby="since-arrival"291 className="mb-12 rounded-3xl border bg-gradient-to-br from-wash via-surface to-surface p-5 sm:p-6"292 >293 <h2 id="since-arrival" className="text-lg font-semibold text-ink">294 {t("since.title")}295 </h2>296 <p className="mb-4 mt-1 text-sm text-ink2">{t("since.subtitle")}</p>297 <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">298 {sessionMetrics.map((metric) => (299 <LiveCounter300 key={`session-${metric.id}`}301 {...counterProps(metric)}302 window="session"303 live304 />305 ))}306 </div>307 </section>308 )}309310 {tiers.country.filter((m) => !m.id.startsWith(CONTINENT_PREFIX)).length > 0 && (311 <Section312 id="countries"313 title={t("countries.title")}314 subtitle={t("countries.subtitle")}315 >316 <CountryRanking317 metrics={tiers.country.filter((m) => !m.id.startsWith(CONTINENT_PREFIX))}318 models={models}319 />320 </Section>321 )}322323 {tiers.country.filter((m) => m.id.startsWith(CONTINENT_PREFIX)).length > 0 && (324 <Section325 id="continents"326 title={t("continents.title")}327 subtitle={t("continents.subtitle")}328 >329 <CountryRanking330 metrics={tiers.country.filter((m) => m.id.startsWith(CONTINENT_PREFIX))}331 models={models}332 />333 </Section>334 )}335336 {tiers.realtime.length > 0 && (337 <Section id="realtime" title={t("realtime.title")} subtitle={t("realtime.subtitle")}>338 <QuakesLine />339 <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">340 {tiers.realtime.map((metric) => (341 <LiveCounter key={metric.id} {...counterProps(metric)} window="total" />342 ))}343 </div>344 </Section>345 )}346347 {tiers.reference.length > 0 && (348 <Section349 id="reference"350 title={t("reference.title")}351 subtitle={t("reference.subtitle")}352 >353 <div className="divide-y divide-line rounded-2xl border bg-surface">354 {tiers.reference.map((metric) => (355 <LiveCounter356 key={metric.id}357 {...counterProps(metric)}358 window={resolvePrimaryWindow(metric)}359 size="row"360 />361 ))}362 </div>363 </Section>364 )}365 </div>366 );367}368369function Section({370 id,371 title,372 subtitle,373 children,374}: {375 id: string;376 title: string;377 subtitle: string;378 children: React.ReactNode;379}) {380 return (381 <section aria-labelledby={`section-${id}`} className="mb-12">382 <h2 id={`section-${id}`} className="text-lg font-semibold text-ink">383 {title}384 </h2>385 <p className="mb-4 mt-1 text-sm text-ink2">{subtitle}</p>386 {children}387 </section>388 );389}390391/** True event-driven realtime (USGS): one fetch on mount, no interpolation. */392function QuakesLine() {393 const { locale, t } = useI18n();394 const [quakes, setQuakes] = useState<QuakesResponse | null>(null);395396 useEffect(() => {397 let cancelled = false;398 fetchQuakes()399 .then((q) => {400 if (!cancelled) setQuakes(q);401 })402 .catch(() => {403 // Realtime extras are best-effort — the counter cards remain authoritative.404 });405 return () => {406 cancelled = true;407 };408 }, []);409410 if (quakes === null || quakes.lastMajor === null) return null;411 const magnitude = formatValue(quakes.lastMajor.mag, { decimals: 1, unit: "" }, { locale });412 return (413 <p className="mb-3 text-xs text-ink2">414 {t("quakes.lastMajor")} : M {magnitude} — {quakes.lastMajor.place} (415 {formatDateUtc(quakes.lastMajor.timeIso, locale)})416 </p>417 );418}419