SPB Git forge

spb/valoplex

Public

ValoPlex — moteur d'évaluation spécialisé pour les plex au Québec, petit frère de Vrai-Prix.

11commits 1branches 0releases
2.4 MBsize
maindefault branch
20 days agolast push
TypeScript 91.9% Python 6% CSS 2.1%

stats v3 : rapports PDF personnalisés (catalogue, rendu au choix, constructeur bilingue)

- lib/report-custom.ts : dashboard dérivé de stats.json, catalogue de blocs,
  moteur PDF personnalisé au gabarit ValoPlex (LETTER, orange), rendus
  line/area/bar/donut/bars/cards/table
- routes /api/stats/catalog + /api/stats/report/custom (rapport v1 intact)
- components/ReportBuilder.tsx : modal fr/en, ordre, rendus, modèles
  localStorage — monté dans StatsView
Simon-Pierre Boucher committed 1 mo ago (Aug 23, 2026) parent b3d040f

5 changed files +756 −0

added app/src/app/api/stats/catalog/route.ts +16 −0
@@ -0,0 +1,16 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/** GET /api/stats/catalog — v3 : blocs composables du constructeur de
3 + * rapports personnalisés (dérivés de src/data/stats.json). */
4 +import { NextResponse } from "next/server";
5 +import { catalogFromStats } from "@/lib/report-custom";
6 +import type { ProvStats } from "@/components/StatsView";
7 +import stats from "@/data/stats.json";
8 +
9 +export async function GET() {
10 + const s = stats as ProvStats;
11 + return NextResponse.json(
12 + { updated: s.generated, period: { label: "instantané du rôle 2026", applicable: false },
13 + blocks: catalogFromStats(s) },
14 + { headers: { "Cache-Control": "public, max-age=300" } },
15 + );
16 +}
added app/src/app/api/stats/report/custom/route.ts +31 −0
@@ -0,0 +1,31 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/** POST /api/stats/report/custom — v3 : rapport PDF personnalisé ValoPlex.
3 + * Corps : {"title", "blocks": [{"key": "series:millesimes", "render": "bar"}, …]}
4 + * — ordre respecté, clés inconnues ignorées, aucune clé valide → 400. */
5 +import { type NextRequest, NextResponse } from "next/server";
6 +import { buildCustomReport, customFilename, type CustomSpec } from "@/lib/report-custom";
7 +import type { ProvStats } from "@/components/StatsView";
8 +import stats from "@/data/stats.json";
9 +
10 +export async function POST(req: NextRequest) {
11 + let spec: CustomSpec;
12 + try {
13 + spec = (await req.json()) as CustomSpec;
14 + } catch {
15 + return NextResponse.json({ error: "Corps JSON invalide" }, { status: 400 });
16 + }
17 + try {
18 + const pdf = await buildCustomReport(stats as ProvStats, spec ?? {});
19 + return new NextResponse(new Uint8Array(pdf), {
20 + headers: {
21 + "Content-Type": "application/pdf",
22 + "Content-Disposition": `attachment; filename="${customFilename()}"`,
23 + },
24 + });
25 + } catch (e) {
26 + if (e instanceof Error && e.message === "aucun-bloc") {
27 + return NextResponse.json({ error: "Aucun bloc valide dans la composition" }, { status: 400 });
28 + }
29 + throw e;
30 + }
31 +}
added app/src/components/ReportBuilder.tsx +225 −0
@@ -0,0 +1,225 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * v3 — constructeur de rapports personnalisés ValoPlex (ka-stats SPEC §3bis,
4 + * port maison au style ValoPlex, bilingue fr/en). Compose un PDF bloc par
5 + * bloc : catalogue (GET /api/stats/catalog), rendu au choix, ordre libre,
6 + * modèles sauvegardés en localStorage (clé ka-stats-rapports).
7 + */
8 +"use client";
9 +import { useEffect, useState } from "react";
10 +import { useLang } from "./LangContext";
11 +
12 +type CatalogBlock = {
13 + key: string; section: string; title: string;
14 + renders: string[]; default_render: string; count?: number;
15 +};
16 +type Sel = { key: string; render: string };
17 +type Tpl = { name: string; title: string; blocks: Sel[] };
18 +
19 +const TPL_KEY = "ka-stats-rapports";
20 +const RENDER_FR: Record<string, string> = {
21 + line: "Courbe", area: "Aire", bar: "Barres verticales", bars: "Barres horizontales",
22 + donut: "Anneau", cards: "Cartes", table: "Tableau",
23 +};
24 +const RENDER_EN: Record<string, string> = {
25 + line: "Line", area: "Area", bar: "Vertical bars", bars: "Horizontal bars",
26 + donut: "Donut", cards: "Cards", table: "Table",
27 +};
28 +const SECTION_FR: Record<string, string> = {
29 + kpis: "Indicateurs", series: "Évolution", breakdowns: "Répartitions",
30 + geo: "Géographie", tables: "Tableaux", records: "Records",
31 +};
32 +const SECTION_EN: Record<string, string> = {
33 + kpis: "Indicators", series: "Trends", breakdowns: "Breakdowns",
34 + geo: "Geography", tables: "Tables", records: "Records",
35 +};
36 +
37 +const loadTpls = (): Tpl[] => {
38 + try { return JSON.parse(localStorage.getItem(TPL_KEY) ?? "[]"); } catch { return []; }
39 +};
40 +const saveTpls = (t: Tpl[]) => { try { localStorage.setItem(TPL_KEY, JSON.stringify(t)); } catch { /* privé */ } };
41 +
42 +export default function ReportBuilder() {
43 + const { lang } = useLang();
44 + const fr = lang === "fr";
45 + const RL = fr ? RENDER_FR : RENDER_EN;
46 + const SL = fr ? SECTION_FR : SECTION_EN;
47 + const [open, setOpen] = useState(false);
48 + const [cat, setCat] = useState<CatalogBlock[] | null>(null);
49 + const [sel, setSel] = useState<Sel[]>([]);
50 + const [title, setTitle] = useState("");
51 + const [busy, setBusy] = useState(false);
52 + const [err, setErr] = useState("");
53 + const [tpls, setTpls] = useState<Tpl[]>([]);
54 +
55 + useEffect(() => {
56 + if (!open) return;
57 + setErr("");
58 + setTpls(loadTpls());
59 + fetch("/api/stats/catalog")
60 + .then((r) => (r.ok ? r.json() : Promise.reject(r.status)))
61 + .then((d) => setCat(d.blocks ?? []))
62 + .catch(() => setErr(fr ? "Catalogue indisponible — réessayez plus tard." : "Catalog unavailable — try again later."));
63 + const esc = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); };
64 + document.addEventListener("keydown", esc);
65 + const prev = document.body.style.overflow;
66 + document.body.style.overflow = "hidden";
67 + return () => { document.removeEventListener("keydown", esc); document.body.style.overflow = prev; };
68 + }, [open, fr]);
69 +
70 + const generate = async () => {
71 + if (busy || !sel.length) return;
72 + setBusy(true); setErr("");
73 + try {
74 + const r = await fetch("/api/stats/report/custom", {
75 + method: "POST", headers: { "Content-Type": "application/json" },
76 + body: JSON.stringify({ title, blocks: sel }),
77 + });
78 + if (!r.ok) throw new Error(String(r.status));
79 + const blob = await r.blob();
80 + const m = (r.headers.get("Content-Disposition") ?? "").match(/filename="?([^";]+)/);
81 + const a = document.createElement("a");
82 + a.href = URL.createObjectURL(blob);
83 + a.download = m ? m[1] : "valoplex-rapport-personnalise.pdf";
84 + document.body.appendChild(a); a.click(); a.remove();
85 + setTimeout(() => URL.revokeObjectURL(a.href), 4000);
86 + } catch {
87 + setErr(fr ? "La génération a échoué — réessayez." : "Generation failed — try again.");
88 + }
89 + setBusy(false);
90 + };
91 +
92 + const groups: [string, CatalogBlock[]][] = [];
93 + for (const b of cat ?? []) {
94 + const g = groups.find(([s]) => s === b.section);
95 + if (g) g[1].push(b); else groups.push([b.section, [b]]);
96 + }
97 + const selKeys = new Set(sel.map((s) => s.key));
98 +
99 + return (
100 + <>
101 + <button type="button" className="btn btn-ghost mt-5 ml-0 sm:ml-3" onClick={() => setOpen(true)}>
102 + 🛠 {fr ? "Rapport personnalisé" : "Custom report"}
103 + </button>
104 + {open && (
105 + <div role="dialog" aria-modal="true"
106 + onClick={(e) => { if (e.target === e.currentTarget) setOpen(false); }}
107 + className="fixed inset-0 z-[900] flex items-start justify-center overflow-auto bg-[rgba(20,24,20,0.5)] px-3 py-[4vh]">
108 + <div className="flex max-h-[92vh] w-full max-w-[960px] flex-col rounded-[10px] border-[1.5px] border-ink bg-paper text-left shadow-[10px_10px_0_rgba(20,24,20,0.25)]">
109 + <div className="flex items-center justify-between gap-3 border-b border-ink/20 px-5 py-4">
110 + <b className="vp-display text-lg">{fr ? "Rapport personnalisé" : "Custom report"}</b>
111 + <button type="button" className="btn btn-ghost" onClick={() => setOpen(false)}>✕ {fr ? "Fermer" : "Close"}</button>
112 + </div>
113 + <div className="grid flex-1 grid-cols-1 overflow-auto md:grid-cols-2">
114 + <div className="min-w-0 border-b border-ink/15 p-5 md:border-b-0 md:border-r">
115 + <h3 className="vp-mono mb-2 text-[10.5px] uppercase tracking-[0.08em] text-ink-3">
116 + {fr ? "Blocs disponibles" : "Available blocks"} ({cat?.length ?? "…"})
117 + </h3>
118 + {!cat && !err && <p className="text-sm text-ink-3">{fr ? "Chargement du catalogue…" : "Loading catalog…"}</p>}
119 + {groups.map(([secId, bs]) => (
120 + <div key={secId}>
121 + <p className="vp-mono mb-1.5 mt-3 text-[10px] font-bold uppercase tracking-[0.08em] text-ink-2">{SL[secId] ?? secId}</p>
122 + {bs.map((b) => (
123 + <div key={b.key}
124 + className={`mb-1.5 flex items-center justify-between gap-2 rounded-lg border border-ink/25 px-2.5 py-1.5 text-[13px] ${selKeys.has(b.key) ? "opacity-45" : ""}`}>
125 + <span className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap" title={b.title}>{b.title}</span>
126 + <button type="button" className="btn btn-ghost !min-h-0 !px-2.5 !py-1"
127 + onClick={() => setSel((s) => s.some((x) => x.key === b.key && x.render === b.default_render) ? s : [...s, { key: b.key, render: b.default_render }])}
128 + aria-label={`+ ${b.title}`}>+</button>
129 + </div>
130 + ))}
131 + </div>
132 + ))}
133 + </div>
134 + <div className="min-w-0 p-5">
135 + <h3 className="vp-mono mb-2 text-[10.5px] uppercase tracking-[0.08em] text-ink-3">
136 + {fr ? "Composition du rapport" : "Report composition"} ({sel.length})
137 + </h3>
138 + <label className="vp-mono text-[10px] uppercase tracking-[0.06em] text-ink-3" htmlFor="rb-title">
139 + {fr ? "Titre du rapport" : "Report title"}
140 + </label>
141 + <input id="rb-title" maxLength={80} value={title} onChange={(e) => setTitle(e.target.value)}
142 + placeholder={fr ? "Ex. : Revue du parc 2026" : "E.g.: 2026 portfolio review"}
143 + className="mb-3 mt-1 w-full rounded-lg border-[1.5px] border-ink bg-white px-3 py-2 text-sm" />
144 + {sel.length ? sel.map((s, i) => {
145 + const b = (cat ?? []).find((x) => x.key === s.key);
146 + return (
147 + <div key={`${s.key}:${s.render}:${i}`}
148 + className="mb-1.5 flex items-center gap-2 rounded-lg border-[1.5px] border-ink bg-white px-2.5 py-2 text-[13px]">
149 + <button type="button" disabled={i === 0} className="disabled:opacity-25"
150 + onClick={() => setSel((xs) => { const n = [...xs]; [n[i - 1], n[i]] = [n[i], n[i - 1]]; return n; })}
151 + aria-label={fr ? "Monter" : "Move up"}>▲</button>
152 + <button type="button" disabled={i === sel.length - 1} className="disabled:opacity-25"
153 + onClick={() => setSel((xs) => { const n = [...xs]; [n[i + 1], n[i]] = [n[i], n[i + 1]]; return n; })}
154 + aria-label={fr ? "Descendre" : "Move down"}>▼</button>
155 + <span className="min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap" title={b?.title ?? s.key}>
156 + <b>{i + 1}.</b> {b?.title ?? s.key}
157 + </span>
158 + {b && b.renders.length > 1 ? (
159 + <select value={s.render}
160 + onChange={(e) => setSel((xs) => xs.map((x, j) => (j === i ? { ...x, render: e.target.value } : x)))}
161 + className="max-w-[140px] rounded-md border border-ink/40 bg-paper px-1.5 py-1 text-xs"
162 + aria-label={fr ? "Rendu" : "Render"}>
163 + {b.renders.map((r) => <option key={r} value={r}>{RL[r] ?? r}</option>)}
164 + </select>
165 + ) : <span className="vp-mono text-[10px] uppercase text-ink-3">{RL[s.render] ?? s.render}</span>}
166 + <button type="button" onClick={() => setSel((xs) => xs.filter((_, j) => j !== i))}
167 + aria-label={fr ? "Retirer" : "Remove"}>✕</button>
168 + </div>
169 + );
170 + }) : (
171 + <div className="rounded-lg border border-dashed border-ink/40 p-4 text-center text-[13px] text-ink-3">
172 + {fr ? "Aucun bloc — ajoutez des blocs depuis la colonne de gauche, ou chargez un modèle ci-dessous."
173 + : "No blocks yet — add blocks from the left column, or load a template below."}
174 + </div>
175 + )}
176 + <p className="mt-2.5 flex gap-2">
177 + <button type="button" className="btn btn-ghost" disabled={!cat?.length}
178 + onClick={() => setSel((cat ?? []).map((b) => ({ key: b.key, render: b.default_render })))}>
179 + {fr ? "Tout ajouter" : "Add all"}
180 + </button>
181 + <button type="button" className="btn btn-ghost" disabled={!sel.length} onClick={() => setSel([])}>
182 + {fr ? "Vider" : "Clear"}
183 + </button>
184 + </p>
185 + {err && <p className="mt-1.5 text-[12.5px] text-[#b3423a]">{err}</p>}
186 + </div>
187 + </div>
188 + <div className="flex flex-wrap items-center justify-between gap-3 border-t border-ink/20 px-5 py-4">
189 + <span className="flex flex-wrap items-center gap-2">
190 + <select value="" aria-label={fr ? "Modèles sauvegardés" : "Saved templates"}
191 + className="max-w-[200px] rounded-lg border-[1.5px] border-ink bg-white px-2 py-1.5 text-sm"
192 + onChange={(e) => {
193 + const t = tpls[Number(e.target.value)];
194 + if (!t) return;
195 + setTitle(t.title || t.name);
196 + setSel((t.blocks ?? []).filter((x) => (cat ?? []).some((b) => b.key === x.key)).map((x) => ({ ...x })));
197 + }}>
198 + <option value="">{fr ? "Modèles" : "Templates"} ({tpls.length})…</option>
199 + {tpls.map((t, i) => <option key={t.name} value={i}>{t.name}</option>)}
200 + </select>
201 + <button type="button" className="btn btn-ghost" disabled={!sel.length}
202 + onClick={() => {
203 + const name = window.prompt(fr ? "Nom du modèle :" : "Template name:", title || (fr ? "Mon rapport" : "My report"));
204 + if (!name) return;
205 + const next = [...tpls.filter((t) => t.name !== name), { name, title, blocks: sel.map((x) => ({ ...x })) }];
206 + setTpls(next); saveTpls(next);
207 + }}>💾 {fr ? "Sauvegarder" : "Save"}</button>
208 + <button type="button" className="btn btn-ghost" disabled={!tpls.length}
209 + onClick={() => {
210 + const name = window.prompt((fr ? "Nom du modèle à supprimer :\n" : "Template to delete:\n") + tpls.map((t) => `· ${t.name}`).join("\n"));
211 + if (!name) return;
212 + const next = tpls.filter((t) => t.name !== name);
213 + setTpls(next); saveTpls(next);
214 + }}>🗑 {fr ? "Supprimer" : "Delete"}</button>
215 + </span>
216 + <button type="button" className="btn btn-primary" disabled={!sel.length || busy} onClick={generate}>
217 + {busy ? (fr ? "Génération…" : "Generating…") : `⬇ ${fr ? "Générer le PDF" : "Generate PDF"}`}
218 + </button>
219 + </div>
220 + </div>
221 + </div>
222 + )}
223 + </>
224 + );
225 +}
modified app/src/components/StatsView.tsx +2 −0
@@ -3,6 +3,7 @@
3 3 import { useState } from "react";
4 4 import { CountUp } from "./MetricViz";
5 5 import { useLang } from "./LangContext";
6 +import ReportBuilder from "./ReportBuilder";
6 7
7 8 export interface ProvStats {
8 9 generated: string;
@@ -115,6 +116,7 @@ export default function StatsView({ s }: { s: ProvStats }) {
115 116 : "Download the provincial statistics report (PDF)"}{" "}
116 117 ↓
117 118 </a>
119 + <ReportBuilder />
118 120 </section>
119 121
120 122 {/* ---- tuiles physiques ---- */}
added app/src/lib/report-custom.ts +482 −0
@@ -0,0 +1,482 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * v3 — rapports personnalisés ValoPlex (ka-stats SPEC §3bis).
4 + * Catalogue de blocs dérivé des données provinciales (stats.json) +
5 + * génération d'un PDF composé bloc par bloc (rendu au choix : courbe/aire/
6 + * barres/anneau/tableau…), au gabarit ValoPlex (LETTER, encre/papier/orange).
7 + * Le rapport v1 (`report-stats.ts`, /api/report/stats) reste inchangé.
8 + */
9 +import PDFDocument from "pdfkit";
10 +import path from "path";
11 +import type { ProvStats } from "@/components/StatsView";
12 +
13 +const INK = "#141814";
14 +const INK2 = "#4d5551";
15 +const INK3 = "#8b928c";
16 +const PAPER = "#f5f3ee";
17 +const SURFACE2 = "#faf9f5";
18 +const ACCENT = "#ff9f45"; // orange ValoPlex
19 +const ACCENT_DEEP = "#b25f16";
20 +const WHITE = "#ffffff";
21 +
22 +const W = 612; // LETTER
23 +const H = 792;
24 +const M = 44;
25 +const CW = W - 2 * M;
26 +const TOP = 40;
27 +const BOT = H - 56;
28 +
29 +const F = (f: string) => path.join(process.cwd(), "assets", "fonts", f);
30 +const money = (v: number) =>
31 + new Intl.NumberFormat("fr-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(v);
32 +const compact = (v: number) => {
33 + if (v >= 1e12) return `${(v / 1e12).toLocaleString("fr-CA", { maximumFractionDigits: 2 })} billions $`;
34 + if (v >= 1e9) return `${(v / 1e9).toLocaleString("fr-CA", { maximumFractionDigits: 1 })} G$`;
35 + if (v >= 1e6) return `${(v / 1e6).toLocaleString("fr-CA", { maximumFractionDigits: 1 })} M$`;
36 + return money(v);
37 +};
38 +const num = (v: number) => v.toLocaleString("fr-CA");
39 +const short = (v: number) => {
40 + if (Math.abs(v) >= 1e12) return `${(v / 1e12).toLocaleString("fr-CA", { maximumFractionDigits: 2 })} B`;
41 + if (Math.abs(v) >= 1e9) return `${(v / 1e9).toLocaleString("fr-CA", { maximumFractionDigits: 1 })} G`;
42 + if (Math.abs(v) >= 1e6) return `${(v / 1e6).toLocaleString("fr-CA", { maximumFractionDigits: 1 })} M`;
43 + if (Math.abs(v) >= 1e4) return `${(v / 1e3).toLocaleString("fr-CA", { maximumFractionDigits: 0 })} k`;
44 + return v.toLocaleString("fr-CA", { maximumFractionDigits: 1 });
45 +};
46 +const TYPE_FR: Record<string, string> = {
47 + unifamilial: "Unifamiliale", plex: "Plex (2-5 log.)", condo_ou_multi: "Condo / multi",
48 + chalet: "Chalet", maison_mobile: "Maison mobile", terrain: "Terrain", autre: "Autre",
49 +};
50 +
51 +type Doc = InstanceType<typeof PDFDocument>;
52 +type Row = (string | number)[];
53 +type Point = { t: string; v: number };
54 +type Item = { label: string; value: number };
55 +
56 +/* ------------------------- dashboard dérivé de stats.json ------------------------- */
57 +export type CatalogBlock = {
58 + key: string; section: string; title: string;
59 + renders: string[]; default_render: string; count?: number;
60 +};
61 +export type CustomBlock = { key: string; render?: string };
62 +export type CustomSpec = { title?: string; blocks?: CustomBlock[] };
63 +
64 +type VDash = {
65 + kpis: { label: string; value: string }[];
66 + series: { id: string; title: string; unit: string; points: Point[]; fmt: (v: number) => string }[];
67 + breakdowns: { id: string; title: string; items: Item[]; fmt: (v: number) => string }[];
68 + geo: { title: string; items: Item[] };
69 + tables: { id: string; title: string; columns: string[]; rows: Row[] }[];
70 + records: { label: string; value: string }[];
71 +};
72 +
73 +export function buildVDash(s: ProvStats): VDash {
74 + const kpis = [
75 + { label: "Valeur totale du parc (2026)", value: compact(s.valeur_totale_2026) },
76 + { label: "Propriétés estimées", value: num(s.unites) },
77 + { label: "Municipalités couvertes", value: num(s.municipalites) },
78 + { label: "Valeur médiane provinciale", value: money(s.valeur_mediane_2026) },
79 + { label: "Croissance 2021 → 2026 (périmètre constant)", value: `+${s.croissance_2021_2026_pct.toLocaleString("fr-CA")} %` },
80 + { label: "Logements", value: num(s.logements) },
81 + { label: "Plancher bâti", value: `${num(s.aire_etages_km2)} km²` },
82 + { label: "Superficie de terrain", value: `${num(s.terrain_km2)} km²` },
83 + { label: "Évaluation municipale totale (rôle)", value: compact(s.valeur_role_totale) },
84 + ];
85 + const series: VDash["series"] = [
86 + { id: "millesimes", title: "Valeur provinciale par millésime", unit: "$",
87 + points: s.totaux_annee.map((x) => ({ t: String(x.year), v: x.total })), fmt: compact },
88 + { id: "unites_millesime", title: "Unités estimées par millésime", unit: "unités",
89 + points: s.totaux_annee.map((x) => ({ t: String(x.year), v: x.n })), fmt: num },
90 + ];
91 + const breakdowns: VDash["breakdowns"] = [
92 + { id: "types_valeur", title: "Valeur totale par type de propriété",
93 + items: s.par_type.map((t) => ({ label: TYPE_FR[t.type] ?? t.type, value: t.total })), fmt: compact },
94 + { id: "types_n", title: "Propriétés par type",
95 + items: s.par_type.map((t) => ({ label: TYPE_FR[t.type] ?? t.type, value: t.n })), fmt: num },
96 + ];
97 + const geo = {
98 + title: "Top municipalités par valeur totale",
99 + items: s.par_ville.slice(0, 15).map((v) => ({ label: v.ville, value: v.total })),
100 + };
101 + const tables: VDash["tables"] = [
102 + { id: "villes", title: `Palmarès des municipalités (${s.par_ville.length})`,
103 + columns: ["#", "Municipalité", "Propriétés", "Valeur totale", "Valeur médiane"],
104 + rows: s.par_ville.map((v, i) => [i + 1, v.ville, num(v.n), compact(v.total), v.mediane ? money(v.mediane) : "—"]) },
105 + { id: "types", title: "Détail par type de propriété",
106 + columns: ["Type", "Propriétés", "Valeur totale", "Valeur médiane"],
107 + rows: s.par_type.map((t) => [TYPE_FR[t.type] ?? t.type, num(t.n), compact(t.total), t.mediane ? money(t.mediane) : "—"]) },
108 + ];
109 + const top = s.par_ville[0];
110 + const topType = [...s.par_type].sort((a, b) => b.total - a.total)[0];
111 + const years = s.totaux_annee;
112 + let bestYoY: { y: number; pct: number } | null = null;
113 + for (let i = 1; i < years.length; i++) {
114 + const pct = (years[i].total / years[i - 1].total - 1) * 100;
115 + if (!bestYoY || pct > bestYoY.pct) bestYoY = { y: years[i].year, pct };
116 + }
117 + const records = [
118 + ...(top ? [{ label: "Municipalité la plus valorisée", value: `${top.ville} — ${compact(top.total)}` }] : []),
119 + ...(topType ? [{ label: "Type dominant (valeur)", value: `${TYPE_FR[topType.type] ?? topType.type} — ${compact(topType.total)}` }] : []),
120 + ...(bestYoY ? [{ label: "Plus forte croissance annuelle", value: `${bestYoY.y} — +${bestYoY.pct.toFixed(1)} %` }] : []),
121 + { label: "Valeur moyenne par propriété", value: money(s.valeur_totale_2026 / s.unites) },
122 + { label: "Écart estimation vs rôle", value: `+${(((s.valeur_totale_2026 / s.valeur_role_totale) - 1) * 100).toFixed(1)} %` },
123 + ];
124 + return { kpis, series, breakdowns, geo, tables, records };
125 +}
126 +
127 +export function catalogFromStats(s: ProvStats): CatalogBlock[] {
128 + const d = buildVDash(s);
129 + const out: CatalogBlock[] = [];
130 + const add = (key: string, title: string, renders: string[], def?: string, count?: number) =>
131 + out.push({ key, section: key.split(":")[0], title, renders,
132 + default_render: def ?? renders[0],
133 + ...(count !== undefined ? { count } : {}) });
134 + add("kpis", "Indicateurs clés (KPI)", ["cards", "table"], undefined, d.kpis.length);
135 + for (const se of d.series) add(`series:${se.id}`, se.title, ["line", "area", "bar", "table"], "bar", se.points.length);
136 + for (const b of d.breakdowns) add(`breakdowns:${b.id}`, b.title, ["donut", "bars", "table"], "bars", b.items.length);
137 + add("geo", d.geo.title, ["bars", "table"], undefined, d.geo.items.length);
138 + for (const t of d.tables) add(`tables:${t.id}`, t.title, ["table"], undefined, t.rows.length);
139 + add("records", "Records & faits marquants", ["cards", "table"], undefined, d.records.length);
140 + return out;
141 +}
142 +
143 +/* --------------------------------- primitives PDF --------------------------------- */
144 +class C {
145 + y = TOP + 34;
146 + constructor(public doc: Doc, public generated: string) {}
147 + page() { return this.doc.bufferedPageRange().count; }
148 + chrome() {
149 + const { doc } = this;
150 + doc.rect(0, 0, W, H).fill(PAPER);
151 + doc.rect(0, 0, W, 8).fill(INK);
152 + doc.rect(0, 8, W, 2.5).fill(ACCENT);
153 + doc.rect(M, H - 46, CW, 1.2).fill(INK);
154 + doc.font("JB-Reg").fontSize(6.5).fillColor(INK3).text(
155 + "WWW.VALOPLEX.COM — RAPPORT PERSONNALISÉ · ESTIMATIONS STATISTIQUES (MODÈLE HÉDONIQUE) · SIMON-PIERRE BOUCHER · CONTACT@SPBOUCHER.AI",
156 + M, H - 38, { width: CW - 60, characterSpacing: 0.4, lineBreak: false });
157 + doc.font("JB-Reg").fontSize(6.5).fillColor(INK3)
158 + .text(this.generated, W - M - 160, H - 30, { width: 160, align: "right" });
159 + }
160 + ensure(h: number) {
161 + if (this.y + h > BOT) {
162 + this.doc.addPage({ size: "LETTER", margin: 0 });
163 + this.chrome();
164 + this.y = TOP;
165 + }
166 + }
167 + kicker(txt: string) {
168 + this.ensure(26);
169 + const { doc } = this;
170 + doc.rect(M, this.y + 3.5, 20, 2).fill(ACCENT_DEEP);
171 + doc.font("JB-Bold").fontSize(8).fillColor(ACCENT_DEEP)
172 + .text(txt.toUpperCase(), M + 26, this.y, { characterSpacing: 1.4, width: CW - 26, height: 10, ellipsis: true, lineBreak: false });
173 + this.y += 20;
174 + }
175 + card(h: number, fill = WHITE) {
176 + const { doc } = this;
177 + doc.roundedRect(M + 3, this.y + 3, CW, h, 8).fill("#e3e1d9");
178 + doc.roundedRect(M, this.y, CW, h, 8).lineWidth(1.3).fillAndStroke(fill, INK);
179 + }
180 +}
181 +
182 +function axesGrid(c: C, a: { x: number; y: number; w: number; h: number }, vmin: number, vmax: number) {
183 + const { doc } = c;
184 + for (let g = 0; g <= 4; g++) {
185 + const gy = a.y + (a.h * g) / 4;
186 + doc.rect(a.x, gy, a.w, 0.5).fill("#e3e1d9");
187 + doc.font("JB-Reg").fontSize(5.8).fillColor(INK3)
188 + .text(short(vmax - ((vmax - vmin) * g) / 4), a.x - 40, gy - 3, { width: 36, align: "right", lineBreak: false });
189 + }
190 +}
191 +
192 +function chartBlock(c: C, title: string, h: number): { x: number; y: number; w: number; h: number } {
193 + c.ensure(h + 46);
194 + c.kicker(title);
195 + c.card(h + 18);
196 + const area = { x: M + 56, y: c.y + 10, w: CW - 56 - 22, h: h - 6 };
197 + c.y += h + 18 + 14;
198 + return area;
199 +}
200 +
201 +function linePdf(c: C, title: string, pts: Point[], kind: "line" | "area" | "bar", fmt: (v: number) => string) {
202 + if (pts.length < 2 && kind !== "bar") kind = "bar";
203 + const a = chartBlock(c, title, 120);
204 + const { doc } = c;
205 + const vmax = Math.max(...pts.map((p) => p.v), 1);
206 + const vmin = Math.min(0, ...pts.map((p) => p.v));
207 + const rng = vmax - vmin || 1;
208 + axesGrid(c, a, vmin, vmax);
209 + const X = (i: number) => a.x + (a.w * i) / Math.max(pts.length - 1, 1);
210 + const Y = (v: number) => a.y + a.h * (1 - (v - vmin) / rng);
211 + if (kind === "bar") {
212 + const bw = Math.max(3, a.w / pts.length - 3);
213 + pts.forEach((p, i) => {
214 + const bh = (a.h * (p.v - vmin)) / rng;
215 + doc.rect(a.x + (a.w * i) / pts.length + 1.5, a.y + a.h - bh, bw, Math.max(bh, 1))
216 + .lineWidth(0.6).fillAndStroke(ACCENT, INK);
217 + });
218 + } else {
219 + if (kind === "area") {
220 + doc.moveTo(X(0), Y(pts[0].v));
221 + pts.forEach((p, i) => doc.lineTo(X(i), Y(p.v)));
222 + doc.lineTo(X(pts.length - 1), a.y + a.h).lineTo(X(0), a.y + a.h).closePath()
223 + .fillOpacity(0.18).fill(ACCENT).fillOpacity(1);
224 + }
225 + doc.moveTo(X(0), Y(pts[0].v));
226 + pts.forEach((p, i) => doc.lineTo(X(i), Y(p.v)));
227 + doc.lineWidth(2).stroke(ACCENT_DEEP);
228 + }
229 + [0, Math.floor(pts.length / 2), pts.length - 1]
230 + .filter((v, i, arr) => arr.indexOf(v) === i)
231 + .forEach((i) => {
232 + doc.font("JB-Reg").fontSize(6).fillColor(INK3)
233 + .text(pts[i].t.slice(0, 10), X(i) - 22, a.y + a.h + 4, { width: 44, align: "center", lineBreak: false });
234 + });
235 + // min/max/moyenne sous le graphique
236 + const vs = pts.map((p) => p.v);
237 + const mean = vs.reduce((x, y) => x + y, 0) / vs.length;
238 + doc.font("JB-Reg").fontSize(6).fillColor(INK3).text(
239 + `MIN ${fmt(Math.min(...vs))} · MAX ${fmt(Math.max(...vs))} · MOYENNE ${fmt(mean)}`,
240 + M + 14, c.y - 10, { characterSpacing: 0.4, lineBreak: false });
241 +}
242 +
243 +function hbarsPdf(c: C, title: string, items: Item[], fmt: (v: number) => string) {
244 + const rows = items.slice(0, 15);
245 + const h = rows.length * 19 + 16;
246 + c.ensure(h + 40);
247 + c.kicker(title);
248 + c.card(h);
249 + const { doc } = c;
250 + const max = Math.max(...rows.map((r) => r.value), 1);
251 + rows.forEach((r, i) => {
252 + const y = c.y + 10 + i * 19;
253 + doc.font("JB-Bold").fontSize(7.5).fillColor(INK)
254 + .text(r.label, M + 14, y + 3, { width: 120, height: 9, ellipsis: true, lineBreak: false });
255 + const tx = M + 140, tw = CW - 140 - 118;
256 + doc.rect(tx, y, tw, 13).fill(SURFACE2);
257 + doc.rect(tx, y, Math.max(2, (r.value / max) * tw), 13).fill(ACCENT);
258 + doc.font("JB-Bold").fontSize(7.5).fillColor(INK)
259 + .text(fmt(r.value), M + CW - 112 - 14, y + 3, { width: 112, align: "right", lineBreak: false });
260 + });
261 + c.y += h + 14;
262 +}
263 +
264 +function donutPdf(c: C, title: string, items: Item[], fmt: (v: number) => string) {
265 + const rows = items.filter((i) => i.value > 0).slice(0, 8);
266 + const total = rows.reduce((s, r) => s + r.value, 0);
267 + if (!total) return;
268 + const h = Math.max(120, rows.length * 15 + 20);
269 + c.ensure(h + 40);
270 + c.kicker(title);
271 + c.card(h);
272 + const { doc } = c;
273 + const cx = M + 78, cy = c.y + h / 2, R = Math.min(46, h / 2 - 12);
274 + const shades = [1, 0.78, 0.58, 0.42, 0.3, 0.22, 0.15, 0.1];
275 + let start = -Math.PI / 2;
276 + rows.forEach((r, i) => {
277 + const frac = r.value / total;
278 + const steps = Math.max(2, Math.ceil(64 * frac));
279 + doc.moveTo(cx, cy);
280 + for (let st = 0; st <= steps; st++) {
281 + const ang = start + 2 * Math.PI * frac * (st / steps);
282 + doc.lineTo(cx + R * Math.cos(ang), cy + R * Math.sin(ang));
283 + }
284 + doc.closePath().fillOpacity(shades[i % shades.length]).fill(ACCENT).fillOpacity(1);
285 + start += 2 * Math.PI * frac;
286 + });
287 + doc.circle(cx, cy, R * 0.55).lineWidth(1).fillAndStroke(WHITE, INK);
288 + doc.circle(cx, cy, R).lineWidth(1).stroke(INK);
289 + let ly = c.y + (h - rows.length * 15) / 2 + 2;
290 + rows.forEach((r, i) => {
291 + doc.rect(M + 150, ly + 2, 8, 8).lineWidth(0.7)
292 + .fillOpacity(shades[i % shades.length]).fillAndStroke(ACCENT, INK);
293 + doc.fillOpacity(1).font("JB-Reg").fontSize(7.2).fillColor(INK).text(
294 + `${r.label} — ${fmt(r.value)} (${((100 * r.value) / total).toFixed(1)} %)`,
295 + M + 164, ly + 2.5, { width: CW - 164 - 20, height: 10, ellipsis: true, lineBreak: false });
296 + ly += 15;
297 + });
298 + c.y += h + 14;
299 +}
300 +
301 +function tablePdf(c: C, title: string, columns: string[], rows: Row[], maxRows = 400) {
302 + c.kicker(title);
303 + const { doc } = c;
304 + const wcol = CW / columns.length;
305 + const head = () => {
306 + c.ensure(40);
307 + doc.rect(M, c.y, CW, 16).fill(INK);
308 + doc.font("JB-Bold").fontSize(6).fillColor(WHITE);
309 + columns.forEach((col, i) =>
310 + doc.text(String(col).toUpperCase(), M + i * wcol + 8, c.y + 5, { width: wcol - 12, height: 8, ellipsis: true, lineBreak: false }));
311 + c.y += 16;
312 + };
313 + head();
314 + rows.slice(0, maxRows).forEach((row, ri) => {
315 + if (c.y + 15 > BOT) { c.ensure(40); head(); }
316 + if (ri % 2 === 0) doc.rect(M, c.y, CW, 15).fill(SURFACE2);
317 + row.forEach((cell, i) => {
318 + doc.font(i === 0 ? "SG-Bold" : "JB-Reg").fontSize(7).fillColor(INK)
319 + .text(String(cell), M + i * wcol + 8, c.y + 4, { width: wcol - 12, height: 9, ellipsis: true, lineBreak: false });
320 + });
321 + c.y += 15;
322 + });
323 + doc.rect(M, c.y, CW, 0.9).fill(INK);
324 + c.y += 14;
325 + if (rows.length > maxRows) {
326 + doc.font("JB-Reg").fontSize(6.5).fillColor(INK3)
327 + .text(`… ${rows.length - maxRows} lignes supplémentaires non imprimées`, M, c.y - 8);
328 + }
329 +}
330 +
331 +function kpiCards(c: C, kpis: VDash["kpis"]) {
332 + c.kicker("Indicateurs clés");
333 + const { doc } = c;
334 + const cols = 3, gap = 10, cw = (CW - (cols - 1) * gap) / cols, ch = 48;
335 + const rowsN = Math.ceil(kpis.length / cols);
336 + c.ensure(rowsN * (ch + 10));
337 + kpis.forEach((k, i) => {
338 + const tx = M + (i % cols) * (cw + gap);
339 + if (i > 0 && i % cols === 0) c.y += ch + 10;
340 + if (c.y + ch > BOT) { c.ensure(ch + 12); }
341 + doc.roundedRect(tx + 3, c.y + 3, cw, ch, 8).fill("#e3e1d9");
342 + doc.roundedRect(tx, c.y, cw, ch, 8).lineWidth(1.3).fillAndStroke(WHITE, INK);
343 + doc.font("SG-Bold").fontSize(12).fillColor(INK)
344 + .text(k.value, tx + 10, c.y + 10, { width: cw - 20, height: 15, ellipsis: true, lineBreak: false });
345 + doc.font("JB-Bold").fontSize(5.4).fillColor(INK3)
346 + .text(k.label.toUpperCase(), tx + 10, c.y + 30, { width: cw - 20, characterSpacing: 0.4, height: 14 });
347 + });
348 + c.y += 48 + 18;
349 +}
350 +
351 +function recordCards(c: C, records: VDash["records"]) {
352 + c.kicker("Records & faits marquants");
353 + const { doc } = c;
354 + records.forEach((r) => {
355 + c.ensure(26);
356 + doc.roundedRect(M, c.y, CW, 20, 6).lineWidth(1).fillAndStroke(SURFACE2, INK);
357 + doc.font("JB-Reg").fontSize(7.5).fillColor(INK2)
358 + .text(r.label, M + 12, c.y + 6.5, { width: CW * 0.5, height: 9, ellipsis: true, lineBreak: false });
359 + doc.font("SG-Bold").fontSize(8.5).fillColor(INK)
360 + .text(r.value, M + CW * 0.5, c.y + 6, { width: CW * 0.5 - 14, align: "right", height: 10, ellipsis: true, lineBreak: false });
361 + c.y += 24;
362 + });
363 + c.y += 8;
364 +}
365 +
366 +/* ------------------------------- rapport personnalisé ------------------------------- */
367 +export async function buildCustomReport(s: ProvStats, spec: CustomSpec): Promise<Buffer> {
368 + const dash = buildVDash(s);
369 + const cat = new Map(catalogFromStats(s).map((b) => [b.key, b]));
370 + const blocks = (spec.blocks ?? [])
371 + .filter((b): b is CustomBlock => !!b && typeof b === "object" && cat.has(String(b.key)))
372 + .slice(0, 40);
373 + if (!blocks.length) throw new Error("aucun-bloc");
374 + const title = String(spec.title ?? "").slice(0, 80).trim();
375 + const label = title ? `Rapport personnalisé — ${title}` : "Rapport personnalisé";
376 + const generated = new Date().toISOString().slice(0, 16).replace("T", " ");
377 +
378 + const doc = new PDFDocument({
379 + size: "LETTER", margin: 0, bufferPages: true,
380 + info: { Title: `ValoPlex — ${label}` },
381 + });
382 + doc.registerFont("SG-Bold", F("SpaceGrotesk-Bold.ttf"));
383 + doc.registerFont("JB-Reg", F("JetBrainsMono-Regular.ttf"));
384 + doc.registerFont("JB-Bold", F("JetBrainsMono-Bold.ttf"));
385 + doc.registerFont("Inter", F("Inter-Regular.ttf"));
386 + const chunks: Buffer[] = [];
387 + doc.on("data", (b: Buffer) => chunks.push(b));
388 + const done = new Promise<Buffer>((res) => doc.on("end", () => res(Buffer.concat(chunks))));
389 +
390 + /* couverture (bandeau encre + logo ValoPlex, comme le rapport v1) */
391 + doc.rect(0, 0, W, H).fill(PAPER);
392 + doc.rect(0, 0, W, 96).fill(INK);
393 + doc.font("SG-Bold").fontSize(24).fillColor(PAPER).text("Valo", M, 30, { lineBreak: false });
394 + const lw = doc.widthOfString("Valo");
395 + doc.save();
396 + doc.rotate(-3, { origin: [M + lw + 4, 42] });
397 + doc.roundedRect(M + lw + 4, 27, doc.widthOfString("Plex") + 12, 30, 5).fill(ACCENT);
398 + doc.fillColor(INK).text("Plex", M + lw + 10, 30, { lineBreak: false });
399 + doc.restore();
400 + doc.font("JB-Reg").fontSize(7).fillColor(ACCENT)
401 + .text("RAPPORT PERSONNALISÉ · QUÉBEC", M, 66, { characterSpacing: 1.6, lineBreak: false });
402 + doc.font("JB-Reg").fontSize(7).fillColor("#9aa39c")
403 + .text(`MILLÉSIME 2026 · GÉNÉRÉ LE ${generated}`, W - M - 220, 40, { width: 220, align: "right" });
404 + doc.rect(0, 96, W, 3).fill(ACCENT);
405 + let y = 140;
406 + doc.rect(M, y + 3.5, 20, 2).fill(ACCENT_DEEP);
407 + doc.font("JB-Bold").fontSize(8).fillColor(ACCENT_DEEP)
408 + .text("VALOPLEX · RAPPORT STATISTIQUE", M + 26, y, { characterSpacing: 1.4, lineBreak: false });
409 + y += 26;
410 + doc.font("SG-Bold").fontSize(26).fillColor(INK).text(label, M, y, { width: CW });
411 + y += doc.heightOfString(label, { width: CW }) + 46;
412 + const rows: [string, string][] = [
413 + ["Type de rapport", label],
414 + ["Période couverte", "Instantané du rôle d'évaluation 2026 (millésime)"],
415 + ["Généré le", generated],
416 + ["Plateforme", "www.valoplex.com"],
417 + ["Composition", `${blocks.length} bloc${blocks.length > 1 ? "s" : ""}`],
418 + ["Données", `${num(s.unites)} propriétés · ${num(s.municipalites)} municipalités`],
419 + ];
420 + doc.rect(M, y - 14, 34, 3).fill(ACCENT);
421 + rows.forEach(([k, v]) => {
422 + doc.font("JB-Reg").fontSize(8).fillColor(INK3)
423 + .text(k.toUpperCase(), M, y + 2.5, { width: 150, characterSpacing: 0.6, lineBreak: false });
424 + doc.font("SG-Bold").fontSize(11).fillColor(INK)
425 + .text(v, M + 160, y, { width: CW - 160, height: 14, ellipsis: true, lineBreak: false });
426 + y += 24;
427 + });
428 + doc.rect(0, H - 64, W, 64).fill(INK);
429 + doc.font("SG-Bold").fontSize(13).fillColor(WHITE).text("ValoPlex — juste pour le show", M, H - 42, { lineBreak: false });
430 + doc.font("JB-Bold").fontSize(9).fillColor(ACCENT)
431 + .text("www.valoplex.com", W - M - 180, H - 40, { width: 180, align: "right" });
432 +
433 + /* blocs, dans l'ordre demandé */
434 + doc.addPage({ size: "LETTER", margin: 0 });
435 + const c = new C(doc, generated);
436 + c.chrome();
437 + c.y = TOP;
438 + for (const blk of blocks) {
439 + const b = cat.get(String(blk.key))!;
440 + const render = b.renders.includes(String(blk.render ?? "")) ? String(blk.render) : b.default_render;
441 + const [section, id] = [b.section, b.key.split(":").slice(1).join(":")];
442 + if (section === "kpis") {
443 + if (render === "table") tablePdf(c, "Indicateurs clés", ["Indicateur", "Valeur"], dash.kpis.map((k) => [k.label, k.value]));
444 + else kpiCards(c, dash.kpis);
445 + } else if (section === "series") {
446 + const se = dash.series.find((x) => x.id === id);
447 + if (!se) continue;
448 + if (render === "table") tablePdf(c, se.title, ["Millésime", se.unit === "$" ? "Valeur" : "Unités"], se.points.map((p) => [p.t, se.fmt(p.v)]));
449 + else linePdf(c, se.title, se.points, render as "line" | "area" | "bar", se.fmt);
450 + } else if (section === "breakdowns") {
451 + const b2 = dash.breakdowns.find((x) => x.id === id);
452 + if (!b2) continue;
453 + if (render === "table") tablePdf(c, b2.title, ["Type", "Valeur"], b2.items.map((it) => [it.label, b2.fmt(it.value)]));
454 + else if (render === "donut") donutPdf(c, b2.title, b2.items, b2.fmt);
455 + else hbarsPdf(c, b2.title, b2.items, b2.fmt);
456 + } else if (section === "geo") {
457 + if (render === "table") tablePdf(c, dash.geo.title, ["Municipalité", "Valeur totale"], dash.geo.items.map((it) => [it.label, compact(it.value)]));
458 + else hbarsPdf(c, dash.geo.title, dash.geo.items, compact);
459 + } else if (section === "tables") {
460 + const t = dash.tables.find((x) => x.id === id);
461 + if (t) tablePdf(c, t.title, t.columns, t.rows);
462 + } else if (section === "records") {
463 + if (render === "table") tablePdf(c, "Records & faits marquants", ["Fait marquant", "Valeur"], dash.records.map((r) => [r.label, r.value]));
464 + else recordCards(c, dash.records);
465 + }
466 + }
467 +
468 + /* numéros de page (post-passe) */
469 + const total = doc.bufferedPageRange().count;
470 + for (let i = 1; i < total; i++) {
471 + doc.switchToPage(i);
472 + doc.font("JB-Bold").fontSize(7.5).fillColor(INK)
473 + .text(`${i + 1} / ${total}`, W - M - 40, H - 39, { width: 40, align: "right", lineBreak: false });
474 + }
475 + doc.end();
476 + return done;
477 +}
478 +
479 +export function customFilename(): string {
480 + const today = new Date().toLocaleDateString("fr-CA", { timeZone: "America/Toronto" });
481 + return `valoplex_stats_personnalise_${today}.pdf`;
482 +}
483