// Auteur : Simon-Pierre Boucher — contact@spboucher.ai /** * Contrat commun /api/stats/dashboard v2 du Groupe KA (voir src/ka/stats/SPEC.md), * construit depuis src/data/stats.json (agrégats historiques du rôle) et * src/data/stats-v2.json (agrégats enrichis : distributions, transactions * mensuelles/quotidiennes, indice de marché — générés par * scripts/build-stats-v2.mjs depuis data/vraiprix.db, données 100 % réelles). * * Particularité Vrai-Prix : le rôle d'évaluation est un INSTANTANÉ (millésime * 2026) — `period` est accepté (contrat commun) mais non applicable ; les * vraies séries temporelles sont les millésimes 2021-2026, le corpus de * ventes réelles (mensuel, 2021-01 → 2026-07) et l'indice de marché mensuel. * Les delta_pct sont calculés (millésime vs millésime, 12 mois vs 12 mois * précédents) et arrondis à 1 décimale — jamais inventés. */ import stats from "@/data/stats.json"; import v2 from "@/data/stats-v2.json"; import type { ProvStats } from "@/components/StatsView"; /** Ventes réelles publiées 2021-01 → 2026-07 (constante du corpus, voir /methodologie). */ export const TRANSACTIONS = 745119; export const PERIOD_LABEL = "instantané du rôle 2026"; export const TYPE_FR: Record = { unifamilial: "Unifamiliale", plex: "Plex (2-5 log.)", condo_ou_multi: "Condo / multi", chalet: "Chalet", maison_mobile: "Maison mobile", terrain: "Terrain", autre: "Autre", }; const money = (v: number) => new Intl.NumberFormat("fr-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(v); const compact = (v: number) => { if (v >= 1e12) return `${(v / 1e12).toLocaleString("fr-CA", { maximumFractionDigits: 2 })} billions $`; if (v >= 1e9) return `${(v / 1e9).toLocaleString("fr-CA", { maximumFractionDigits: 1 })} G$`; if (v >= 1e6) return `${(v / 1e6).toLocaleString("fr-CA", { maximumFractionDigits: 1 })} M$`; return money(v); }; const pct = (v: number) => `${v >= 0 ? "+" : ""}${v.toLocaleString("fr-CA", { maximumFractionDigits: 1 })} %`; const r1 = (v: number) => Math.round(v * 10) / 10; const delta = (cur: number, prev: number): number | null => prev > 0 ? r1((cur / prev - 1) * 100) : null; const MOIS_FR = ["janv.", "févr.", "mars", "avr.", "mai", "juin", "juill.", "août", "sept.", "oct.", "nov.", "déc."]; const moisLabel = (m: string) => `${MOIS_FR[parseInt(m.slice(5, 7), 10) - 1]} ${m.slice(0, 4)}`; /* ---------- types du contrat (SPEC §2) ---------- */ export type Point = { t: string; v: number }; export interface KaKpi { id: string; label: string; value: number | string; unit?: string; delta_pct?: number | null; direction?: "up" | "down"; spark?: Point[]; help?: string; } export interface KaSerie { id: string; title: string; unit?: string; kind: "line" | "bar" | "area"; points: Point[]; compare?: Point[]; } export interface KaRecord { label: string; value: string; date?: string } interface V2Data { generated: string; units: { total: number; geoloc: number; with_role: number; with_est2026: number; with_year: number; with_area: number; avg_est2026: number; avg_role: number; sum_role: number; logements: number; }; valeur_bins: { label: string; value: number }[]; construction: { label: string; n: number; total: number; moyenne: number | null }[]; rec: { max_role: { municipalite: string; v: number; cubf_libelle: string | null }; max_est: { municipalite: string; v: number }; }; tx: { monthly: { m: string; n: number; total: number; median: number }[]; monthly_by_type: { keys: string[]; points: { t: string; values: number[] }[] }; amount_bins: { label: string; value: number }[]; daily: { date: string; n: number }[]; max_date: string; max: { amount: number; city: string | null; date: string }; best_month: { m: string; n: number }; top_villes: { city: string; n: number; moyenne: number }[]; }; market: { idx_by_type: { label: string; points: Point[] }[]; ppm2_by_type: { label: string; points: Point[] }[]; }; } const V = v2 as unknown as V2Data; /** Records & faits marquants, calculés depuis les données réelles (jamais inventés). */ export function buildRecords(s: ProvStats): KaRecord[] { const out: KaRecord[] = []; const topVille = s.par_ville[0]; if (topVille) { out.push({ label: "Municipalité la plus valorisée", value: `${topVille.ville} — ${compact(topVille.total)}`, }); } const topMed = s.par_ville.reduce((a, b) => ((b.mediane ?? 0) > (a.mediane ?? 0) ? b : a), s.par_ville[0]); if (topMed?.mediane) { out.push({ label: "Valeur médiane la plus élevée (top 200)", value: `${topMed.ville} — ${money(topMed.mediane)}`, }); } let bestYoy: { year: number; yoy: number } | null = null; for (let i = 1; i < s.totaux_annee.length; i++) { const yoy = (s.totaux_annee[i].total / s.totaux_annee[i - 1].total - 1) * 100; if (!bestYoy || yoy > bestYoy.yoy) bestYoy = { year: s.totaux_annee[i].year, yoy }; } if (bestYoy) { out.push({ label: "Plus forte hausse annuelle du parc", value: pct(bestYoy.yoy), date: String(bestYoy.year), }); } const topType = s.par_type.reduce((a, b) => (b.total > a.total ? b : a), s.par_type[0]); if (topType) { const share = (topType.total / s.valeur_totale_2026) * 100; out.push({ label: "Type de bien dominant", value: `${TYPE_FR[topType.type] ?? topType.type} — ${compact(topType.total)} (${share.toLocaleString("fr-CA", { maximumFractionDigits: 1 })} %)`, }); } out.push({ label: "Croissance 2021→2026 (périmètre constant)", value: pct(s.croissance_2021_2026_pct), }); // records v2 — rôle & corpus de ventes (stats-v2.json, données réelles) if (V.rec?.max_role) { out.push({ label: "Plus grosse évaluation au rôle", value: `${V.rec.max_role.cubf_libelle ?? "Immeuble"} (${V.rec.max_role.municipalite}) — ${compact(V.rec.max_role.v)}`, }); } if (V.rec?.max_est) { out.push({ label: "Estimation Vrai-Prix la plus élevée", value: `${V.rec.max_est.municipalite} — ${compact(V.rec.max_est.v)}`, }); } if (V.tx?.max) { out.push({ label: "Plus grosse vente du corpus", value: `${compact(V.tx.max.amount)}${V.tx.max.city ? ` — ${V.tx.max.city}` : ""}`, date: V.tx.max.date, }); } if (V.tx?.best_month) { out.push({ label: "Mois record de ventes", value: `${V.tx.best_month.n.toLocaleString("fr-CA")} ventes`, date: moisLabel(V.tx.best_month.m), }); } const topTx = V.tx?.top_villes?.[0]; if (topTx) { out.push({ label: "Ville la plus active en ventes (2021-2026)", value: `${topTx.city} — ${topTx.n.toLocaleString("fr-CA")} ventes`, }); } return out; } function buildDashboard() { const s = stats as ProvStats; const u = V.units; const gpct = (a: number, b: number) => r1((100 * a) / (b || 1)); /* --- millésimes (2021-2026) : deltas millésime vs millésime précédent --- */ const ta = s.totaux_annee; const last = ta[ta.length - 1]; const prev = ta[ta.length - 2]; const avgPrev = prev ? prev.total / prev.n : 0; /* --- corpus de ventes : 12 derniers mois vs 12 mois précédents (réel) --- */ const m = V.tx.monthly; const last12 = m.slice(-12); const prev12 = m.slice(-24, -12); const sum = (a: { n: number; total: number }[], k: "n" | "total") => a.reduce((x, y) => x + y[k], 0); const lastMonth = m[m.length - 1]; const sameMonthN1 = m[m.length - 13]; const kpis: KaKpi[] = [ { id: "unites", label: "Propriétés couvertes (rôle 2026)", value: s.unites, delta_pct: prev ? delta(last.n, prev.n) : null, direction: "up", spark: ta.map((a) => ({ t: String(a.year), v: a.n })), help: "Unités d'évaluation estimées au millésime 2026 — variation vs millésime 2025.", }, { id: "valeur_totale", label: "Valeur totale du parc (2026)", value: compact(s.valeur_totale_2026), delta_pct: prev ? delta(last.total, prev.total) : null, direction: "up", spark: ta.map((a) => ({ t: String(a.year), v: a.total })), help: "Somme des estimations Vrai-Prix — variation vs millésime 2025.", }, { id: "valeur_moyenne", label: "Valeur moyenne (2026)", value: money(u.avg_est2026), delta_pct: avgPrev ? delta(u.avg_est2026, avgPrev) : null, direction: "up", spark: ta.map((a) => ({ t: String(a.year), v: Math.round(a.total / a.n) })), }, { id: "valeur_mediane", label: "Valeur médiane (2026)", value: money(s.valeur_mediane_2026) }, { id: "municipalites", label: "Municipalités couvertes", value: s.municipalites }, { id: "logements", label: "Logements au rôle", value: u.logements }, { id: "ventes_12m", label: "Ventes réelles — 12 derniers mois", value: sum(last12, "n"), delta_pct: delta(sum(last12, "n"), sum(prev12, "n")), direction: sum(last12, "n") >= sum(prev12, "n") ? "up" : "down", spark: m.slice(-24).map((x) => ({ t: x.m, v: x.n })), help: "Corpus de ventes publiées — 12 derniers mois vs les 12 mois précédents.", }, { id: "volume_12m", label: "Volume des ventes — 12 derniers mois", value: compact(sum(last12, "total")), delta_pct: delta(sum(last12, "total"), sum(prev12, "total")), direction: sum(last12, "total") >= sum(prev12, "total") ? "up" : "down", spark: m.slice(-24).map((x) => ({ t: x.m, v: x.total })), }, { id: "prix_vente_median", label: `Prix de vente médian (${moisLabel(lastMonth.m)})`, value: money(lastMonth.median), delta_pct: sameMonthN1 ? delta(lastMonth.median, sameMonthN1.median) : null, direction: sameMonthN1 && lastMonth.median >= sameMonthN1.median ? "up" : "down", spark: m.slice(-24).map((x) => ({ t: x.m, v: x.median })), help: "Médiane des ventes du dernier mois complet du corpus — variation vs le même mois un an plus tôt.", }, { id: "transactions", label: "Ventes réelles du corpus (2021-01 → 2026-07)", value: TRANSACTIONS, help: "Toutes les ventes publiées intégrées au modèle de comparables.", }, ]; const gauges = [ { id: "geoloc", label: "Unités géolocalisées", value: gpct(u.geoloc, u.total), max: 100, unit: "%", help: "Unités du rôle avec coordonnées lat/lng valides.", }, { id: "role_connu", label: "Valeur au rôle connue", value: gpct(u.with_role, u.total), max: 100, unit: "%", help: "Unités avec évaluation municipale renseignée.", }, { id: "annee_connue", label: "Année de construction connue", value: gpct(u.with_year, u.total), max: 100, unit: "%", }, { id: "aire_connue", label: "Superficie de plancher connue", value: gpct(u.with_area, u.total), max: 100, unit: "%", }, ]; const series: KaSerie[] = [ { id: "valeur_millesime", title: "Valeur provinciale par millésime (2021-2026)", unit: "$", kind: "line", points: ta.map((a) => ({ t: String(a.year), v: a.total })), }, { id: "unites_millesime", title: "Unités estimées par millésime", unit: "unités", kind: "line", points: ta.map((a) => ({ t: String(a.year), v: a.n })), }, { id: "ventes_mois", title: "Ventes réelles par mois (corpus 2021-2026)", unit: "ventes", kind: "bar", points: m.map((x) => ({ t: x.m, v: x.n })), }, { id: "volume_mois", title: "Volume mensuel des ventes ($)", unit: "$", kind: "area", points: m.map((x) => ({ t: x.m, v: x.total })), }, { id: "prix_median_mois", title: "Prix de vente médian par mois", unit: "$", kind: "line", points: m.map((x) => ({ t: x.m, v: x.median })), }, ]; const multiseries = [ { id: "indice_marche", title: "Indice de marché par type (base 1,0)", unit: "", series: V.market.idx_by_type, }, { id: "ppm2", title: "Prix au m² des ventes, par type", unit: "$/m²", series: V.market.ppm2_by_type, }, ]; const stacked = [ { id: "ventes_type", title: "Ventes mensuelles par type de propriété", unit: "ventes", keys: V.tx.monthly_by_type.keys, points: V.tx.monthly_by_type.points, }, ]; const breakdowns = [ { id: "types", title: "Répartition de la valeur par type de bien", kind: "donut" as const, items: s.par_type.map((t) => ({ label: TYPE_FR[t.type] ?? t.type, value: t.total })), }, { id: "types_n", title: "Unités par type de bien", kind: "bars" as const, items: s.par_type.map((t) => ({ label: TYPE_FR[t.type] ?? t.type, value: t.n })), }, { id: "construction", title: "Valeur par tranche d'année de construction", kind: "bars" as const, items: V.construction.map((c) => ({ label: c.label, value: c.total })), }, ]; const distributions = [ { id: "valeurs", title: "Distribution des valeurs estimées (2026)", unit: "unités", bins: V.valeur_bins, }, { id: "montants_vente", title: "Distribution des montants de vente (2021-2026)", unit: "ventes", bins: V.tx.amount_bins, }, ]; const geo = { title: "Top municipalités par valeur totale estimée", items: s.par_ville.slice(0, 15).map((v) => ({ label: v.ville, value: v.total })), }; const heatmap = { title: `Ventes réelles par jour (corpus, jusqu'au ${V.tx.max_date})`, cells: V.tx.daily.map((d) => ({ date: d.date, value: d.n })), }; const topMediane = [...s.par_ville] .filter((v) => v.mediane != null) .sort((a, b) => (b.mediane ?? 0) - (a.mediane ?? 0)) .slice(0, 20); const idxEnsemble = V.market.idx_by_type.find((x) => x.label === "Ensemble"); const ppm2 = (label: string, t: string) => V.market.ppm2_by_type.find((x) => x.label === label)?.points.find((p) => p.t === t)?.v ?? null; const last12Months = (idxEnsemble?.points ?? []).slice(-12); const tables = [ { id: "top_municipalites", title: "Palmarès des 200 plus grandes municipalités (millésime 2026)", columns: ["Rang", "Municipalité", "Propriétés", "Valeur totale ($)", "Valeur médiane ($)"], rows: s.par_ville.map((v, i) => [i + 1, v.ville, v.n, v.total, v.mediane] as (string | number | null)[]), }, { id: "top_medianes", title: "Top 20 municipalités par valeur médiane (parmi le top 200)", columns: ["Rang", "Municipalité", "Valeur médiane ($)", "Propriétés", "Valeur totale ($)"], rows: topMediane.map((v, i) => [i + 1, v.ville, v.mediane, v.n, v.total] as (string | number | null)[]), }, { id: "par_type", title: "Répartition par type de propriété", columns: ["Type", "Unités", "Valeur totale ($)", "Valeur médiane ($)"], rows: s.par_type.map((t) => [TYPE_FR[t.type] ?? t.type, t.n, t.total, t.mediane] as (string | number | null)[]), }, { id: "construction", title: "Parc par tranche d'année de construction", columns: ["Tranche", "Unités", "Valeur totale ($)", "Valeur moyenne ($)"], rows: V.construction.map((c) => [c.label, c.n, c.total, c.moyenne] as (string | number | null)[]), }, { id: "indice_marche", title: "Indice de marché — 12 derniers mois", columns: ["Mois", "Indice (ensemble)", "$/m² unifamiliale", "$/m² condo", "$/m² plex"], rows: last12Months.map((p) => [ p.t, p.v, ppm2("Unifamiliale", p.t), ppm2("Condo", p.t), ppm2("Plex", p.t), ] as (string | number | null)[]), }, { id: "top_villes_ventes", title: "Top 20 villes par nombre de ventes (corpus 2021-2026)", columns: ["Rang", "Ville", "Ventes", "Montant moyen ($)"], rows: V.tx.top_villes.map((v, i) => [i + 1, v.city, v.n, v.moyenne] as (string | number | null)[]), }, ]; return { updated: V.generated, period: { from: null as string | null, to: null as string | null, label: PERIOD_LABEL, applicable: false, note: "Les données de Vrai-Prix sont un instantané du rôle d'évaluation foncière (millésime 2026) : " + "le paramètre `period` est accepté mais sans effet. Les séries temporelles réelles servies sont " + "les millésimes 2021-2026, le corpus de ventes mensuel (2021-01 → 2026-07) et l'indice de marché.", }, kpis, gauges, series, multiseries, stacked, breakdowns, distributions, geo, heatmap, tables, records: buildRecords(s), }; } export type StatsDashboard = ReturnType; /** Cache mémoire : les données sont des imports statiques, on ne construit qu'une fois. */ let cache: StatsDashboard | null = null; export function getDashboard(): StatsDashboard { if (!cache) cache = buildDashboard(); return cache; }