SPB Git forge

spb/qc26

Public
10commits 1branches 0releases
2.8 MBsize
maindefault branch
17 days agolast push
Python 51.6% TypeScript 46.7% CSS 1.7%
5.8 KB · 38 lines tsx
Raw Blame History
1import type { Metadata } from "next";2import Link from "next/link";3import { apiSafe } from "@/lib/api";4import { PartyBadge } from "@/components/party";5import { Card, Empty, SectionHeader } from "@/components/ui";6import { PARTY_IDS, partyVar, partyLabel } from "@/lib/parties";7import { pct, prob } from "@/lib/format";89export const metadata: Metadata = { title: "Modèle vs réalité", description: "Précision de la prévision QC26 face aux résultats : circonscriptions correctes, surprises, tossups, calibration, erreur sur les sièges." };10export const revalidate = 0;1112interface MvR { runId: number; ridingsCompared: number; correct: number; wrong: number; upsets: { code: number; name: string; slug: string; favorite: string; p: number; leader: string; bureaux: [number, number]; marginPct: number | null; final: boolean }[]; tossups: { correct: number; total: number }; calibration: Record<string, { n: number; hits: number }>; forecastSeats: Record<string, number>; liveSeats: Record<string, { elected: number; leading: number }>; seatMAE: number | null }1314export default async function Page() {15  const d = await apiSafe<MvR>("/api/live/model-vs-reality", { revalidate: 0 });16  if (!d) return <Empty title="Indisponible" />;17  const acc = d.ridingsCompared ? d.correct / d.ridingsCompared : null;18  return (19    <div className="space-y-10">20      <div><Link href="/live" className="text-[12.5px] text-ink-3 hover:text-ink">← Direct</Link><h1 className="display text-[40px] md:text-[56px] mt-3">Modèle vs réalité</h1><p className="text-ink-2 mt-3 max-w-2xl text-[15px]">QC26 assume publiquement ses erreurs. Cette page compare la dernière prévision publiée avant 20 h (run #{d.runId}) aux résultats officiels, au fil de la soirée puis en version finale.</p></div>21      {d.ridingsCompared === 0 ? <Empty title="En attente des premiers résultats">Les métriques apparaîtront dès que des circonscriptions rapporteront.</Empty> : (22        <>23          <div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-3">24            <Card><div className="eyebrow">Circonscriptions correctes</div><div className="num display text-[40px] mt-1">{d.correct}<span className="text-ink-3 text-[20px]"> / {d.ridingsCompared}</span></div><div className="text-[12.5px] text-ink-2">{acc !== null ? prob(acc) : "—"} · favori QC26 = leader actuel</div></Card>25            <Card><div className="eyebrow">Surprises</div><div className="num display text-[40px] mt-1">{d.upsets.length}</div><div className="text-[12.5px] text-ink-2">favori ≥ 70 % battu ou devancé</div></Card>26            <Card><div className="eyebrow">Tossups corrects</div><div className="num display text-[40px] mt-1">{d.tossups.correct}<span className="text-ink-3 text-[20px]"> / {d.tossups.total}</span></div><div className="text-[12.5px] text-ink-2">courses classées « serré »</div></Card>27            <Card><div className="eyebrow">Seat MAE</div><div className="num display text-[40px] mt-1">{d.seatMAE !== null ? d.seatMAE.toFixed(1).replace(".", ",") : "—"}</div><div className="text-[12.5px] text-ink-2">{d.seatMAE !== null ? "erreur absolue moyenne (sièges, final)" : "disponible au dépouillement complet"}</div></Card>28          </div>29          <section><SectionHeader eyebrow="Sièges" title="Forecast vs Live" /><Card pad={false}><table className="data-table"><thead><tr><th>Parti</th><th className="r">Forecast (médiane)</th><th className="r">Élus</th><th className="r">En avance</th><th className="r">Live</th><th className="r">Écart</th></tr></thead><tbody>{PARTY_IDS.map((id) => { const live = (d.liveSeats[id]?.elected ?? 0) + (d.liveSeats[id]?.leading ?? 0); return <tr key={id}><td><PartyBadge id={id} size="sm" /></td><td className="r num">{d.forecastSeats[id]}</td><td className="r num">{d.liveSeats[id]?.elected ?? 0}</td><td className="r num">{d.liveSeats[id]?.leading ?? 0}</td><td className="r num font-bold" style={{ color: partyVar(id) }}>{live}</td><td className="r num">{live - d.forecastSeats[id] > 0 ? "+" : ""}{live - d.forecastSeats[id]}</td></tr>; })}</tbody></table></Card></section>30          <section><SectionHeader eyebrow="Calibration" title="Probabilité annoncée vs fréquence observée" description="Dans une prévision calibrée, les favoris à 70–80 % gagnent environ 75 % du temps." /><Card><div className="grid grid-cols-2 sm:grid-cols-5 gap-2">{Object.entries(d.calibration).map(([b, v]) => (<div key={b} className="surface-2 rounded-[8px] p-3 text-center"><div className="text-[11.5px] text-ink-3 num">{b} %</div><div className="num font-bold text-[20px]">{v.n ? Math.round((100 * v.hits) / v.n) : "—"} %</div><div className="text-[11.5px] text-ink-3 num">{v.hits}/{v.n} corrects</div></div>))}</div></Card></section>31          <section><SectionHeader eyebrow="Surprises de la soirée" title="Upsets" /><Card pad={false}>{d.upsets.length === 0 ? <div className="p-6 text-center text-ink-3 text-[13.5px]">Aucune surprise pour l&apos;instant.</div> : <table className="data-table"><thead><tr><th>Circonscription</th><th>Favori QC26</th><th className="r">Probabilité initiale</th><th>Leader actuel</th><th className="r">Bureaux</th><th className="r">Marge</th></tr></thead><tbody>{d.upsets.map((u) => <tr key={u.code}><td><Link href={`/circonscription/${u.slug}`} className="hover:underline font-medium">{u.name}</Link></td><td><PartyBadge id={u.favorite} size="sm" /></td><td className="r num">{prob(u.p)}</td><td><PartyBadge id={u.leader} size="sm" /> {u.final && <span className="text-[10.5px] uppercase text-ok">final</span>}</td><td className="r num">{u.bureaux[0]}/{u.bureaux[1]}</td><td className="r num">{pct(u.marginPct)}</td></tr>)}</tbody></table>}</Card></section>32          <p className="text-[12px] text-ink-3">{partyLabel("pq")} et autres sigles : voir <Link href="/partis" className="link">Partis</Link>.</p>33        </>34      )}35    </div>36  );37}38