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%
6.1 KB · 176 lines tsx
Raw Blame History
1/**2 * earth-now.co3 * Author:  Simon-Pierre Boucher4 * Contact: contact@spboucher.ai5 * File:    apps/web/components/MetricDetail.tsx6 * Purpose: Per-metric page body — big live counter and the metric's own methodology section (model family, sources, uncertainty)7 */89"use client";1011import Link from "next/link";12import { useEffect, useMemo, useState } from "react";13import { formatUncertainty, formatValue, type CounterModel } from "@earth-now/counter";14import type { Locale, MetricSummary } from "@/lib/api";15import { hintsFor, resolvePrimaryWindow } from "@/lib/derived";16import { I18nProvider, useI18n } from "@/lib/i18n";17import { formatDateUtc, isLocale, type MessageKey } from "@/lib/messages";18import { tierFor } from "@/lib/tiers";19import LiveCounter from "./LiveCounter";2021const LOCALE_STORAGE_KEY = "earth-now.locale";22const PERCENT_HINTS = { decimals: 0, unit: "%" } as const;2324export interface MetricDetailProps {25  metric: MetricSummary;26  model: CounterModel | null;27}2829export default function MetricDetail({ metric, model }: MetricDetailProps) {30  const [locale, setLocaleState] = useState<Locale>("fr");31  useEffect(() => {32    const saved = window.localStorage.getItem(LOCALE_STORAGE_KEY);33    if (isLocale(saved)) setLocaleState(saved);34  }, []);35  useEffect(() => {36    document.documentElement.lang = locale;37  }, [locale]);38  const setLocale = (next: Locale) => {39    setLocaleState(next);40    window.localStorage.setItem(LOCALE_STORAGE_KEY, next);41  };42  return (43    <I18nProvider locale={locale} setLocale={setLocale}>44      <MetricDetailBody metric={metric} model={model} />45    </I18nProvider>46  );47}4849function MetricDetailBody({ metric, model }: MetricDetailProps) {50  const { locale, t } = useI18n();51  const sessionStart = useMemo(() => Date.now(), []);52  const live = model !== null && tierFor(metric, model, sessionStart) === "live";53  const hints = hintsFor(metric, locale);5455  return (56    <div className="mx-auto max-w-4xl px-4 pb-16">57      <nav className="py-6 text-sm">58        <Link href="/" className="text-accent hover:opacity-80">59          {t("metric.back")}60        </Link>61      </nav>6263      <LiveCounter64        metric={metric}65        model={model ?? undefined}66        window={resolvePrimaryWindow(metric)}67        sessionStart={sessionStart}68        size="display"69        live={live}70        noLink71      />7273      <section aria-labelledby="metric-method" className="mt-10">74        <h2 id="metric-method" className="text-lg font-semibold text-ink">75          {t("metric.methodTitle")}76        </h2>77        <p className="mt-2 max-w-3xl text-sm leading-relaxed text-ink2">78          {familyExplanation(metric.model, t)}79        </p>8081        <dl className="mt-5 grid grid-cols-1 gap-x-8 gap-y-3 text-sm sm:grid-cols-2">82          <MethodRow label={t("methodology.kind")}>83            {t(`kind.${metric.kind}` as MessageKey)} · {t(`domain.${metric.domain}` as MessageKey)}84          </MethodRow>85          <MethodRow label={t("methodology.model")}>86            {metric.model}87            {model !== null && model.modelVersion !== metric.model88              ? ` (${model.modelVersion})`89              : ""}{" "}90            — {t("metric.level")} {String(metric.level)}91          </MethodRow>92          {model !== null && (93            <MethodRow label={t("methodology.observed")}>94              {formatDateUtc(model.observedAt, locale)}95            </MethodRow>96          )}97          {model?.uncertainty !== undefined && (98            <MethodRow label={t("methodology.uncertainty")}>99              {formatUncertainty(model.uncertainty.low, model.uncertainty.high, hints, {100                locale,101              })}{" "}102              {metric.display.unit[locale]}103            </MethodRow>104          )}105          {metric.uncertaintyFraction !== undefined && (106            <MethodRow label={t("methodology.uncertainty")}>107              ± {formatValue(metric.uncertaintyFraction * 100, PERCENT_HINTS, { locale })} % (108              {t("info.estimateNote")})109            </MethodRow>110          )}111          <MethodRow label={t("metric.windows")}>112            {metric.windows.map((w) => t(`window.${w}` as MessageKey)).join(" · ")}113          </MethodRow>114        </dl>115116        <h3 className="mt-6 text-sm font-semibold uppercase tracking-wide text-ink2">117          {t("methodology.source")}118        </h3>119        <ul className="mt-2 space-y-2 text-sm">120          {metric.sources.map((source) => (121            <li key={source.id} className="rounded-xl border bg-surface px-4 py-3">122              <a123                href={source.url}124                target="_blank"125                rel="noreferrer"126                className="font-medium text-accent underline decoration-accent/40 hover:opacity-80"127              >128                {source.name}129              </a>130              <div className="mt-1 text-xs text-ink2">131                {t("methodology.license")} : {source.license} · {t("methodology.cadence")} :{" "}132                {source.cadence}133              </div>134            </li>135          ))}136        </ul>137138        {metric.editorialNote !== undefined && (139          <p className="mt-4 max-w-3xl rounded-xl bg-wash px-4 py-3 text-sm text-ink2">140            {metric.editorialNote[locale]}141          </p>142        )}143144        <p className="mt-6 text-sm">145          <Link href="/methodology" className="text-accent underline decoration-accent/40 hover:opacity-80">146            {t("metric.fullMethodology")}147          </Link>148        </p>149      </section>150    </div>151  );152}153154function MethodRow({ label, children }: { label: string; children: React.ReactNode }) {155  return (156    <div>157      <dt className="text-xs uppercase tracking-wide text-muted">{label}</dt>158      <dd className="mt-0.5 text-ink">{children}</dd>159    </div>160  );161}162163/** One-sentence how-it-works per model family (the per-metric methodology promise). */164function familyExplanation(family: string, t: (key: MessageKey) => string): string {165  const known = new Set([166    "seasonal-spline-v2",167    "keeling-fusion-v1",168    "seasonal-ytd-v1",169    "linear-ytd-v1",170    "linear-stock-v1",171    "static-rt-v1",172    "derived",173  ]);174  return known.has(family) ? t(`explain.${family}` as MessageKey) : t("explain.default");175}176