SPB Git forge

spb/vrai-prix

Public

Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.

60commits 1branches 0releases
12.3 MBsize
maindefault branch
17 days agolast push
TypeScript 90.2% JavaScript 3.5% Python 3.4% CSS 1.9% HTML 0.6%

Upgrade majeur apparence + information : contexte marché, fourchette visuelle, accueil enrichi

- Fiche estimation : barre de fourchette P10-P90 (marqueurs estimation + rôle),
  faits rapides ($/m², type, année, superficies), section « Le marché autour de
  cette propriété » (courbe d'indice base 100 + tendances 12 mois/2021, ventes
  réelles du secteur avec rayon adaptatif, position percentile dans la
  municipalité), comparables enrichis ($/m², superficie, année)
- Moteur : MarketContext (estimator) — stats secteur depuis les candidats déjà
  chargés, percentile municipal (db), aussi servi par /api/estimate (manuel)
- Accueil : pouls du marché (indice par type + variations), dernières ventes
  réelles captées (lien vers l'estimation), bande « comment ça marche »,
  compteur ventes/30 j — page en ISR 1 h
- DB : index idx_tx_date sur transactions(date)
- MetricViz : RangeBar, MarketIndexChart, PercentileBar, IndexSpark

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 23, 2026) parent 4bcf5c2

6 changed files +906 −23

