1/** Accès serveur → moteur QC26 (FastAPI). Côté client, les appels passent par le rewrite /api. */2export const ENGINE_URL = process.env.QC26_ENGINE_URL || "http://127.0.0.1:8201";3export const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://www.qc26.ca";45export class ApiError extends Error {6 constructor(public status: number, message: string) {7 super(message);8 }9}1011export async function api<T>(path: string, opts: { revalidate?: number; cache?: RequestCache } = {}): Promise<T> {12 const url = `${ENGINE_URL}${path}`;13 const res = await fetch(url, {14 next: { revalidate: opts.revalidate ?? 60 },15 cache: opts.cache,16 headers: { accept: "application/json" },17 });18 if (!res.ok) throw new ApiError(res.status, `${res.status} ${path}`);19 return res.json() as Promise<T>;20}2122export async function apiSafe<T>(path: string, opts: { revalidate?: number } = {}): Promise<T | null> {23 try {24 return await api<T>(path, opts);25 } catch {26 return null;27 }28}2930// ------------------------------------------------------------------ types31export type PartyId = "caq" | "plq" | "pq" | "qs" | "pcq";32export type PartyKey = PartyId | "aut";3334export interface Meta {35 electionDate: string;36 electionName: string;37 campaignStart: string;38 pollsCloseLocal: string;39 seatsTotal: number;40 majority: number;41 daysToElection: number;42 mode: "forecast" | "live" | "archive";43 simulation: boolean;44 modelVersion: string;45 latestRun: { id: number; completedAt: string | null; pollCount: number; modelVersion: string } | null;46 parties: RegistryParty[];47 others: { id: string; label: string; color: string };48 pollRegions: Record<string, string>;49 topics: Record<string, string>;50 axes: Record<string, { label: string; left: string; right: string }>;51 disclaimer: string;52}5354export interface RegistryParty {55 id: PartyId;56 abbreviation: string;57 officialName: string;58 shortName: string;59 color: string;60 secondaryColor: string;61 website: string;62 dgeqId: string;63 colorNote: string;64 order: number;65}6667export interface PartyForecast {68 voteMean: number;69 voteSd: number;70 vote80: [number, number];71 vote95: [number, number];72 seatsMean: number;73 seatsMedian: number;74 seats80: [number, number];75 seats95: [number, number];76 pMostSeats: number;77 pMajority: number;78 seatDistribution: number[] | null;79}8081export interface TrendPoint {82 date: string;83 mean: Record<string, number>;84 sd: Record<string, number>;85 n_polls: number;86}8788export interface ForecastSummaryParty {89 vote: number;90 vote_sd: number;91 vote80: [number, number];92 seats: number;93 seats_mean: number;94 seats80: [number, number];95 seats95: [number, number];96 p_most_seats: number;97 p_majority: number;98}99100export interface Forecast {101 id: number;102 modelVersion: string;103 dataCutoff: string;104 completedAt: string | null;105 pollCount: number;106 nSimulations: number;107 seed: number;108 codeCommit: string | null;109 trigger: string | null;110 summary: {111 seats_total: number;112 majority: number;113 parties: Record<string, ForecastSummaryParty>;114 p_minority: number;115 leader: string;116 poll_count: number;117 latest_poll: string;118 national_now: Record<string, number>;119 cutoff: string;120 days_to_election: number;121 battlegrounds: number[];122 ridings: Record<string, { fav: string; p: number; second: string; margin: number }>;123 };124 diff: {125 previous_run: number;126 previous_at: string | null;127 changes: Record<string, { vote: number; seats: number; p_majority: number; p_most_seats: number }>;128 flips: { code: number; from: string; to: string; p: number }[];129 causes: { kind: string; detail: string | string[] }[];130 } | null;131 parties: Record<string, PartyForecast>;132 trend: TrendPoint[] | null;133}134135export interface Party extends RegistryParty {136 leader: string | null;137 leaderSince: string | null;138 dgeqName: string | null;139 spokespersons: string | null;140 candidatesCount: number;141 seats2022: number;142 vote2022: number | null;143 forecast: { voteMean: number; seatsMedian: number; seats80: [number, number]; pMajority: number; pMostSeats: number } | null;144 documents: number;145 proposals: number;146}147148export interface RidingListItem {149 code: number;150 name: string;151 slug: string;152 region: string | null;153 regionName: string | null;154 electors: number | null;155 isNew: boolean;156 changed: boolean;157 incumbent: { party: string | null; name: string | null; running: boolean | null };158 centroid: [number | null, number | null];159 forecast: { favorite: string; p: number; runnerUp: string | null; margin: number; rating: string; swing: number; pWin: Record<string, number> } | null;160 live: { status: string; leader: string | null; bureaux: [number, number]; marginPct: number | null } | null;161}162163export interface Poll {164 id: string;165 pollster: { id: string; name: string };166 sponsor: string | null;167 publicationDate: string | null;168 fieldworkStart: string | null;169 fieldworkEnd: string;170 sampleSize: number | null;171 population: string | null;172 methodology: string | null;173 mode: string | null;174 marginOfError: number | null;175 sourceUrl: string | null;176 sourceTier: string;177 extractionMethod: string;178 confidence: number;179 validated: boolean;180 excluded: boolean;181 exclusionReason: string | null;182 undecided: number | null;183 results: Record<string, number>;184 resultsPage: number | null;185 ingestedAt: string | null;186 document: { id: number; sha256: string; pages: number | null; bytes: number; contentType: string | null; downloadedAt: string; archiveUrl: string } | null;187 weight: { weight: number; recency: number; sample: number; impact: Record<string, number> | null } | null;188 validation: { checks?: string[]; warnings?: string[]; sum?: number } | null;189 nonPrimarySource: boolean;190 subsamples?: Record<string, Record<string, Record<string, number | null>>>;191 leadership?: { question: string; page: number; values: Record<string, number> }[] | null;192 llmNotes?: string[] | null;193 houseEffects?: Record<string, number>;194 audit?: { ts: string; actor: string; field: string | null; old: string | null; new: string | null; reason: string | null }[];195}196197export interface LiveState {198 mode: string;199 simulation: boolean;200 seatsNow: Record<string, { elected: number; leading: number }>;201 lastValid: { snapshot_id: number; at: string; source_updated_at: string | null; reporting: number; simulated: boolean } | null;202 quarantine: boolean;203 quarantineReason: string | null;204 snapshot: { id: number; fetchedAt: string; sourceUpdatedAt: string | null; reporting: number; total: number; bureaux: [number, number]; final: boolean } | null;205 nowcast: {206 id: number;207 ts: string;208 majority: number;209 reporting: number;210 coverage: number;211 p_minority: number;212 parties: Record<string, { elected: number; leading: number; total_now: number; seats_mean: number; seats_median: number; seats80: [number, number]; p_majority: number; p_most_seats: number; vote_est: number | null }>;213 projected_now: number[];214 } | null;215 ridings: Record<string, { status: string; leader: string | null; leaderCandidate: string | null; bureaux: [number, number]; votes: number; marginVotes: number | null; marginPct: number | null; turnout: number | null; candidates: { name: string; party_id: string; votes: number; share: number }[] | null; final: boolean }>;216 nowcastRidings: Record<string, { p: number[]; est: number[]; w: number }> | null;217 seatsTotal: number;218 majority: number;219}220