SPB Git

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%
13.5 KB · 419 lines tsx
Raw Blame History
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