/** Accès serveur → moteur QC26 (FastAPI). Côté client, les appels passent par le rewrite /api. */ export const ENGINE_URL = process.env.QC26_ENGINE_URL || "http://127.0.0.1:8201"; export const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://www.qc26.ca"; export class ApiError extends Error { constructor(public status: number, message: string) { super(message); } } export async function api(path: string, opts: { revalidate?: number; cache?: RequestCache } = {}): Promise { const url = `${ENGINE_URL}${path}`; const res = await fetch(url, { next: { revalidate: opts.revalidate ?? 60 }, cache: opts.cache, headers: { accept: "application/json" }, }); if (!res.ok) throw new ApiError(res.status, `${res.status} ${path}`); return res.json() as Promise; } export async function apiSafe(path: string, opts: { revalidate?: number } = {}): Promise { try { return await api(path, opts); } catch { return null; } } // ------------------------------------------------------------------ types export type PartyId = "caq" | "plq" | "pq" | "qs" | "pcq"; export type PartyKey = PartyId | "aut"; export interface Meta { electionDate: string; electionName: string; campaignStart: string; pollsCloseLocal: string; seatsTotal: number; majority: number; daysToElection: number; mode: "forecast" | "live" | "archive"; simulation: boolean; modelVersion: string; latestRun: { id: number; completedAt: string | null; pollCount: number; modelVersion: string } | null; parties: RegistryParty[]; others: { id: string; label: string; color: string }; pollRegions: Record; topics: Record; axes: Record; disclaimer: string; } export interface RegistryParty { id: PartyId; abbreviation: string; officialName: string; shortName: string; color: string; secondaryColor: string; website: string; dgeqId: string; colorNote: string; order: number; } export interface PartyForecast { voteMean: number; voteSd: number; vote80: [number, number]; vote95: [number, number]; seatsMean: number; seatsMedian: number; seats80: [number, number]; seats95: [number, number]; pMostSeats: number; pMajority: number; seatDistribution: number[] | null; } export interface TrendPoint { date: string; mean: Record; sd: Record; n_polls: number; } export interface ForecastSummaryParty { vote: number; vote_sd: number; vote80: [number, number]; seats: number; seats_mean: number; seats80: [number, number]; seats95: [number, number]; p_most_seats: number; p_majority: number; } export interface Forecast { id: number; modelVersion: string; dataCutoff: string; completedAt: string | null; pollCount: number; nSimulations: number; seed: number; codeCommit: string | null; trigger: string | null; summary: { seats_total: number; majority: number; parties: Record; p_minority: number; leader: string; poll_count: number; latest_poll: string; national_now: Record; cutoff: string; days_to_election: number; battlegrounds: number[]; ridings: Record; }; diff: { previous_run: number; previous_at: string | null; changes: Record; flips: { code: number; from: string; to: string; p: number }[]; causes: { kind: string; detail: string | string[] }[]; } | null; parties: Record; trend: TrendPoint[] | null; } export interface Party extends RegistryParty { leader: string | null; leaderSince: string | null; dgeqName: string | null; spokespersons: string | null; candidatesCount: number; seats2022: number; vote2022: number | null; forecast: { voteMean: number; seatsMedian: number; seats80: [number, number]; pMajority: number; pMostSeats: number } | null; documents: number; proposals: number; } export interface RidingListItem { code: number; name: string; slug: string; region: string | null; regionName: string | null; electors: number | null; isNew: boolean; changed: boolean; incumbent: { party: string | null; name: string | null; running: boolean | null }; centroid: [number | null, number | null]; forecast: { favorite: string; p: number; runnerUp: string | null; margin: number; rating: string; swing: number; pWin: Record } | null; live: { status: string; leader: string | null; bureaux: [number, number]; marginPct: number | null } | null; } export interface Poll { id: string; pollster: { id: string; name: string }; sponsor: string | null; publicationDate: string | null; fieldworkStart: string | null; fieldworkEnd: string; sampleSize: number | null; population: string | null; methodology: string | null; mode: string | null; marginOfError: number | null; sourceUrl: string | null; sourceTier: string; extractionMethod: string; confidence: number; validated: boolean; excluded: boolean; exclusionReason: string | null; undecided: number | null; results: Record; resultsPage: number | null; ingestedAt: string | null; document: { id: number; sha256: string; pages: number | null; bytes: number; contentType: string | null; downloadedAt: string; archiveUrl: string } | null; weight: { weight: number; recency: number; sample: number; impact: Record | null } | null; validation: { checks?: string[]; warnings?: string[]; sum?: number } | null; nonPrimarySource: boolean; subsamples?: Record>>; leadership?: { question: string; page: number; values: Record }[] | null; llmNotes?: string[] | null; houseEffects?: Record; audit?: { ts: string; actor: string; field: string | null; old: string | null; new: string | null; reason: string | null }[]; } export interface LiveState { mode: string; simulation: boolean; seatsNow: Record; lastValid: { snapshot_id: number; at: string; source_updated_at: string | null; reporting: number; simulated: boolean } | null; quarantine: boolean; quarantineReason: string | null; snapshot: { id: number; fetchedAt: string; sourceUpdatedAt: string | null; reporting: number; total: number; bureaux: [number, number]; final: boolean } | null; nowcast: { id: number; ts: string; majority: number; reporting: number; coverage: number; p_minority: number; parties: Record; projected_now: number[]; } | null; ridings: Record; nowcastRidings: Record | null; seatsTotal: number; majority: number; }