Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.
TypeScript 90.2%
JavaScript 3.5%
Python 3.4%
CSS 1.9%
HTML 0.6%
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2/**3 * Contrat commun /api/stats/dashboard v2 du Groupe KA (voir src/ka/stats/SPEC.md),4 * construit depuis src/data/stats.json (agrégats historiques du rôle) et5 * src/data/stats-v2.json (agrégats enrichis : distributions, transactions6 * mensuelles/quotidiennes, indice de marché — générés par7 * scripts/build-stats-v2.mjs depuis data/vraiprix.db, données 100 % réelles).8 *9 * Particularité Vrai-Prix : le rôle d'évaluation est un INSTANTANÉ (millésime10 * 2026) — `period` est accepté (contrat commun) mais non applicable ; les11 * vraies séries temporelles sont les millésimes 2021-2026, le corpus de12 * ventes réelles (mensuel, 2021-01 → 2026-07) et l'indice de marché mensuel.13 * Les delta_pct sont calculés (millésime vs millésime, 12 mois vs 12 mois14 * précédents) et arrondis à 1 décimale — jamais inventés.15 */16import stats from "@/data/stats.json";17import v2 from "@/data/stats-v2.json";18import type { ProvStats } from "@/components/StatsView";1920/** Ventes réelles publiées 2021-01 → 2026-07 (constante du corpus, voir /methodologie). */21export const TRANSACTIONS = 745119;2223export const PERIOD_LABEL = "instantané du rôle 2026";2425export const TYPE_FR: Record<string, string> = {26 unifamilial: "Unifamiliale",27 plex: "Plex (2-5 log.)",28 condo_ou_multi: "Condo / multi",29 chalet: "Chalet",30 maison_mobile: "Maison mobile",31 terrain: "Terrain",32 autre: "Autre",33};3435const money = (v: number) =>36 new Intl.NumberFormat("fr-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(v);37const compact = (v: number) => {38 if (v >= 1e12) return `${(v / 1e12).toLocaleString("fr-CA", { maximumFractionDigits: 2 })} billions $`;39 if (v >= 1e9) return `${(v / 1e9).toLocaleString("fr-CA", { maximumFractionDigits: 1 })} G$`;40 if (v >= 1e6) return `${(v / 1e6).toLocaleString("fr-CA", { maximumFractionDigits: 1 })} M$`;41 return money(v);42};43const pct = (v: number) => `${v >= 0 ? "+" : ""}${v.toLocaleString("fr-CA", { maximumFractionDigits: 1 })} %`;44const r1 = (v: number) => Math.round(v * 10) / 10;45const delta = (cur: number, prev: number): number | null =>46 prev > 0 ? r1((cur / prev - 1) * 100) : null;47const MOIS_FR = ["janv.", "févr.", "mars", "avr.", "mai", "juin", "juill.", "août", "sept.", "oct.", "nov.", "déc."];48const moisLabel = (m: string) => `${MOIS_FR[parseInt(m.slice(5, 7), 10) - 1]} ${m.slice(0, 4)}`;4950/* ---------- types du contrat (SPEC §2) ---------- */51export type Point = { t: string; v: number };52export interface KaKpi {53 id: string; label: string; value: number | string; unit?: string;54 delta_pct?: number | null; direction?: "up" | "down"; spark?: Point[]; help?: string;55}56export interface KaSerie {57 id: string; title: string; unit?: string; kind: "line" | "bar" | "area";58 points: Point[]; compare?: Point[];59}60export interface KaRecord { label: string; value: string; date?: string }6162interface V2Data {63 generated: string;64 units: {65 total: number; geoloc: number; with_role: number; with_est2026: number;66 with_year: number; with_area: number; avg_est2026: number; avg_role: number;67 sum_role: number; logements: number;68 };69 valeur_bins: { label: string; value: number }[];70 construction: { label: string; n: number; total: number; moyenne: number | null }[];71 rec: {72 max_role: { municipalite: string; v: number; cubf_libelle: string | null };73 max_est: { municipalite: string; v: number };74 };75 tx: {76 monthly: { m: string; n: number; total: number; median: number }[];77 monthly_by_type: { keys: string[]; points: { t: string; values: number[] }[] };78 amount_bins: { label: string; value: number }[];79 daily: { date: string; n: number }[];80 max_date: string;81 max: { amount: number; city: string | null; date: string };82 best_month: { m: string; n: number };83 top_villes: { city: string; n: number; moyenne: number }[];84 };85 market: {86 idx_by_type: { label: string; points: Point[] }[];87 ppm2_by_type: { label: string; points: Point[] }[];88 };89}90const V = v2 as unknown as V2Data;9192/** Records & faits marquants, calculés depuis les données réelles (jamais inventés). */93export function buildRecords(s: ProvStats): KaRecord[] {94 const out: KaRecord[] = [];95 const topVille = s.par_ville[0];96 if (topVille) {97 out.push({98 label: "Municipalité la plus valorisée",99 value: `${topVille.ville} — ${compact(topVille.total)}`,100 });101 }102 const topMed = s.par_ville.reduce((a, b) => ((b.mediane ?? 0) > (a.mediane ?? 0) ? b : a), s.par_ville[0]);103 if (topMed?.mediane) {104 out.push({105 label: "Valeur médiane la plus élevée (top 200)",106 value: `${topMed.ville} — ${money(topMed.mediane)}`,107 });108 }109 let bestYoy: { year: number; yoy: number } | null = null;110 for (let i = 1; i < s.totaux_annee.length; i++) {111 const yoy = (s.totaux_annee[i].total / s.totaux_annee[i - 1].total - 1) * 100;112 if (!bestYoy || yoy > bestYoy.yoy) bestYoy = { year: s.totaux_annee[i].year, yoy };113 }114 if (bestYoy) {115 out.push({116 label: "Plus forte hausse annuelle du parc",117 value: pct(bestYoy.yoy),118 date: String(bestYoy.year),119 });120 }121 const topType = s.par_type.reduce((a, b) => (b.total > a.total ? b : a), s.par_type[0]);122 if (topType) {123 const share = (topType.total / s.valeur_totale_2026) * 100;124 out.push({125 label: "Type de bien dominant",126 value: `${TYPE_FR[topType.type] ?? topType.type} — ${compact(topType.total)} (${share.toLocaleString("fr-CA", { maximumFractionDigits: 1 })} %)`,127 });128 }129 out.push({130 label: "Croissance 2021→2026 (périmètre constant)",131 value: pct(s.croissance_2021_2026_pct),132 });133 // records v2 — rôle & corpus de ventes (stats-v2.json, données réelles)134 if (V.rec?.max_role) {135 out.push({136 label: "Plus grosse évaluation au rôle",137 value: `${V.rec.max_role.cubf_libelle ?? "Immeuble"} (${V.rec.max_role.municipalite}) — ${compact(V.rec.max_role.v)}`,138 });139 }140 if (V.rec?.max_est) {141 out.push({142 label: "Estimation Vrai-Prix la plus élevée",143 value: `${V.rec.max_est.municipalite} — ${compact(V.rec.max_est.v)}`,144 });145 }146 if (V.tx?.max) {147 out.push({148 label: "Plus grosse vente du corpus",149 value: `${compact(V.tx.max.amount)}${V.tx.max.city ? ` — ${V.tx.max.city}` : ""}`,150 date: V.tx.max.date,151 });152 }153 if (V.tx?.best_month) {154 out.push({155 label: "Mois record de ventes",156 value: `${V.tx.best_month.n.toLocaleString("fr-CA")} ventes`,157 date: moisLabel(V.tx.best_month.m),158 });159 }160 const topTx = V.tx?.top_villes?.[0];161 if (topTx) {162 out.push({163 label: "Ville la plus active en ventes (2021-2026)",164 value: `${topTx.city} — ${topTx.n.toLocaleString("fr-CA")} ventes`,165 });166 }167 return out;168}169170function buildDashboard() {171 const s = stats as ProvStats;172 const u = V.units;173 const gpct = (a: number, b: number) => r1((100 * a) / (b || 1));174175 /* --- millésimes (2021-2026) : deltas millésime vs millésime précédent --- */176 const ta = s.totaux_annee;177 const last = ta[ta.length - 1];178 const prev = ta[ta.length - 2];179 const avgPrev = prev ? prev.total / prev.n : 0;180181 /* --- corpus de ventes : 12 derniers mois vs 12 mois précédents (réel) --- */182 const m = V.tx.monthly;183 const last12 = m.slice(-12);184 const prev12 = m.slice(-24, -12);185 const sum = (a: { n: number; total: number }[], k: "n" | "total") => a.reduce((x, y) => x + y[k], 0);186 const lastMonth = m[m.length - 1];187 const sameMonthN1 = m[m.length - 13];188189 const kpis: KaKpi[] = [190 {191 id: "unites", label: "Propriétés couvertes (rôle 2026)", value: s.unites,192 delta_pct: prev ? delta(last.n, prev.n) : null, direction: "up",193 spark: ta.map((a) => ({ t: String(a.year), v: a.n })),194 help: "Unités d'évaluation estimées au millésime 2026 — variation vs millésime 2025.",195 },196 {197 id: "valeur_totale", label: "Valeur totale du parc (2026)", value: compact(s.valeur_totale_2026),198 delta_pct: prev ? delta(last.total, prev.total) : null, direction: "up",199 spark: ta.map((a) => ({ t: String(a.year), v: a.total })),200 help: "Somme des estimations Vrai-Prix — variation vs millésime 2025.",201 },202 {203 id: "valeur_moyenne", label: "Valeur moyenne (2026)", value: money(u.avg_est2026),204 delta_pct: avgPrev ? delta(u.avg_est2026, avgPrev) : null, direction: "up",205 spark: ta.map((a) => ({ t: String(a.year), v: Math.round(a.total / a.n) })),206 },207 { id: "valeur_mediane", label: "Valeur médiane (2026)", value: money(s.valeur_mediane_2026) },208 { id: "municipalites", label: "Municipalités couvertes", value: s.municipalites },209 { id: "logements", label: "Logements au rôle", value: u.logements },210 {211 id: "ventes_12m", label: "Ventes réelles — 12 derniers mois", value: sum(last12, "n"),212 delta_pct: delta(sum(last12, "n"), sum(prev12, "n")),213 direction: sum(last12, "n") >= sum(prev12, "n") ? "up" : "down",214 spark: m.slice(-24).map((x) => ({ t: x.m, v: x.n })),215 help: "Corpus de ventes publiées — 12 derniers mois vs les 12 mois précédents.",216 },217 {218 id: "volume_12m", label: "Volume des ventes — 12 derniers mois", value: compact(sum(last12, "total")),219 delta_pct: delta(sum(last12, "total"), sum(prev12, "total")),220 direction: sum(last12, "total") >= sum(prev12, "total") ? "up" : "down",221 spark: m.slice(-24).map((x) => ({ t: x.m, v: x.total })),222 },223 {224 id: "prix_vente_median", label: `Prix de vente médian (${moisLabel(lastMonth.m)})`, value: money(lastMonth.median),225 delta_pct: sameMonthN1 ? delta(lastMonth.median, sameMonthN1.median) : null,226 direction: sameMonthN1 && lastMonth.median >= sameMonthN1.median ? "up" : "down",227 spark: m.slice(-24).map((x) => ({ t: x.m, v: x.median })),228 help: "Médiane des ventes du dernier mois complet du corpus — variation vs le même mois un an plus tôt.",229 },230 {231 id: "transactions", label: "Ventes réelles du corpus (2021-01 → 2026-07)", value: TRANSACTIONS,232 help: "Toutes les ventes publiées intégrées au modèle de comparables.",233 },234 ];235236 const gauges = [237 {238 id: "geoloc", label: "Unités géolocalisées", value: gpct(u.geoloc, u.total), max: 100, unit: "%",239 help: "Unités du rôle avec coordonnées lat/lng valides.",240 },241 {242 id: "role_connu", label: "Valeur au rôle connue", value: gpct(u.with_role, u.total), max: 100, unit: "%",243 help: "Unités avec évaluation municipale renseignée.",244 },245 {246 id: "annee_connue", label: "Année de construction connue", value: gpct(u.with_year, u.total), max: 100, unit: "%",247 },248 {249 id: "aire_connue", label: "Superficie de plancher connue", value: gpct(u.with_area, u.total), max: 100, unit: "%",250 },251 ];252253 const series: KaSerie[] = [254 {255 id: "valeur_millesime", title: "Valeur provinciale par millésime (2021-2026)", unit: "$",256 kind: "line", points: ta.map((a) => ({ t: String(a.year), v: a.total })),257 },258 {259 id: "unites_millesime", title: "Unités estimées par millésime", unit: "unités",260 kind: "line", points: ta.map((a) => ({ t: String(a.year), v: a.n })),261 },262 {263 id: "ventes_mois", title: "Ventes réelles par mois (corpus 2021-2026)", unit: "ventes",264 kind: "bar", points: m.map((x) => ({ t: x.m, v: x.n })),265 },266 {267 id: "volume_mois", title: "Volume mensuel des ventes ($)", unit: "$",268 kind: "area", points: m.map((x) => ({ t: x.m, v: x.total })),269 },270 {271 id: "prix_median_mois", title: "Prix de vente médian par mois", unit: "$",272 kind: "line", points: m.map((x) => ({ t: x.m, v: x.median })),273 },274 ];275276 const multiseries = [277 {278 id: "indice_marche", title: "Indice de marché par type (base 1,0)", unit: "",279 series: V.market.idx_by_type,280 },281 {282 id: "ppm2", title: "Prix au m² des ventes, par type", unit: "$/m²",283 series: V.market.ppm2_by_type,284 },285 ];286287 const stacked = [288 {289 id: "ventes_type", title: "Ventes mensuelles par type de propriété", unit: "ventes",290 keys: V.tx.monthly_by_type.keys, points: V.tx.monthly_by_type.points,291 },292 ];293294 const breakdowns = [295 {296 id: "types", title: "Répartition de la valeur par type de bien", kind: "donut" as const,297 items: s.par_type.map((t) => ({ label: TYPE_FR[t.type] ?? t.type, value: t.total })),298 },299 {300 id: "types_n", title: "Unités par type de bien", kind: "bars" as const,301 items: s.par_type.map((t) => ({ label: TYPE_FR[t.type] ?? t.type, value: t.n })),302 },303 {304 id: "construction", title: "Valeur par tranche d'année de construction", kind: "bars" as const,305 items: V.construction.map((c) => ({ label: c.label, value: c.total })),306 },307 ];308309 const distributions = [310 {311 id: "valeurs", title: "Distribution des valeurs estimées (2026)", unit: "unités",312 bins: V.valeur_bins,313 },314 {315 id: "montants_vente", title: "Distribution des montants de vente (2021-2026)", unit: "ventes",316 bins: V.tx.amount_bins,317 },318 ];319320 const geo = {321 title: "Top municipalités par valeur totale estimée",322 items: s.par_ville.slice(0, 15).map((v) => ({ label: v.ville, value: v.total })),323 };324325 const heatmap = {326 title: `Ventes réelles par jour (corpus, jusqu'au ${V.tx.max_date})`,327 cells: V.tx.daily.map((d) => ({ date: d.date, value: d.n })),328 };329330 const topMediane = [...s.par_ville]331 .filter((v) => v.mediane != null)332 .sort((a, b) => (b.mediane ?? 0) - (a.mediane ?? 0))333 .slice(0, 20);334335 const idxEnsemble = V.market.idx_by_type.find((x) => x.label === "Ensemble");336 const ppm2 = (label: string, t: string) =>337 V.market.ppm2_by_type.find((x) => x.label === label)?.points.find((p) => p.t === t)?.v ?? null;338 const last12Months = (idxEnsemble?.points ?? []).slice(-12);339340 const tables = [341 {342 id: "top_municipalites",343 title: "Palmarès des 200 plus grandes municipalités (millésime 2026)",344 columns: ["Rang", "Municipalité", "Propriétés", "Valeur totale ($)", "Valeur médiane ($)"],345 rows: s.par_ville.map((v, i) => [i + 1, v.ville, v.n, v.total, v.mediane] as (string | number | null)[]),346 },347 {348 id: "top_medianes",349 title: "Top 20 municipalités par valeur médiane (parmi le top 200)",350 columns: ["Rang", "Municipalité", "Valeur médiane ($)", "Propriétés", "Valeur totale ($)"],351 rows: topMediane.map((v, i) => [i + 1, v.ville, v.mediane, v.n, v.total] as (string | number | null)[]),352 },353 {354 id: "par_type",355 title: "Répartition par type de propriété",356 columns: ["Type", "Unités", "Valeur totale ($)", "Valeur médiane ($)"],357 rows: s.par_type.map((t) => [TYPE_FR[t.type] ?? t.type, t.n, t.total, t.mediane] as (string | number | null)[]),358 },359 {360 id: "construction",361 title: "Parc par tranche d'année de construction",362 columns: ["Tranche", "Unités", "Valeur totale ($)", "Valeur moyenne ($)"],363 rows: V.construction.map((c) => [c.label, c.n, c.total, c.moyenne] as (string | number | null)[]),364 },365 {366 id: "indice_marche",367 title: "Indice de marché — 12 derniers mois",368 columns: ["Mois", "Indice (ensemble)", "$/m² unifamiliale", "$/m² condo", "$/m² plex"],369 rows: last12Months.map((p) => [370 p.t, p.v, ppm2("Unifamiliale", p.t), ppm2("Condo", p.t), ppm2("Plex", p.t),371 ] as (string | number | null)[]),372 },373 {374 id: "top_villes_ventes",375 title: "Top 20 villes par nombre de ventes (corpus 2021-2026)",376 columns: ["Rang", "Ville", "Ventes", "Montant moyen ($)"],377 rows: V.tx.top_villes.map((v, i) => [i + 1, v.city, v.n, v.moyenne] as (string | number | null)[]),378 },379 ];380381 return {382 updated: V.generated,383 period: {384 from: null as string | null,385 to: null as string | null,386 label: PERIOD_LABEL,387 applicable: false,388 note:389 "Les données de Vrai-Prix sont un instantané du rôle d'évaluation foncière (millésime 2026) : " +390 "le paramètre `period` est accepté mais sans effet. Les séries temporelles réelles servies sont " +391 "les millésimes 2021-2026, le corpus de ventes mensuel (2021-01 → 2026-07) et l'indice de marché.",392 },393 kpis,394 gauges,395 series,396 multiseries,397 stacked,398 breakdowns,399 distributions,400 geo,401 heatmap,402 tables,403 records: buildRecords(s),404 };405}406407export type StatsDashboard = ReturnType<typeof buildDashboard>;408409/** Cache mémoire : les données sont des imports statiques, on ne construit qu'une fois. */410let cache: StatsDashboard | null = null;411export function getDashboard(): StatsDashboard {412 if (!cache) cache = buildDashboard();413 return cache;414}415