"use client"; import Link from "next/link"; import { useEffect, useMemo, useRef, useState } from "react"; import type { Forecast, LiveState, Meta } from "@/lib/api"; import { cls, dateTimeFr, int, pct, prob, relTime, statusLabel, timeFr } from "@/lib/format"; import { PARTY_IDS, partyVar, partyLabel, partyName } from "@/lib/parties"; import { SeatMeter } from "./charts"; import { MapSection } from "./map-section"; import { PartyBadge, PartyDot, PartyLogo } from "./party"; import { Badge, Card, LiveBadge, ProbabilityBar, ProjectionBadge, SectionHeader } from "./ui"; interface LiveEvent { id: number; ts: string; kind: string; message: string; ridingCode: number | null; riding?: string | null; party: string | null } /** Hook : état live + événements, rafraîchi par SSE (repli : polling 20 s). */ export function useLive(initial?: LiveState | null) { const [state, setState] = useState(initial ?? null); const [events, setEvents] = useState([]); const [connected, setConnected] = useState(false); const [lastMsg, setLastMsg] = useState(null); const lastId = useRef(0); const refresh = async () => { try { const [st, ev] = await Promise.all([fetch("/api/live/state", { cache: "no-store" }).then((r) => r.json()), fetch(`/api/live/events?since=${lastId.current}&limit=60`, { cache: "no-store" }).then((r) => r.json())]); setState(st); if (ev.events?.length) { setEvents((prev) => { const merged = [...ev.events, ...prev]; const seen = new Set(); return merged.filter((e) => !seen.has(e.id) && seen.add(e.id)).slice(0, 200); }); lastId.current = Math.max(lastId.current, ...ev.events.map((e: LiveEvent) => e.id)); } setLastMsg(new Date().toISOString()); } catch { /* garder le dernier état valide */ } }; useEffect(() => { refresh(); let es: EventSource | null = null; let poll: ReturnType | null = null; try { es = new EventSource("/api/live/stream"); es.onopen = () => setConnected(true); es.onerror = () => setConnected(false); es.addEventListener("snapshot", () => refresh()); es.addEventListener("nowcast", () => refresh()); es.addEventListener("status", () => refresh()); } catch { setConnected(false); } poll = setInterval(refresh, 20000); return () => { es?.close(); if (poll) clearInterval(poll); }; }, []); return { state, events, connected, lastMsg }; } export function LiveHome({ meta, forecast, initial }: { meta: Meta; forecast: Forecast; initial?: LiveState | null }) { const { state, events, connected } = useLive(initial); const seatsNow = state?.seatsNow ?? {}; const totalNow = (id: string) => (seatsNow[id]?.elected ?? 0) + (seatsNow[id]?.leading ?? 0); const leaderNow = PARTY_IDS.slice().sort((a, b) => totalNow(b) - totalNow(a))[0]; const nc = state?.nowcast; const reporting = state?.snapshot?.reporting ?? 0; const archive = meta.mode === "archive"; return (
{archive ? Archive · Résultats : }{state?.simulation && Simulation (données synthétiques 2022)}{state?.quarantine && Flux en quarantaine}

Québec 2026
{archive ? "Résultats officiels" : "Résultats en direct"}

{reporting} / {meta.seatsTotal}
circonscriptions rapportent
{meta.majority}
majorité
{archive ? "Résultat final" : "Résultat live"} · élus + en avance
[id, totalNow(id)]))} total={meta.seatsTotal} majority={meta.majority} showLabels={false} title="Sièges en avance ou élus" />
{PARTY_IDS.slice().sort((a, b) => totalNow(b) - totalNow(a)).map((id) => (
{seatsNow[id]?.elected ?? 0} élus · {seatsNow[id]?.leading ?? 0} en avance
{totalNow(id)}
))}
{nc && (
Probabilité de majorité {partyLabel(leaderNow)} · Nowcast QC26
Nowcast
{prob(nc.parties[leaderNow]?.p_majority)}
Minoritaire {prob(nc.p_minority)} · couverture {pct(nc.coverage * 100, 0)} · {relTime(nc.ts)}
)}
Modèle vs réalité →} /> {PARTY_IDS.slice().sort((a, b) => totalNow(b) - totalNow(a) || forecast.parties[b].seatsMedian - forecast.parties[a].seatsMedian).map((id) => ( ))}
PartiForecastÉlusEn avanceLiveNowcastP(maj)
{partyLabel(id)} {forecast.parties[id].seatsMedian} ({forecast.parties[id].seats80[0]}–{forecast.parties[id].seats80[1]}) {seatsNow[id]?.elected ?? 0} {seatsNow[id]?.leading ?? 0} {totalNow(id)} {nc ? `${nc.parties[id]?.seats_median} ` : "—"}{nc && ({nc.parties[id]?.seats80[0]}–{nc.parties[id]?.seats80[1]})} {nc ? prob(nc.parties[id]?.p_majority) : "—"}
); } export function FeedStatus({ state, connected }: { state: LiveState | null; connected: boolean }) { const lv = state?.lastValid; return (
Flux Élections Québec · {state?.quarantine ? actualisation temporairement retardée (anomalie détectée) : lv ? dernière donnée officielle reçue {timeFr(lv.at)}{lv.source_updated_at ? ` (horodatage DGEQ ${lv.source_updated_at.slice(11, 19)})` : ""} : en attente des premiers résultats}
Source : donnees.electionsquebec.qc.ca (JSON officiel, mis à jour toutes les 2 à 5 min après 20 h). QC26 conserve toujours la dernière donnée valide ; l'absence de données n'est jamais affichée comme un zéro.
); } export function Ticker({ events, limit = 30 }: { events: LiveEvent[]; limit?: number }) { return ( {events.length === 0 &&
Aucun événement pour l'instant.
}
    {events.slice(0, limit).map((e) => (
  • {timeFr(e.ts).slice(0, 5)} {e.ridingCode ? {e.message} : e.message}
  • ))}
); } export function MajorityTimeline() { const [hist, setHist] = useState<{ ts: string; reporting: number; parties: Record; pMinority: number }[]>([]); useEffect(() => { const load = () => fetch("/api/live/nowcast/history", { cache: "no-store" }).then((r) => r.json()).then((d) => setHist(d.history ?? [])).catch(() => {}); load(); const t = setInterval(load, 30000); return () => clearInterval(t); }, []); if (hist.length < 2) return null; const W = 420, H = 160, pl = 30, pb = 22; const x = (i: number) => pl + (i / (hist.length - 1)) * (W - pl - 8); const y = (p: number) => 8 + (1 - p) * (H - pb - 8); return (
{[0, 0.5, 1].map((p) => ({Math.round(p * 100)}))} {PARTY_IDS.map((id) => ( `${x(i)},${y(h.parties[id]?.pMajority ?? 0)}`).join(" ")} />))} `${x(i)},${y(h.pMinority ?? 0)}`).join(" ")} /> {[0, Math.floor(hist.length / 2), hist.length - 1].map((i) => ({timeFr(hist[i].ts).slice(0, 5)}))}
{PARTY_IDS.map((id) => {partyLabel(id)})}Minoritaire
); } export function CloseRaces({ state, limit = 10 }: { state: LiveState | null; limit?: number }) { const [names, setNames] = useState>({}); useEffect(() => { fetch("/api/ridings").then((r) => r.json()).then((d) => setNames(Object.fromEntries(d.ridings.map((r: { code: number; name: string; slug: string }) => [String(r.code), { name: r.name, slug: r.slug }])))).catch(() => {}); }, []); const rows = useMemo(() => Object.entries(state?.ridings ?? {}).filter(([, r]) => r.bureaux[0] > 0 && r.marginPct !== null && !r.final).sort((a, b) => (a[1].marginPct ?? 99) - (b[1].marginPct ?? 99)).slice(0, limit), [state, limit]); if (!rows.length) return
Aucune circonscription en dépouillement.
; return (
{rows.map(([code, r]) => (
{names[code]?.name ?? code}
{statusLabel(r.status)} · {r.bureaux[0]}/{r.bureaux[1]} bureaux
{(r.candidates ?? []).slice(0, 3).map((c) => (
{c.name}{int(c.votes)}
))}
Marge {pct(r.marginPct)} · {int(r.marginVotes)} votes
))}
); } export { PartyBadge, partyName, dateTimeFr };