SPB Git

spb/vrai-prix

Public

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

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 (Aug 23, 2026) parent 4bcf5c2 Browse files

6 changed files +906 −23

modified src/app/HomeClient.tsx +204 −1
@@ -1,11 +1,74 @@
1 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai 1 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 "use client"; 2 "use client";
3+import Link from "next/link";
3 import SearchBox from "@/components/SearchBox"; 4 import SearchBox from "@/components/SearchBox";
4 import EstimateForm from "@/components/EstimateForm"; 5 import EstimateForm from "@/components/EstimateForm";
6+import { IndexSpark, locNum } from "@/components/MetricViz";
5 import { useLang } from "@/components/LangContext"; 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 const { lang, t } = useLang(); 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 return ( 72 return (
10 <div className="pb-6 pt-10 sm:pt-14"> 73 <div className="pb-6 pt-10 sm:pt-14">
11 {/* ---- Héros ---- */} 74 {/* ---- Héros ---- */}
@@ -38,6 +101,10 @@ export default function HomeClient() {
38 <span className="stat-chip"> 101 <span className="stat-chip">
39 <b>745 119</b> {lang === "fr" ? "ventes réelles" : "real sales"} 102 <b>745 119</b> {lang === "fr" ? "ventes réelles" : "real sales"}
40 </span> 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 <span className="stat-chip"> 108 <span className="stat-chip">
42 <b>1 100</b> {lang === "fr" ? "municipalités" : "municipalities"} 109 <b>1 100</b> {lang === "fr" ? "municipalités" : "municipalities"}
43 </span> 110 </span>
@@ -60,6 +127,142 @@ export default function HomeClient() {
60 </p> 127 </p>
61 </section> 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 {/* ---- Formulaire manuel ---- */} 266 {/* ---- Formulaire manuel ---- */}
64 <section className="mt-14"> 267 <section className="mt-14">
65 <span className="kicker">{lang === "fr" ? "Plan B" : "Plan B"}</span> 268 <span className="kicker">{lang === "fr" ? "Plan B" : "Plan B"}</span>
modified src/app/page.tsx +54 −2
@@ -1,10 +1,15 @@
1 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai 1 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 import type { Metadata } from "next"; 2 import type { Metadata } from "next";
3 import Link from "next/link"; 3 import Link from "next/link";
4−import HomeClient from "./HomeClient"; 4+import HomeClient, { type PulseItem, type RecentSaleItem } from "./HomeClient";
5 import stats from "@/data/stats.json"; 5 import stats from "@/data/stats.json";
6+import { getMarketIndex, recentSales, salesCountSince } from "@/lib/db";
6 import { moneyFr, slugify } from "@/lib/seo"; 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 export const metadata: Metadata = { 13 export const metadata: Metadata = {
9 title: { 14 title: {
10 absolute: "Vrai-Prix — estimation immobilière gratuite au Québec · Un service Groupe KA", 15 absolute: "Vrai-Prix — estimation immobilière gratuite au Québec · Un service Groupe KA",
@@ -31,10 +36,57 @@ const TOP_VILLES = (
31 stats as { par_ville: { ville: string; n: number; mediane: number | null }[] } 36 stats as { par_ville: { ville: string; n: number; mediane: number | null }[] }
32 ).par_ville.slice(0, 24); 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 export default function Home() { 80 export default function Home() {
81+ const monthAgo = new Date();
82+ monthAgo.setDate(monthAgo.getDate() - 30);
35 return ( 83 return (
36 <> 84 <>
37− <HomeClient /> 85+ <HomeClient
86+ pulse={buildPulse()}
87+ recent={buildRecent()}
88+ salesLast30d={salesCountSince(iso(monthAgo))}
89+ />
38 {/* ---- Explorer par municipalité (maillage interne indexable) ---- */} 90 {/* ---- Explorer par municipalité (maillage interne indexable) ---- */}
39 <section className="mt-14"> 91 <section className="mt-14">
40 <span className="kicker">Explorer par municipalité</span> 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 /* ------------------------------ frise temporelle ------------------------------ */ 749 /* ------------------------------ frise temporelle ------------------------------ */
437 export function EraLine({ year, lang }: { year: number | null; lang: string }) { 750 export function EraLine({ year, lang }: { year: number | null; lang: string }) {
438 if (!year || year < 1750) return null; 751 if (!year || year < 1750) return null;
modified src/components/ResultView.tsx +167 −7
@@ -12,6 +12,9 @@ import {
12 EraLine, 12 EraLine,
13 locNum, 13 locNum,
14 LotDiagram, 14 LotDiagram,
15+ MarketIndexChart,
16+ PercentileBar,
17+ RangeBar,
15 ValueSplit, 18 ValueSplit,
16 } from "./MetricViz"; 19 } from "./MetricViz";
17 import type { EstimateResult } from "@/lib/engine"; 20 import type { EstimateResult } from "@/lib/engine";
@@ -44,6 +47,7 @@ export default function ResultView({ data }: { data: UnitEstimate }) {
44 const [activeComp, setActiveComp] = useState<string | null>(null); 47 const [activeComp, setActiveComp] = useState<string | null>(null);
45 const r: EstimateResult = data.result; 48 const r: EstimateResult = data.result;
46 const u = data.unit; 49 const u = data.unit;
50+ const ctx = data.context;
47 const s = u?.specs; 51 const s = u?.specs;
48 const fr = lang === "fr"; 52 const fr = lang === "fr";
49 const histMax = Math.max(...(u?.history.map((h) => h.value ?? 0) ?? [0]), 1); 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 {/* ---- Panneau prix ---- */} 72 {/* ---- Panneau prix ---- */}
69 <section className="vp-card rise p-6 sm:p-8"> 73 <section className="vp-card rise p-6 sm:p-8">
70 <div className="flex flex-wrap items-start justify-between gap-6"> 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 <span className="kicker">{t("estimatedValue")}</span> 77 <span className="kicker">{t("estimatedValue")}</span>
73 {u && ( 78 {u && (
74 <h1 className="vp-display mt-2 text-[clamp(20px,3vw,28px)] font-bold uppercase leading-tight tracking-[-0.02em]"> 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 <p className="vp-display mt-3 text-[clamp(44px,7vw,72px)] font-bold leading-none tracking-[-0.04em]"> 85 <p className="vp-display mt-3 text-[clamp(44px,7vw,72px)] font-bold leading-none tracking-[-0.04em]">
81 <CountUp value={r.estimate} format={moneyFmt} /> 86 <CountUp value={r.estimate} format={moneyFmt} />
82 </p> 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 <div className="mt-5 flex flex-wrap items-center gap-x-6 gap-y-3"> 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 {u?.valeurRole != null && ( 123 {u?.valeurRole != null && (
91 <div> 124 <div>
92 <p className="klabel">{t("assessed")}</p> 125 <p className="klabel">{t("assessed")}</p>
@@ -274,6 +307,120 @@ export default function ResultView({ data }: { data: UnitEstimate }) {
274 </div> 307 </div>
275 </section> 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 {/* ---- Historique ---- */} 424 {/* ---- Historique ---- */}
278 {u && u.history.some((h) => h.value != null) && ( 425 {u && u.history.some((h) => h.value != null) && (
279 <section> 426 <section>
@@ -383,7 +530,20 @@ export default function ResultView({ data }: { data: UnitEstimate }) {
383 <p className="vp-mono mt-0.5 text-[10.5px] uppercase tracking-[0.04em] text-ink-3"> 530 <p className="vp-mono mt-0.5 text-[10.5px] uppercase tracking-[0.04em] text-ink-3">
384 {c.date} · {c.distanceM < 1000 ? `${Math.round(c.distanceM)} m` : `${(c.distanceM / 1000).toFixed(1)} km`} ·{" "} 531 {c.date} · {c.distanceM < 1000 ? `${Math.round(c.distanceM)} m` : `${(c.distanceM / 1000).toFixed(1)} km`} ·{" "}
385 {t("sold")} {fmt(c.amount, lang)} 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 </p> 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 <p className="vp-mono mt-0.5 text-[10.5px] text-ink-2"> 547 <p className="vp-mono mt-0.5 text-[10.5px] text-ink-2">
388 {t("adjTime")} {signed(c.adjTime, lang)} · {t("adjArea")} {signed(c.adjArea, lang)} ·{" "} 548 {t("adjTime")} {signed(c.adjTime, lang)} · {t("adjArea")} {signed(c.adjArea, lang)} ·{" "}
389 {t("adjAge")} {signed(c.adjAge, lang)} 549 {t("adjAge")} {signed(c.adjAge, lang)}
modified src/lib/db.ts +49 −0
@@ -177,6 +177,55 @@ export function municipalityCenter(
177 .get(name) as { lat: number; lng: number; n: number } | undefined; 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 export function saveLead(email: string, unitId: string | null, estimate: number | null): void { 229 export function saveLead(email: string, unitId: string | null, estimate: number | null): void {
181 getDb() 230 getDb()
182 .prepare("INSERT INTO leads (email, unit_id, estimate) VALUES (?, ?, ?)") 231 .prepare("INSERT INTO leads (email, unit_id, estimate) VALUES (?, ?, ?)")
modified src/lib/estimator.ts +119 −13
@@ -5,10 +5,17 @@ import {
5 getMarketIndex, 5 getMarketIndex,
6 getUnit, 6 getUnit,
7 municipalityCenter, 7 municipalityCenter,
8+ municipalityPercentile,
8 type TxRow, 9 type TxRow,
9 type UnitRow, 10 type UnitRow,
10 } from "./db"; 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 export interface UnitSpecs { 20 export interface UnitSpecs {
14 cubf: number | null; 21 cubf: number | null;
@@ -31,6 +38,28 @@ export interface UnitSpecs {
31 apt: string | null; 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 export interface UnitEstimate { 63 export interface UnitEstimate {
35 unit: { 64 unit: {
36 id: string; 65 id: string;
@@ -49,6 +78,7 @@ export interface UnitEstimate {
49 specs: UnitSpecs; 78 specs: UnitSpecs;
50 } | null; 79 } | null;
51 result: EstimateResult; 80 result: EstimateResult;
81+ context?: MarketContext;
52 } 82 }
53 83
54 /** Type de comparable attendu pour le type de tx du marché de l'indice. */ 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 return getCandidates(lat, lng, 0.25, since(36), 600); 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 export function estimateByUnitId(id: string): UnitEstimate | null { 188 export function estimateByUnitId(id: string): UnitEstimate | null {
96 const u = getUnit(id); 189 const u = getUnit(id);
97 if (!u) return null; 190 if (!u) return null;
@@ -106,13 +199,20 @@ export function estimateByUnitId(id: string): UnitEstimate | null {
106 modelP10: u.p10, 199 modelP10: u.p10,
107 modelP90: u.p90, 200 modelP90: u.p90,
108 }; 201 };
109− const result = estimate( 202+ const candidates = candidatesAround(u.lat, u.lng);
110− subject, 203+ const index = getMarketIndex(indexType(u.type_prop));
111− toComps(candidatesAround(u.lat, u.lng)), 204+ const result = estimate(subject, toComps(candidates), index, nowISO());
112− getMarketIndex(indexType(u.type_prop)), 205+ const context = buildContext(
113− nowISO() 206+ u.type_prop,
207+ u.lat,
208+ u.lng,
209+ candidates,
210+ index,
211+ u.municipalite,
212+ result.estimate
114 ); 213 );
115 return { 214 return {
215+ context,
116 unit: { 216 unit: {
117 id: u.id_provinc, 217 id: u.id_provinc,
118 adresse: u.adresse, 218 adresse: u.adresse,
@@ -283,12 +383,18 @@ export function estimateManual(p: ManualParams): UnitEstimate | null {
283 modelP10: null, 383 modelP10: null,
284 modelP90: null, 384 modelP90: null,
285 }; 385 };
286− const result = estimate( 386+ const candidates = candidatesAround(center.lat, center.lng);
287− subject, 387+ const index = getMarketIndex(indexType(p.typeProp));
288− toComps(candidatesAround(center.lat, center.lng)), 388+ const result = estimate(subject, toComps(candidates), index, nowISO());
289− getMarketIndex(indexType(p.typeProp)),
290− nowISO()
291− );
292 if (!Number.isFinite(result.estimate)) return null; 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
Menu

Theme