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%
3.4 KB · 40 lines tsx
Raw Blame History
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