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.2 KB · 57 lines tsx
Raw Blame History
1import type { Metadata } from "next";2import { apiSafe, type Forecast, type Poll } from "@/lib/api";3import { Legend } from "@/components/charts";4import { TrendChartInteractive } from "@/components/trend-chart";5import { Card, Empty, PageTitle, SectionHeader } from "@/components/ui";6import { PollsTable } from "@/components/polls-table";7import { PARTY_IDS, partyVar, partyLabel } from "@/lib/parties";8import { dateShort } from "@/lib/format";910export const metadata: Metadata = { title: "Sondages Québec 2026 — Agrégateur et tendances", description: "Tous les sondages provinciaux depuis l'élection de 2022, avec documents originaux archivés, poids dans le modèle QC26, effets maison et sous-échantillons." };11export const revalidate = 60;1213interface Pollster { id: string; name: string; website: string | null; polls: number; lastPoll: string | null; houseEffects: Record<string, number> | null; nUsed: number | null }1415export default async function Page() {16  const [data, f, ps] = await Promise.all([apiSafe<{ polls: Poll[]; trend: Forecast["trend"] }>("/api/polls?limit=600"), apiSafe<Forecast>("/api/forecast/latest"), apiSafe<{ pollsters: Pollster[] }>("/api/pollsters")]);17  if (!data) return <Empty title="Sondages indisponibles" />;18  const polls = data.polls;19  const pollPts = polls.filter((p) => !p.excluded).map((p) => ({ date: p.fieldworkEnd, values: p.results, pollster: p.pollster.name }));20  const primary = polls.filter((p) => p.sourceTier === "primary").length;21  return (22    <div className="space-y-12">23      <PageTitle eyebrow={`Sondages · ${polls.length} sondages · ${primary} documents originaux archivés`} title="Moyenne QC26"24        lede="Chaque sondage provincial depuis le scrutin d'octobre 2022 : téléchargé chez la firme, haché (SHA-256), archivé, extrait page par page et validé avant d'entrer dans le modèle. Les lignes marquées « index » attendent encore leur document original." />25      <Card>26        {f?.trend && <TrendChartInteractive trend={f.trend} polls={pollPts} height={360} election2022={{ caq: 40.98, plq: 14.37, qs: 15.43, pq: 14.61, pcq: 12.91 }} />}27        <div className="flex flex-wrap items-center justify-between gap-2 mt-2"><Legend ids={PARTY_IDS} /><span className="text-[12px] text-ink-3">Points = sondages bruts · ligne = moyenne QC26 corrigée des effets maison · bande 80 %</span></div>28      </Card>29      <section>30        <SectionHeader eyebrow="Derniers sondages" title="Tous les sondages" description="Cliquez sur une ligne pour ouvrir le Poll Inspector : document original, pages utilisées, sous-échantillons, poids et impact sur le modèle." />31        <Card pad={false}><PollsTable polls={polls} /></Card>32      </section>33      {ps && (34        <section>35          <SectionHeader eyebrow="Firmes" title="Effets maison" description="Écart moyen de chaque firme à la tendance des autres firmes (points), rétréci de moitié et borné à ±3 ; positif = la firme mesure le parti plus haut que la moyenne." />36          <div className="md:hidden grid grid-cols-1 gap-2">37            {ps.pollsters.sort((a, b) => b.polls - a.polls).map((p) => (38              <div key={p.id} className="card p-3">39                <div className="flex items-baseline justify-between"><span className="font-semibold text-[14px]">{p.name}</span><span className="text-[12px] text-ink-3 num">{p.polls} sondages · dernier {dateShort(p.lastPoll)}</span></div>40                <div className="grid grid-cols-5 gap-1 mt-2">{PARTY_IDS.map((id) => <div key={id} className="inset text-center py-1.5" style={{ borderTop: `3px solid ${partyVar(id)}` }}><div className="mono text-[9.5px] uppercase tracking-[0.06em] text-ink-2">{partyLabel(id)}</div><div className="mono text-[13.5px] font-semibold">{p.houseEffects?.[id] !== undefined ? (p.houseEffects[id] > 0 ? "+" : "") + p.houseEffects[id].toFixed(1).replace(".", ",") : "—"}</div></div>)}</div>41              </div>42            ))}43            <p className="text-[12px] text-ink-3 px-1">— : moins de 2 sondages dans la fenêtre de 400 jours, aucun effet maison estimé.</p>44          </div>45          <Card pad={false} className="overflow-x-auto hidden md:block">46            <table className="data-table min-w-[640px]"><thead><tr><th>Firme</th><th className="r">Sondages</th><th>Dernier</th>{PARTY_IDS.map((id) => <th key={id} className="r" style={{ color: partyVar(id) }}>{partyLabel(id)}</th>)}</tr></thead>47              <tbody>{ps.pollsters.sort((a, b) => b.polls - a.polls).map((p) => (48                <tr key={p.id}><td className="font-semibold">{p.website ? <a href={p.website} target="_blank" rel="noopener noreferrer" className="hover:underline">{p.name}</a> : p.name}</td><td className="r num">{p.polls}</td><td className="num">{dateShort(p.lastPoll)}</td>{PARTY_IDS.map((id) => <td key={id} className="r num">{p.houseEffects?.[id] !== undefined ? (p.houseEffects[id] > 0 ? "+" : "") + p.houseEffects[id].toFixed(1).replace(".", ",") : <span className="text-ink-3">—</span>}</td>)}</tr>49              ))}</tbody></table>50            <p className="text-[12px] text-ink-3 p-3">— : moins de 2 sondages dans la fenêtre de 400 jours, aucun effet maison estimé (le sondage entre sans correction).</p>51          </Card>52        </section>53      )}54    </div>55  );56}57