modified src/app/HomeClient.tsx +204 −1
@@ -1,11 +1,74 @@
1 1 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 2 "use client";
3 +import Link from "next/link";
3 4 import SearchBox from "@/components/SearchBox";
4 5 import EstimateForm from "@/components/EstimateForm";
6 +import { IndexSpark, locNum } from "@/components/MetricViz";
5 7 import { useLang } from "@/components/LangContext";
6 8
7 −export default function HomeClient() {
9 +/** Indice de marché d'un grand type — série + variations (calculé côté serveur). */
10 +export interface PulseItem {
11 + type: "unifamilial" | "condo" | "plex";
12 + series: { month: string; idx: number }[];
13 + pct12m: number | null;
14 + since2021: number | null;
15 +}
16 +
17 +/** Vente réelle récente appariée à une unité d'évaluation. */
18 +export interface RecentSaleItem {
19 + id: string;
20 + date: string;
21 + amount: number;
22 + street: string | null;
23 + city: string | null;
24 + propertyType: string | null;
25 + idProvinc: string | null;
26 + vsRolePct: number | null;
27 +}
28 +
29 +const PULSE_LABELS: Record<PulseItem["type"], { fr: string; en: string }> = {
30 + unifamilial: { fr: "Unifamiliale", en: "Single-family" },
31 + condo: { fr: "Condo", en: "Condo" },
32 + plex: { fr: "Plex", en: "Plex" },
33 +};
34 +
35 +function TrendBadge({ pct, label }: { pct: number | null; label: string }) {
36 + if (pct == null) return null;
37 + return (
38 + <span
39 + className={`vp-mono rounded-[4px] border border-ink px-1.5 py-0.5 text-[10px] font-bold ${
40 + pct >= 0 ? "bg-lime text-ink" : "bg-danger text-white"
41 + }`}
42 + >
43 + {label} {pct >= 0 ? "+" : ""}
44 + {pct.toFixed(1)} %
45 + </span>
46 + );
47 +}
48 +
49 +export default function HomeClient({
50 + pulse,
51 + recent,
52 + salesLast30d,
53 +}: {
54 + pulse: PulseItem[];
55 + recent: RecentSaleItem[];
56 + salesLast30d: number;
57 +}) {
8 58 const { lang, t } = useLang();
59 + const fr = lang === "fr";
60 + const money = (v: number) =>
61 + new Intl.NumberFormat(fr ? "fr-CA" : "en-CA", {
62 + style: "currency",
63 + currency: "CAD",
64 + maximumFractionDigits: 0,
65 + }).format(v);
66 + const dateFmt = (d: string) =>
67 + new Date(`${d}T12:00:00`).toLocaleDateString(fr ? "fr-CA" : "en-CA", {
68 + day: "numeric",
69 + month: "short",
70 + });
71 +
9 72 return (
10 73 <div className="pb-6 pt-10 sm:pt-14">
11 74 {/* ---- Héros ---- */}
@@ -38,6 +101,10 @@ export default function HomeClient() {
38 101 <span className="stat-chip">
39 102 <b>745 119</b> {lang === "fr" ? "ventes réelles" : "real sales"}
40 103 </span>
104 + <span className="stat-chip">
105 + <b>{locNum(salesLast30d, lang, { maxFrac: 0 })}</b>{" "}
106 + {lang === "fr" ? "ventes captées / 30 j" : "sales captured / 30 d"}
107 + </span>
41 108 <span className="stat-chip">
42 109 <b>1 100</b> {lang === "fr" ? "municipalités" : "municipalities"}
43 110 </span>
@@ -60,6 +127,142 @@ export default function HomeClient() {
60 127 </p>
61 128 </section>
62 129
130 + {/* ---- Comment ça marche (bande compacte) ---- */}
131 + <section className="mt-10 grid gap-3 sm:grid-cols-3">
132 + {(fr
133 + ? [
134 + ["01", "Cherchez l'adresse", "3,7 M d'unités d'évaluation, recherche tolérante aux fautes."],
135 + ["02", "Le moteur croise deux sources", "Modèle hédonique (690 k ventes) + comparables réels ajustés en dollars."],
136 + ["03", "Fourchette + confiance", "P10-P90, indice A-D, rapport PDF — le calcul montré en entier."],
137 + ]
138 + : [
139 + ["01", "Search the address", "3.7M assessment units, typo-tolerant search."],
140 + ["02", "Two sources, crossed", "Hedonic model (690k sales) + real comparables adjusted in dollars."],
141 + ["03", "Range + confidence", "P10-P90, A-D index, PDF report — the full math, shown."],
142 + ]
143 + ).map(([num, title, body], i) => (
144 + <div key={num} className="flex items-start gap-3 rounded-[10px] border-[1.5px] border-dashed border-[rgba(20,24,20,0.3)] bg-surface-2 p-4">
145 + <span className="vp-mono flex h-8 w-8 shrink-0 items-center justify-center rounded-full border-[1.5px] border-ink bg-ink text-[12px] font-bold text-lime">
146 + {num}
147 + </span>
148 + <div>
149 + <p className="vp-display text-[14.5px] font-bold uppercase tracking-[-0.01em]">
150 + {title}
151 + {i < 2 && <span className="ml-2 text-ink-3">→</span>}
152 + </p>
153 + <p className="mt-1 text-[12.5px] leading-snug text-ink-2">{body}</p>
154 + </div>
155 + </div>
156 + ))}
157 + </section>
158 +
159 + {/* ---- Pouls du marché ---- */}
160 + <section className="mt-14">
161 + <span className="kicker">{fr ? "Indice Vrai-Prix" : "Vrai-Prix index"}</span>
162 + <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">
163 + {fr ? "Le pouls du marché québécois" : "The pulse of the Québec market"}
164 + </h2>
165 + <p className="mt-2 max-w-2xl text-[14px] text-ink-2">
166 + {fr
167 + ? "L'indice mensuel calculé sur les ventes réelles — celui-là même qui ajuste les comparables de chaque estimation. Base 100 il y a 24 mois."
168 + : "The monthly index built from real sales — the same one adjusting every estimate's comparables. Base 100 twenty-four months ago."}
169 + </p>
170 + <div className="mt-4 grid gap-4 sm:grid-cols-3">
171 + {pulse.map((p) => (
172 + <div key={p.type} className="vp-card vp-card-hover p-5">
173 + <div className="flex items-center justify-between gap-2">
174 + <p className="vp-display text-[16px] font-bold uppercase tracking-[-0.01em]">
175 + {PULSE_LABELS[p.type][lang]}
176 + </p>
177 + <TrendBadge pct={p.pct12m} label={fr ? "12 mois" : "12 mo"} />
178 + </div>
179 + <div className="mt-3">
180 + <IndexSpark series={p.series} />
181 + </div>
182 + {p.since2021 != null && (
183 + <p className="vp-mono mt-2 text-[10.5px] uppercase tracking-[0.05em] text-ink-3">
184 + {fr ? "Depuis 2021" : "Since 2021"} :{" "}
185 + <b className="text-ink">
186 + {p.since2021 >= 0 ? "+" : ""}
187 + {p.since2021.toFixed(0)} %
188 + </b>
189 + </p>
190 + )}
191 + </div>
192 + ))}
193 + </div>
194 + </section>
195 +
196 + {/* ---- Dernières ventes captées ---- */}
197 + {recent.length > 0 && (
198 + <section className="mt-14">
199 + <span className="kicker">
200 + <span className="pulse" style={{ width: 8, height: 8 }} />
201 + {fr ? "Flux en continu" : "Live feed"}
202 + </span>
203 + <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">
204 + {fr ? "Dernières ventes réelles captées" : "Latest real sales captured"}
205 + </h2>
206 + <p className="mt-2 max-w-2xl text-[14px] text-ink-2">
207 + {fr
208 + ? "Transactions publiées, appariées automatiquement à leur unité d'évaluation. Cliquez pour voir l'estimation complète de la propriété vendue."
209 + : "Published transactions, automatically matched to their assessment unit. Click for the sold property's full estimate."}
210 + </p>
211 + <div className="src-wrap mt-4">
212 + <table className="src-table">
213 + <thead>
214 + <tr>
215 + <th>{fr ? "Date" : "Date"}</th>
216 + <th>{fr ? "Propriété" : "Property"}</th>
217 + <th className="text-right">{fr ? "Prix de vente" : "Sale price"}</th>
218 + <th className="text-right"></th>
219 + </tr>
220 + </thead>
221 + <tbody>
222 + {recent.map((s) => (
223 + <tr key={s.id}>
224 + <td className="vp-mono whitespace-nowrap text-[12px] font-bold uppercase">
225 + {dateFmt(s.date)}
226 + </td>
227 + <td>
228 + <p className="font-semibold leading-tight">
229 + {s.street}
230 + {s.city ? `, ${s.city}` : ""}
231 + </p>
232 + {s.propertyType && s.propertyType !== "indéterminé" && (
233 + <p className="vp-mono mt-0.5 text-[10.5px] uppercase tracking-[0.04em] text-ink-3">
234 + {s.propertyType}
235 + </p>
236 + )}
237 + </td>
238 + <td className="text-right">
239 + <span className="vp-display font-bold">{money(s.amount)}</span>
240 + {s.vsRolePct != null && Math.abs(s.vsRolePct) < 400 && (
241 + <p className="vp-mono mt-0.5 text-[10px] text-ink-3">
242 + {s.vsRolePct >= 0 ? "+" : "−"}
243 + {Math.abs(s.vsRolePct).toFixed(0)} %{" "}
244 + {fr ? "vs rôle" : "vs roll"}
245 + </p>
246 + )}
247 + </td>
248 + <td className="text-right">
249 + {s.idProvinc && (
250 + <Link
251 + href={`/estimation/${encodeURIComponent(s.idProvinc)}`}
252 + className="vp-mono whitespace-nowrap rounded-[5px] border-[1.5px] border-ink bg-surface-2 px-2.5 py-1.5 text-[11px] font-bold uppercase tracking-[0.04em] transition-colors hover:bg-lime"
253 + >
254 + {fr ? "Estimer" : "Estimate"} →
255 + </Link>
256 + )}
257 + </td>
258 + </tr>
259 + ))}
260 + </tbody>
261 + </table>
262 + </div>
263 + </section>
264 + )}
265 +
63 266 {/* ---- Formulaire manuel ---- */}
64 267 <section className="mt-14">
65 268 <span className="kicker">{lang === "fr" ? "Plan B" : "Plan B"}</span>
modified src/app/page.tsx +54 −2
@@ -1,10 +1,15 @@
1 1 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 2 import type { Metadata } from "next";
3 3 import Link from "next/link";
4 −import HomeClient from "./HomeClient";
4 +import HomeClient, { type PulseItem, type RecentSaleItem } from "./HomeClient";
5 5 import stats from "@/data/stats.json";
6 +import { getMarketIndex, recentSales, salesCountSince } from "@/lib/db";
6 7 import { moneyFr, slugify } from "@/lib/seo";
7 8
9 +// Le pouls du marché et les dernières ventes bougent au rythme du connecteur
10 +// (quotidien) — régénération ISR horaire, le reste de la page est stable.
11 +export const revalidate = 3600;
12 +
8 13 export const metadata: Metadata = {
9 14 title: {
10 15 absolute: "Vrai-Prix — estimation immobilière gratuite au Québec · Un service Groupe KA",
@@ -31,10 +36,57 @@ const TOP_VILLES = (
31 36 stats as { par_ville: { ville: string; n: number; mediane: number | null }[] }
32 37 ).par_ville.slice(0, 24);
33 38
39 +/** Séries d'indice par grand type — 25 derniers mois, avec variations. */
40 +function buildPulse(): PulseItem[] {
41 + return (["unifamilial", "condo", "plex"] as const).map((type) => {
42 + const full = getMarketIndex(type);
43 + const series = full.slice(-25);
44 + const last = full.length ? full[full.length - 1].idx : null;
45 + const yearAgo = full.length >= 13 ? full[full.length - 13].idx : null;
46 + const first = full.length >= 2 ? full[0].idx : null;
47 + return {
48 + type,
49 + series,
50 + pct12m:
51 + last != null && yearAgo != null && yearAgo > 0
52 + ? (last / yearAgo - 1) * 100
53 + : null,
54 + since2021:
55 + last != null && first != null && first > 0 ? (last / first - 1) * 100 : null,
56 + };
57 + });
58 +}
59 +
60 +function buildRecent(): RecentSaleItem[] {
61 + return recentSales(6).map((s) => ({
62 + id: s.id,
63 + date: s.date,
64 + amount: s.amount,
65 + street: s.street,
66 + city: s.city,
67 + propertyType: s.property_type,
68 + idProvinc: s.id_provinc,
69 + vsRolePct:
70 + s.valeur_role != null && s.valeur_role > 0
71 + ? (s.amount / s.valeur_role - 1) * 100
72 + : null,
73 + }));
74 +}
75 +
76 +function iso(d: Date): string {
77 + return d.toISOString().slice(0, 10);
78 +}
79 +
34 80 export default function Home() {
81 + const monthAgo = new Date();
82 + monthAgo.setDate(monthAgo.getDate() - 30);
35 83 return (
36 84 <>
37 − <HomeClient />
85 + <HomeClient
86 + pulse={buildPulse()}
87 + recent={buildRecent()}
88 + salesLast30d={salesCountSince(iso(monthAgo))}
89 + />
38 90 {/* ---- Explorer par municipalité (maillage interne indexable) ---- */}
39 91 <section className="mt-14">
40 92 <span className="kicker">Explorer par municipalité</span>
modified src/components/MetricViz.tsx +313 −0
@@ -433,6 +433,319 @@ export function Sparkline({
433 433 );
434 434 }
435 435
436 +/* ------------------------- fourchette visuelle P10-P90 ------------------------- */
437 +/** Barre de fourchette : segment P10→P90, losange = estimation, tiret = rôle. */
438 +export function RangeBar({
439 + low,
440 + estimate,
441 + high,
442 + role,
443 + lang,
444 +}: {
445 + low: number;
446 + estimate: number;
447 + high: number;
448 + role: number | null;
449 + lang: string;
450 +}) {
451 + const [on, setOn] = useState(false);
452 + useEffect(() => {
453 + const id = setTimeout(() => setOn(true), 80);
454 + return () => clearTimeout(id);
455 + }, []);
456 + if (!(high > low) || !Number.isFinite(estimate)) return null;
457 + const lo = Math.min(low, role ?? low);
458 + const hi = Math.max(high, role ?? high);
459 + const pad = (hi - lo) * 0.09 || hi * 0.05;
460 + const min = lo - pad;
461 + const max = hi + pad;
462 + const X = (v: number) => Math.max(0, Math.min(100, ((v - min) / (max - min)) * 100));
463 + // les étiquettes (centrées, nowrap) restent dans la carte même aux extrêmes
464 + const clampX = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, X(v)));
465 + const fmt = (v: number) =>
466 + new Intl.NumberFormat(lang === "fr" ? "fr-CA" : "en-CA", {
467 + style: "currency",
468 + currency: "CAD",
469 + maximumFractionDigits: 0,
470 + notation: v >= 1_000_000 ? "compact" : "standard",
471 + }).format(v);
472 + // l'étiquette du rôle s'efface si elle chevaucherait l'estimation ou les bornes
473 + const roleClose =
474 + role != null &&
475 + (Math.abs(X(role) - X(estimate)) < 9 ||
476 + Math.abs(X(role) - X(low)) < 15 ||
477 + Math.abs(X(role) - X(high)) < 15);
478 + return (
479 + <div aria-label={`${fmt(low)} – ${fmt(high)}`}>
480 + <div className="relative h-[64px]">
481 + {/* étiquette estimation */}
482 + <div
483 + className="absolute top-0 -translate-x-1/2 whitespace-nowrap transition-[left] duration-700 ease-out"
484 + style={{ left: `${on ? clampX(estimate, 18, 82) : 50}%` }}
485 + >
486 + <span className="vp-mono rounded-[4px] border border-ink bg-ink px-1.5 py-0.5 text-[10px] font-bold text-lime">
487 + {lang === "fr" ? "ESTIMATION" : "ESTIMATE"} {fmt(estimate)}
488 + </span>
489 + </div>
490 + {/* piste */}
491 + <div className="absolute left-0 right-0 top-[30px] h-[14px] rounded-[7px] border-[1.5px] border-ink bg-surface-2" />
492 + {/* segment P10-P90 */}
493 + <div
494 + className="absolute top-[30px] h-[14px] origin-left rounded-[7px] border-[1.5px] border-ink transition-transform duration-1000 ease-out"
495 + style={{
496 + left: `${X(low)}%`,
497 + width: `${Math.max(2, X(high) - X(low))}%`,
498 + transform: on ? "scaleX(1)" : "scaleX(0.05)",
499 + background:
500 + "repeating-linear-gradient(-45deg, var(--lime-soft), var(--lime-soft) 5px, rgba(255,81,72,0.35) 5px, rgba(255,81,72,0.35) 7px)",
501 + }}
502 + />
503 + {/* marqueur estimation (losange) */}
504 + <div
505 + className="absolute top-[29px] -translate-x-1/2 transition-[left] duration-700 ease-out"
506 + style={{ left: `${on ? X(estimate) : 50}%` }}
507 + >
508 + <div className="h-[16px] w-[16px] rotate-45 border-[1.8px] border-ink bg-lime" />
509 + </div>
510 + {/* marqueur rôle */}
511 + {role != null && role > 0 && (
512 + <>
513 + <div
514 + className="absolute top-[22px] h-[30px] w-0 -translate-x-1/2 border-l-[2px] border-dashed border-ink opacity-70"
515 + style={{ left: `${X(role)}%` }}
516 + />
517 + {!roleClose && (
518 + <span
519 + className="vp-mono absolute top-[52px] -translate-x-1/2 whitespace-nowrap text-[9px] font-bold uppercase tracking-[0.06em] text-ink-2"
520 + style={{ left: `${clampX(role, 11, 89)}%` }}
521 + >
522 + {lang === "fr" ? "Rôle" : "Roll"} {fmt(role)}
523 + </span>
524 + )}
525 + </>
526 + )}
527 + {/* bornes */}
528 + <span
529 + className="vp-mono absolute top-[50px] -translate-x-1/2 whitespace-nowrap text-[10px] font-bold text-ink-2"
530 + style={{ left: `${clampX(low, 11, 89)}%` }}
531 + >
532 + P10 {fmt(low)}
533 + </span>
534 + <span
535 + className="vp-mono absolute top-[50px] -translate-x-1/2 whitespace-nowrap text-[10px] font-bold text-ink-2"
536 + style={{ left: `${clampX(high, 11, 89)}%` }}
537 + >
538 + P90 {fmt(high)}
539 + </span>
540 + </div>
541 + </div>
542 + );
543 +}
544 +
545 +/* --------------------------- courbe d'indice de marché --------------------------- */
546 +/** Indice mensuel rebasé à 100 au premier mois — la trajectoire du marché local. */
547 +export function MarketIndexChart({
548 + series,
549 + lang,
550 +}: {
551 + series: { month: string; idx: number }[];
552 + lang: string;
553 +}) {
554 + if (series.length < 6) return null;
555 + const base = series[0].idx || 1;
556 + const pts = series.map((p) => (p.idx / base) * 100);
557 + const W = 640;
558 + const H = 200;
559 + const padL = 40;
560 + const padR = 52;
561 + const padT = 18;
562 + const padB = 28;
563 + const min = Math.min(...pts);
564 + const max = Math.max(...pts);
565 + const span = max - min || 1;
566 + const x = (i: number) => padL + (i / (pts.length - 1)) * (W - padL - padR);
567 + const y = (v: number) => padT + (1 - (v - min) / span) * (H - padT - padB);
568 + const line = pts.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(" ");
569 + const area = `${x(0)},${H - padB} ${line} ${x(pts.length - 1)},${H - padB}`;
570 + // graduations : janvier de chaque année
571 + const years = series
572 + .map((p, i) => ({ i, month: p.month }))
573 + .filter((p) => p.month.endsWith("-01"));
574 + // lignes horizontales : min / 100 / max arrondis
575 + const gridVals = [...new Set([Math.round(min), 100, Math.round(max)])].filter(
576 + (v) => v >= min - 1 && v <= max + 1
577 + );
578 + const lastV = pts[pts.length - 1];
579 + return (
580 + <svg
581 + viewBox={`0 0 ${W} ${H}`}
582 + className="h-auto w-full"
583 + role="img"
584 + aria-label={
585 + lang === "fr"
586 + ? `Indice de marché ${series[0].month} à ${series[series.length - 1].month}`
587 + : `Market index ${series[0].month} to ${series[series.length - 1].month}`
588 + }
589 + >
590 + {gridVals.map((v) => (
591 + <g key={v}>
592 + <line
593 + x1={padL}
594 + y1={y(v)}
595 + x2={W - padR}
596 + y2={y(v)}
597 + stroke="var(--ink)"
598 + strokeWidth="1"
599 + strokeDasharray="3 5"
600 + opacity={v === 100 ? 0.45 : 0.18}
601 + />
602 + <text
603 + x={padL - 6}
604 + y={y(v) + 3.5}
605 + textAnchor="end"
606 + fontSize="10"
607 + fontFamily="var(--font-jetbrains)"
608 + fill="var(--ink-3)"
609 + >
610 + {v}
611 + </text>
612 + </g>
613 + ))}
614 + {years.map((p) => (
615 + <g key={p.month}>
616 + <line
617 + x1={x(p.i)}
618 + y1={padT}
619 + x2={x(p.i)}
620 + y2={H - padB}
621 + stroke="var(--ink)"
622 + strokeWidth="1"
623 + opacity="0.1"
624 + />
625 + <text
626 + x={x(p.i)}
627 + y={H - 10}
628 + textAnchor="middle"
629 + fontSize="10"
630 + fontWeight="700"
631 + fontFamily="var(--font-jetbrains)"
632 + fill="var(--ink-2)"
633 + >
634 + {p.month.slice(0, 4)}
635 + </text>
636 + </g>
637 + ))}
638 + <polygon points={area} fill="var(--lime-soft)" opacity="0.8" />
639 + <polyline
640 + points={line}
641 + fill="none"
642 + stroke="var(--green)"
643 + strokeWidth="2.6"
644 + strokeLinejoin="round"
645 + strokeLinecap="round"
646 + />
647 + <circle
648 + cx={x(pts.length - 1)}
649 + cy={y(lastV)}
650 + r="5"
651 + fill="var(--lime)"
652 + stroke="var(--ink)"
653 + strokeWidth="1.8"
654 + />
655 + <text
656 + x={x(pts.length - 1) + 9}
657 + y={y(lastV) + 4}
658 + fontSize="11.5"
659 + fontWeight="700"
660 + fontFamily="var(--font-jetbrains)"
661 + fill="var(--ink)"
662 + >
663 + {Math.round(lastV)}
664 + </text>
665 + </svg>
666 + );
667 +}
668 +
669 +/* ----------------------- position dans la municipalité ----------------------- */
670 +/** Distribution stylisée (cloche) : les barres sous le percentile sont pleines. */
671 +export function PercentileBar({
672 + pct,
673 + lang,
674 +}: {
675 + pct: number;
676 + lang: string;
677 +}) {
678 + const N = 36;
679 + const bars = Array.from({ length: N }, (_, i) => {
680 + const z = (i / (N - 1) - 0.42) / 0.24; // cloche légèrement asymétrique (droite étirée)
681 + return Math.exp(-0.5 * z * z) * (i / N > 0.5 ? 1 + (i / N - 0.5) * 0.35 : 1);
682 + });
683 + const maxB = Math.max(...bars);
684 + const p = Math.max(0, Math.min(100, pct));
685 + return (
686 + <div aria-label={`${Math.round(p)} %`}>
687 + <div className="relative flex h-[72px] items-end px-0.5" style={{ gap: "2px" }}>
688 + {bars.map((b, i) => {
689 + const filled = (i + 0.5) / N <= p / 100;
690 + return (
691 + <div
692 + key={i}
693 + className="min-w-0 flex-1 rounded-t-[2px] border border-ink transition-colors"
694 + style={{
695 + height: `${8 + (b / maxB) * 88}%`,
696 + background: filled ? "var(--lime)" : "var(--surface-2)",
697 + borderWidth: "1px",
698 + opacity: filled ? 1 : 0.55,
699 + }}
700 + />
701 + );
702 + })}
703 + <div
704 + className="absolute bottom-0 top-[-6px] w-0 border-l-[2px] border-ink"
705 + style={{ left: `${p}%` }}
706 + >
707 + <span className="vp-mono absolute -top-1 left-1.5 whitespace-nowrap rounded-[4px] border border-ink bg-ink px-1.5 py-0.5 text-[10px] font-bold text-lime" style={p > 55 ? { left: "auto", right: "6px" } : undefined}>
708 + {lang === "fr" ? "P" : "P"}
709 + {Math.round(p)}
710 + </span>
711 + </div>
712 + </div>
713 + </div>
714 + );
715 +}
716 +
717 +/* ------------------------ mini-courbe d'indice (accueil) ------------------------ */
718 +export function IndexSpark({
719 + series,
720 + width = 150,
721 + height = 46,
722 +}: {
723 + series: { month: string; idx: number }[];
724 + width?: number;
725 + height?: number;
726 +}) {
727 + if (series.length < 2) return null;
728 + const base = series[0].idx || 1;
729 + const pts = series.map((p) => (p.idx / base) * 100);
730 + const min = Math.min(...pts);
731 + const max = Math.max(...pts);
732 + const span = max - min || 1;
733 + const xy = pts.map((v, i) => [
734 + 4 + (i / (pts.length - 1)) * (width - 8),
735 + height - 6 - ((v - min) / span) * (height - 14),
736 + ]);
737 + const line = xy.map(([x, y]) => `${x.toFixed(1)},${y.toFixed(1)}`).join(" ");
738 + const area = `${xy[0][0]},${height - 3} ${line} ${xy[xy.length - 1][0]},${height - 3}`;
739 + const [lx, ly] = xy[xy.length - 1];
740 + return (
741 + <svg viewBox={`0 0 ${width} ${height}`} className="h-auto w-full" aria-hidden="true">
742 + <polygon points={area} fill="var(--lime-soft)" />
743 + <polyline points={line} fill="none" stroke="var(--green)" strokeWidth="2.2" strokeLinejoin="round" strokeLinecap="round" />
744 + <circle cx={lx} cy={ly} r="3.4" fill="var(--lime)" stroke="var(--ink)" strokeWidth="1.4" />
745 + </svg>
746 + );
747 +}
748 +
436 749 /* ------------------------------ frise temporelle ------------------------------ */
437 750 export function EraLine({ year, lang }: { year: number | null; lang: string }) {
438 751 if (!year || year < 1750) return null;
modified src/components/ResultView.tsx +167 −7
@@ -12,6 +12,9 @@ import {
12 12 EraLine,
13 13 locNum,
14 14 LotDiagram,
15 + MarketIndexChart,
16 + PercentileBar,
17 + RangeBar,
15 18 ValueSplit,
16 19 } from "./MetricViz";
17 20 import type { EstimateResult } from "@/lib/engine";
@@ -44,6 +47,7 @@ export default function ResultView({ data }: { data: UnitEstimate }) {
44 47 const [activeComp, setActiveComp] = useState<string | null>(null);
45 48 const r: EstimateResult = data.result;
46 49 const u = data.unit;
50 + const ctx = data.context;
47 51 const s = u?.specs;
48 52 const fr = lang === "fr";
49 53 const histMax = Math.max(...(u?.history.map((h) => h.value ?? 0) ?? [0]), 1);
@@ -68,7 +72,8 @@ export default function ResultView({ data }: { data: UnitEstimate }) {
68 72 {/* ---- Panneau prix ---- */}
69 73 <section className="vp-card rise p-6 sm:p-8">
70 74 <div className="flex flex-wrap items-start justify-between gap-6">
71 − <div className="min-w-0 flex-1">
75 + {/* basis large : sous ~380px de large la jauge passe dessous au lieu d'écraser la colonne */}
76 + <div className="min-w-0 flex-1 basis-[300px]">
72 77 <span className="kicker">{t("estimatedValue")}</span>
73 78 {u && (
74 79 <h1 className="vp-display mt-2 text-[clamp(20px,3vw,28px)] font-bold uppercase leading-tight tracking-[-0.02em]">
@@ -80,13 +85,41 @@ export default function ResultView({ data }: { data: UnitEstimate }) {
80 85 <p className="vp-display mt-3 text-[clamp(44px,7vw,72px)] font-bold leading-none tracking-[-0.04em]">
81 86 <CountUp value={r.estimate} format={moneyFmt} />
82 87 </p>
88 + {/* faits rapides */}
89 + <div className="mt-4 flex flex-wrap gap-2">
90 + {[
91 + u ? t(u.typeProp as TKey) : null,
92 + u?.aireEtagesM2 && r.estimate > 0
93 + ? `${locNum(Math.round(r.estimate / u.aireEtagesM2), lang, { maxFrac: 0 })} $/m²`
94 + : null,
95 + u?.aireEtagesM2 ? locNum(u.aireEtagesM2, lang, { unit: "m²" }) : null,
96 + u?.anneeConstruction ? `${fr ? "constr." : "built"} ${u.anneeConstruction}` : null,
97 + u?.superficieTerrainM2
98 + ? `${fr ? "terrain" : "lot"} ${locNum(u.superficieTerrainM2, lang, { unit: "m²" })}`
99 + : null,
100 + ]
101 + .filter((x): x is string => x != null)
102 + .map((x) => (
103 + <span
104 + key={x}
105 + className="vp-mono whitespace-nowrap rounded-[5px] border-[1.5px] border-ink bg-surface-2 px-2.5 py-1 text-[11px] font-bold uppercase tracking-[0.04em]"
106 + >
107 + {x}
108 + </span>
109 + ))}
110 + </div>
111 + {/* fourchette visuelle P10-P90 avec position du rôle */}
112 + <div className="mt-6 max-w-2xl">
113 + <p className="klabel mb-1">{t("range")}</p>
114 + <RangeBar
115 + low={r.low}
116 + estimate={r.estimate}
117 + high={r.high}
118 + role={u?.valeurRole ?? null}
119 + lang={lang}
120 + />
121 + </div>
83 122 <div className="mt-5 flex flex-wrap items-center gap-x-6 gap-y-3">
84 − <div>
85 − <p className="klabel">{t("range")}</p>
86 − <p className="vp-display text-[19px] font-bold">
87 − {fmt(r.low, lang)} <span className="text-ink-3">→</span> {fmt(r.high, lang)}
88 − </p>
89 − </div>
90 123 {u?.valeurRole != null && (
91 124 <div>
92 125 <p className="klabel">{t("assessed")}</p>
@@ -274,6 +307,120 @@ export default function ResultView({ data }: { data: UnitEstimate }) {
274 307 </div>
275 308 </section>
276 309
310 + {/* ---- Marché local ---- */}
311 + {ctx && (ctx.index.length >= 6 || ctx.sector || ctx.muniPercentile != null) && (
312 + <section>
313 + <span className="kicker">{fr ? "Contexte de marché" : "Market context"}</span>
314 + <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">
315 + {fr ? "Le marché autour de cette propriété" : "The market around this property"}
316 + </h2>
317 + <div className="mt-4 grid items-stretch gap-4 lg:grid-cols-[1.35fr_1fr]">
318 + {/* trajectoire du marché */}
319 + {ctx.index.length >= 6 && (
320 + <div className="vp-card rise p-5">
321 + <div className="flex flex-wrap items-center justify-between gap-2">
322 + <p className="klabel">
323 + {fr
324 + ? `Indice de marché — ${t(ctx.typeProp as TKey)} (base 100 en ${ctx.index[0].month.slice(0, 4)})`
325 + : `Market index — ${t(ctx.typeProp as TKey)} (base 100 in ${ctx.index[0].month.slice(0, 4)})`}
326 + </p>
327 + <div className="flex gap-2">
328 + {ctx.index12mPct != null && (
329 + <span className={`vp-mono rounded-[4px] border border-ink px-1.5 py-0.5 text-[10px] font-bold ${ctx.index12mPct >= 0 ? "bg-lime text-ink" : "bg-danger text-white"}`}>
330 + 12 {fr ? "mois" : "mo"} {ctx.index12mPct >= 0 ? "+" : ""}
331 + {ctx.index12mPct.toFixed(1)} %
332 + </span>
333 + )}
334 + {ctx.indexSince2021Pct != null && (
335 + <span className="vp-mono rounded-[4px] border border-ink bg-surface-2 px-1.5 py-0.5 text-[10px] font-bold text-ink-2">
336 + {ctx.index[0].month.slice(0, 4)}→ {ctx.indexSince2021Pct >= 0 ? "+" : ""}
337 + {ctx.indexSince2021Pct.toFixed(0)} %
338 + </span>
339 + )}
340 + </div>
341 + </div>
342 + <div className="mt-3">
343 + <MarketIndexChart series={ctx.index} lang={lang} />
344 + </div>
345 + <p className="vp-mono mt-2 text-[10px] uppercase tracking-[0.05em] text-ink-3">
346 + {fr
347 + ? "Indice utilisé pour l'ajustement « marché » des comparables — même donnée, même calcul."
348 + : "Index used for the comparables' market adjustment — same data, same math."}
349 + </p>
350 + </div>
351 + )}
352 + <div className="flex flex-col gap-4">
353 + {/* ventes du secteur */}
354 + {ctx.sector && (
355 + <div className="vp-card rise p-5" style={{ animationDelay: "80ms" }}>
356 + <p className="klabel">
357 + {fr
358 + ? `Ventes réelles — rayon ${ctx.sector.radiusKm} km · 12 mois`
359 + : `Real sales — ${ctx.sector.radiusKm} km radius · 12 months`}
360 + </p>
361 + <div className="mt-3 grid grid-cols-3 gap-2">
362 + <div className="kv-cell">
363 + <p className="k">{fr ? "Ventes" : "Sales"}</p>
364 + <p className="v">{ctx.sector.nSales12m}</p>
365 + </div>
366 + <div className="kv-cell">
367 + <p className="k">{fr ? "Prix médian" : "Median price"}</p>
368 + <p className="v">{ctx.sector.medianPrice != null ? fmt(Math.round(ctx.sector.medianPrice), lang) : "—"}</p>
369 + </div>
370 + <div className="kv-cell">
371 + <p className="k">$/m² {fr ? "médian" : "median"}</p>
372 + <p className="v">
373 + {ctx.sector.medianPpm2 != null
374 + ? locNum(Math.round(ctx.sector.medianPpm2), lang, { maxFrac: 0 })
375 + : "—"}
376 + </p>
377 + </div>
378 + </div>
379 + {ctx.sector.medianPrice != null && r.estimate > 0 && (
380 + <p className="vp-mono mt-3 text-[10.5px] uppercase tracking-[0.05em] text-ink-2">
381 + {fr ? "Cette propriété" : "This property"} :{" "}
382 + <b className="text-ink">
383 + {r.estimate >= ctx.sector.medianPrice ? "+" : "−"}
384 + {Math.abs((r.estimate / ctx.sector.medianPrice - 1) * 100).toFixed(0)} %
385 + </b>{" "}
386 + {fr ? "vs médiane du secteur" : "vs sector median"}
387 + </p>
388 + )}
389 + </div>
390 + )}
391 + {/* position dans la municipalité */}
392 + {ctx.muniPercentile != null && u?.municipalite && (
393 + <div className="vp-card rise flex-1 p-5" style={{ animationDelay: "160ms" }}>
394 + <p className="klabel">
395 + {fr ? `Position dans ${u.municipalite}` : `Position in ${u.municipalite}`}
396 + </p>
397 + <div className="mt-3">
398 + <PercentileBar pct={ctx.muniPercentile} lang={lang} />
399 + </div>
400 + <p className="mt-3 text-[13px] text-ink-2">
401 + {fr ? (
402 + <>
403 + Vaut plus que{" "}
404 + <b className="text-ink">{Math.round(ctx.muniPercentile)} %</b> des{" "}
405 + {ctx.muniTotal != null ? locNum(ctx.muniTotal, lang, { maxFrac: 0 }) : ""}{" "}
406 + propriétés estimées de la municipalité.
407 + </>
408 + ) : (
409 + <>
410 + Worth more than{" "}
411 + <b className="text-ink">{Math.round(ctx.muniPercentile)} %</b> of the{" "}
412 + {ctx.muniTotal != null ? locNum(ctx.muniTotal, lang, { maxFrac: 0 }) : ""}{" "}
413 + estimated properties in the municipality.
414 + </>
415 + )}
416 + </p>
417 + </div>
418 + )}
419 + </div>
420 + </div>
421 + </section>
422 + )}
423 +
277 424 {/* ---- Historique ---- */}
278 425 {u && u.history.some((h) => h.value != null) && (
279 426 <section>
@@ -383,7 +530,20 @@ export default function ResultView({ data }: { data: UnitEstimate }) {
383 530 <p className="vp-mono mt-0.5 text-[10.5px] uppercase tracking-[0.04em] text-ink-3">
384 531 {c.date} · {c.distanceM < 1000 ? `${Math.round(c.distanceM)} m` : `${(c.distanceM / 1000).toFixed(1)} km`} ·{" "}
385 532 {t("sold")} {fmt(c.amount, lang)}
533 + {c.floorArea && c.floorArea > 20
534 + ? ` (${locNum(Math.round(c.amount / c.floorArea), lang, { maxFrac: 0 })} $/m²)`
535 + : ""}
386 536 </p>
537 + {(c.floorArea || c.yearBuilt) && (
538 + <p className="vp-mono mt-0.5 text-[10.5px] uppercase tracking-[0.04em] text-ink-3">
539 + {[
540 + c.floorArea ? locNum(c.floorArea, lang, { unit: "m²" }) : null,
541 + c.yearBuilt ? `${fr ? "constr." : "built"} ${c.yearBuilt}` : null,
542 + ]
543 + .filter(Boolean)
544 + .join(" · ")}
545 + </p>
546 + )}
387 547 <p className="vp-mono mt-0.5 text-[10.5px] text-ink-2">
388 548 {t("adjTime")} {signed(c.adjTime, lang)} · {t("adjArea")} {signed(c.adjArea, lang)} ·{" "}
389 549 {t("adjAge")} {signed(c.adjAge, lang)}
modified src/lib/db.ts +49 −0
@@ -177,6 +177,55 @@ export function municipalityCenter(
177 177 .get(name) as { lat: number; lng: number; n: number } | undefined;
178 178 }
179 179
180 +/** Rang de la valeur dans la municipalité (percentile sur est_2026). */
181 +export function municipalityPercentile(
182 + municipalite: string,
183 + value: number
184 +): { below: number; total: number } | null {
185 + const row = getDb()
186 + .prepare(
187 + `SELECT COUNT(*) AS total,
188 + SUM(CASE WHEN est_2026 < ? THEN 1 ELSE 0 END) AS below
189 + FROM units
190 + WHERE municipalite = ? COLLATE NOCASE
191 + AND est_2026 IS NOT NULL AND est_2026 > 0`
192 + )
193 + .get(value, municipalite) as { total: number; below: number | null } | undefined;
194 + if (!row || row.total < 30) return null;
195 + return { below: row.below ?? 0, total: row.total };
196 +}
197 +
198 +export interface RecentSaleRow {
199 + id: string;
200 + date: string;
201 + amount: number;
202 + street: string | null;
203 + city: string | null;
204 + property_type: string | null;
205 + id_provinc: string | null;
206 + valeur_role: number | null;
207 +}
208 +
209 +/** Dernières ventes publiées appariées à une unité (fil « ventes récentes »). */
210 +export function recentSales(limit = 6): RecentSaleRow[] {
211 + return getDb()
212 + .prepare(
213 + `SELECT id, date, amount, street, city, property_type, id_provinc, valeur_role
214 + FROM transactions
215 + WHERE id_provinc IS NOT NULL AND street IS NOT NULL AND amount >= 50000
216 + ORDER BY date DESC, amount DESC
217 + LIMIT ?`
218 + )
219 + .all(limit) as RecentSaleRow[];
220 +}
221 +
222 +export function salesCountSince(sinceDate: string): number {
223 + const row = getDb()
224 + .prepare("SELECT COUNT(*) AS n FROM transactions WHERE date >= ?")
225 + .get(sinceDate) as { n: number };
226 + return row.n;
227 +}
228 +
180 229 export function saveLead(email: string, unitId: string | null, estimate: number | null): void {
181 230 getDb()
182 231 .prepare("INSERT INTO leads (email, unit_id, estimate) VALUES (?, ?, ?)")
modified src/lib/estimator.ts +119 −13
@@ -5,10 +5,17 @@ import {
5 5 getMarketIndex,
6 6 getUnit,
7 7 municipalityCenter,
8 + municipalityPercentile,
8 9 type TxRow,
9 10 type UnitRow,
10 11 } from "./db";
11 −import { estimate, type CompInput, type EstimateResult, type Subject } from "./engine";
12 +import {
13 + estimate,
14 + haversineM,
15 + type CompInput,
16 + type EstimateResult,
17 + type Subject,
18 +} from "./engine";
12 19
13 20 export interface UnitSpecs {
14 21 cubf: number | null;
@@ -31,6 +38,28 @@ export interface UnitSpecs {
31 38 apt: string | null;
32 39 }
33 40
41 +/** Ventes réelles des 12 derniers mois autour du sujet (rayon adaptatif). */
42 +export interface SectorStats {
43 + nSales12m: number;
44 + medianPrice: number | null;
45 + medianPpm2: number | null;
46 + radiusKm: number;
47 +}
48 +
49 +/** Contexte marché affiché avec l'estimation — même transparence que le calcul. */
50 +export interface MarketContext {
51 + /** Type de propriété du sujet (clé i18n). */
52 + typeProp: string;
53 + /** Indice de marché mensuel du type (1.0 = niveau courant). */
54 + index: { month: string; idx: number }[];
55 + index12mPct: number | null;
56 + indexSince2021Pct: number | null;
57 + sector: SectorStats | null;
58 + /** % des propriétés de la municipalité valant moins que le sujet. */
59 + muniPercentile: number | null;
60 + muniTotal: number | null;
61 +}
62 +
34 63 export interface UnitEstimate {
35 64 unit: {
36 65 id: string;
@@ -49,6 +78,7 @@ export interface UnitEstimate {
49 78 specs: UnitSpecs;
50 79 } | null;
51 80 result: EstimateResult;
81 + context?: MarketContext;
52 82 }
53 83
54 84 /** Type de comparable attendu pour le type de tx du marché de l'indice. */
@@ -92,6 +122,69 @@ function candidatesAround(lat: number, lng: number): TxRow[] {
92 122 return getCandidates(lat, lng, 0.25, since(36), 600);
93 123 }
94 124
125 +function median(xs: number[]): number | null {
126 + if (!xs.length) return null;
127 + const s = [...xs].sort((a, b) => a - b);
128 + const m = Math.floor(s.length / 2);
129 + return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
130 +}
131 +
132 +/** Stats du secteur à partir des candidats déjà chargés (aucune requête en plus). */
133 +function sectorStats(lat: number, lng: number, rows: TxRow[]): SectorStats | null {
134 + const cutoff = since(12);
135 + const recent = rows.filter((r) => r.date >= cutoff);
136 + for (const radiusKm of [2, 5, 10]) {
137 + const within = recent.filter(
138 + (r) => haversineM(lat, lng, r.lat, r.lng) <= radiusKm * 1000
139 + );
140 + if (within.length >= 8 || radiusKm === 10) {
141 + if (within.length === 0) return null;
142 + return {
143 + nSales12m: within.length,
144 + medianPrice: median(within.map((r) => r.amount)),
145 + medianPpm2: median(
146 + within
147 + .filter((r) => r.floor_area != null && r.floor_area > 20)
148 + .map((r) => r.amount / r.floor_area!)
149 + ),
150 + radiusKm,
151 + };
152 + }
153 + }
154 + return null;
155 +}
156 +
157 +function buildContext(
158 + typeProp: string,
159 + lat: number,
160 + lng: number,
161 + candidates: TxRow[],
162 + index: { month: string; idx: number }[],
163 + municipalite: string | null,
164 + estimateValue: number
165 +): MarketContext {
166 + const last = index.length ? index[index.length - 1].idx : null;
167 + const yearAgo = index.length >= 13 ? index[index.length - 13].idx : null;
168 + const first = index.length >= 2 ? index[0].idx : null;
169 + const pr =
170 + municipalite && Number.isFinite(estimateValue) && estimateValue > 0
171 + ? municipalityPercentile(municipalite, estimateValue)
172 + : null;
173 + return {
174 + typeProp,
175 + index,
176 + index12mPct:
177 + last != null && yearAgo != null && yearAgo > 0
178 + ? (last / yearAgo - 1) * 100
179 + : null,
180 + indexSince2021Pct:
181 + last != null && first != null && first > 0 ? (last / first - 1) * 100 : null,
182 + sector: sectorStats(lat, lng, candidates),
183 + muniPercentile: pr ? (pr.below / pr.total) * 100 : null,
184 + muniTotal: pr ? pr.total : null,
185 + };
186 +}
187 +
95 188 export function estimateByUnitId(id: string): UnitEstimate | null {
96 189 const u = getUnit(id);
97 190 if (!u) return null;
@@ -106,13 +199,20 @@ export function estimateByUnitId(id: string): UnitEstimate | null {
106 199 modelP10: u.p10,
107 200 modelP90: u.p90,
108 201 };
109 − const result = estimate(
110 − subject,
111 − toComps(candidatesAround(u.lat, u.lng)),
112 − getMarketIndex(indexType(u.type_prop)),
113 − nowISO()
202 + const candidates = candidatesAround(u.lat, u.lng);
203 + const index = getMarketIndex(indexType(u.type_prop));
204 + const result = estimate(subject, toComps(candidates), index, nowISO());
205 + const context = buildContext(
206 + u.type_prop,
207 + u.lat,
208 + u.lng,
209 + candidates,
210 + index,
211 + u.municipalite,
212 + result.estimate
114 213 );
115 214 return {
215 + context,
116 216 unit: {
117 217 id: u.id_provinc,
118 218 adresse: u.adresse,
@@ -283,12 +383,18 @@ export function estimateManual(p: ManualParams): UnitEstimate | null {
283 383 modelP10: null,
284 384 modelP90: null,
285 385 };
286 − const result = estimate(
287 − subject,
288 − toComps(candidatesAround(center.lat, center.lng)),
289 − getMarketIndex(indexType(p.typeProp)),
290 − nowISO()
291 − );
386 + const candidates = candidatesAround(center.lat, center.lng);
387 + const index = getMarketIndex(indexType(p.typeProp));
388 + const result = estimate(subject, toComps(candidates), index, nowISO());
292 389 if (!Number.isFinite(result.estimate)) return null;
293 − return { unit: null, result };
390 + const context = buildContext(
391 + p.typeProp,
392 + center.lat,
393 + center.lng,
394 + candidates,
395 + index,
396 + p.municipality,
397 + result.estimate
398 + );
399 + return { unit: null, result, context };
294 400 }
295 401