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/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