/** 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; components?: ForecastComponents; }; 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[] | Record }[]; } | null; parties: Record; trend: TrendPoint[] | null; } export interface ComponentParties { parties: Record; p_minority: number } export interface ForecastComponents { pollsOnly: ComponentParties; modelNoMarkets: ComponentParties; fundamentals: { enabled: boolean; weight: number; daysLeft?: number; satisfaction?: number; satisfactionPolls?: number; incumbent?: string; incumbentPrior?: number; prior?: Record; shiftPp?: Record; params?: Record }; momentum: { enabled: boolean; shiftPp: Record; parts: { leader: Record; attention: Record; media: Record }; params: Record }; markets: { enabled: boolean; weight: number; bandWeight?: number; ess?: number; constraints?: string[]; achieved?: Record>; marketAsOf?: string | null; marketVolume?: number; mostSeatsMarket?: Record; majorityMarket?: Record; seatBandsImpliedMean?: Record }; byelections: { enabled: boolean; ridings: { code: number; riding: string; date: string; adjLogit: Record; kappa: number }[] }; signals: { attention: { enabled?: boolean; asOf?: string; share7?: Record; share28?: Record; delta?: Record }; media: { enabled?: boolean; asOf?: string; articles?: number; shareOfVoice?: Record; tone?: Record; counts?: Record }; fundamentals: { enabled?: boolean; satisfaction?: number | null; satisfactionNet?: number | null; bestPmShare?: Record; leaderNetFavorable?: Record; secondChoice?: Record; sovereigntyYes?: number | null }; markets: { asOf?: string | null; mostSeats?: Record; majority?: Record; volume?: number } | null; byelections: { n: number; meanSwing: Record | null } }; ess: number; nSimulations: number; } export interface MarketRow { id: string; question: string; kind: string; party: string | null; price: number; bid: number | null; ask: number | null; volume: number | null; liquidity: number | null; url: string | null; updatedAt: string | null; band: [number | null, number | null] | null; place: number | null } export interface MarketsPayload { source: string; asOf: string | null; mostSeats: Record; mostSeatsRaw?: Record; majority: Record; seatBands: Record; place: Record>; volume: number; markets: MarketRow[]; history: Record } export interface AttentionPayload { enabled: boolean; asOf?: string; share7?: Record; share28?: Record; delta?: Record; series?: { date: string; share: Record; views: Record }[]; election?: { date: string; views: number }[]; pages?: Record } export interface MediaItem { id: number; title: string; domain: string | null; url: string; publishedAt: string; parties: Record | null; topic: string | null; summary: string | null } export interface MediaPayload { enabled: boolean; window: number; asOf: string; articles: number; shareOfVoice: Record; tone: Record; counts: Record; gdelt: Record; series: { date: string; articles?: Record; share_of_voice?: Record; tone?: Record }[]; latest: MediaItem[]; topics: Record } export interface FundamentalsPayload { enabled: boolean; asOf: string; incumbent: string; satisfaction: number | null; satisfactionNet: number | null; satisfactionPolls: number; satisfactionBilan: number | null; bestPmShare: Record; bestPm: Record; leaderNetFavorable: Record; leaderFavorable: Record; changeLeader: Record; secondChoice: Record; sovereigntyYes: number | null; sovereigntyPolls: number; series: Record; params: Record } export interface ByElectionsPayload { enabled: boolean; n: number; meanSwing: Record; byElections: { id: string; date: string; riding: string | null; code: number; url: string; turnout: number | null; winner: string | null; results: { candidate: string; party_label: string; party_id: string | null; votes: number; share: number }[]; previous: Record | null; swing: Record; archiveSha: string | null }[] } export interface SignalsPayload { asOf: string; runId: number | null; components: ForecastComponents | null; markets: MarketsPayload; attention: AttentionPayload; media: MediaPayload; fundamentals: FundamentalsPayload; byElections: ByElectionsPayload } 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; }