Python 51.6%
TypeScript 46.7%
CSS 1.7%
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