Python 51.6%
TypeScript 46.7%
CSS 1.7%
1"use client";23import { useEffect, useState } from "react";4import { int, pct, prob, statusLabel, timeFr } from "@/lib/format";5import { partyVar, partyLabel } from "@/lib/parties";6import { PartyDot } from "./party";7import { Badge, Card, LiveBadge, SectionHeader } from "./ui";89interface Live { status: string; updatedAt: string | null; bureaux: [number, number]; votesValid: number; turnout: number | null; leader: string | null; marginVotes: number | null; marginPct: number | null; candidates: { name: string; party_id: string; votes: number; share: number }[] | null; final: boolean }1011export function RidingLive({ code, initial, forecast }: { code: number; initial: Live; forecast: { favorite: string; p: number } | null }) {12 const [live, setLive] = useState<Live>(initial);13 useEffect(() => {14 const load = () => fetch(`/api/ridings/${code}`, { cache: "no-store" }).then((r) => r.json()).then((d) => d.live && setLive(d.live)).catch(() => {});15 const t = setInterval(load, 20000);16 let es: EventSource | null = null;17 try { es = new EventSource("/api/live/stream"); es.addEventListener("snapshot", load); } catch { /* polling */ }18 return () => { clearInterval(t); es?.close(); };19 }, [code]);20 const total = live.candidates?.reduce((a, c) => a + c.votes, 0) ?? 0;21 return (22 <section>23 <SectionHeader eyebrow="Soirée électorale" title="Résultats officiels" description="Données d'Élections Québec ; « Projeté QC26 » est une inférence du nowcast, « Élu·e / Final » provient du dépouillement complet." action={<LiveBadge label={live.final ? "Final" : "Direct"} />} />24 <Card>25 <div className="flex flex-wrap items-center gap-3 text-[13.5px]"><Badge tone={live.status === "PROJECTED" ? "accent" : live.status === "ELECTED" || live.status === "FINAL" ? "ok" : "neutral"}>{statusLabel(live.status)}</Badge><span className="num">{live.bureaux[0]} / {live.bureaux[1]} bureaux ({live.bureaux[1] ? Math.round((100 * live.bureaux[0]) / live.bureaux[1]) : 0} %)</span><span className="num text-ink-2">{int(live.votesValid)} votes valides</span>{live.turnout && <span className="num text-ink-2">participation {pct(live.turnout)}</span>}<span className="num text-ink-3 ml-auto">màj {timeFr(live.updatedAt)}</span></div>26 <div className="mt-4 space-y-2">27 {(live.candidates ?? []).map((c, i) => (28 <div key={c.name} className="grid grid-cols-[1fr_120px_70px_60px] items-center gap-3 text-[13.5px]">29 <span className="inline-flex items-center gap-2 truncate"><PartyDot id={c.party_id} />{c.name} <span className="text-ink-3">{partyLabel(c.party_id)}</span>{i === 0 && live.leader && <span className="text-[10.5px] uppercase font-semibold text-ink-2">en tête</span>}</span>30 <div className="h-[12px] bg-surface-3 rounded-[4px] overflow-hidden"><div className="h-full" style={{ width: `${total ? (c.votes / total) * 100 : 0}%`, background: partyVar(c.party_id) }} /></div>31 <span className="num text-right">{int(c.votes)}</span><span className="num text-right font-semibold">{pct(total ? (100 * c.votes) / total : 0)}</span>32 </div>33 ))}34 </div>35 {live.marginVotes !== null && <div className="text-[12.5px] text-ink-2 mt-3 num">Marge : {int(live.marginVotes)} votes ({pct(live.marginPct)}){forecast && <> · Forecast QC26 avant 20 h : {partyLabel(forecast.favorite)} favori à {prob(forecast.p)}</>}</div>}36 </Card>37 </section>38 );39}40