SPB Git

spb/vrai-prix

Public

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

feat(ventes): registre des ventes récentes (flux Journal de Québec / Journal de Montréal, api.qub.ca)

- page /ventes : tuiles (30 j, 90 j, médiane, dernière capture), médianes par type,
  filtres GET municipalité / type / prix / période (sans JS requis), tableau
  date · adresse (→ /estimation) · municipalité (→ /municipalite) · type · prix ·
  rôle · écart vs rôle, pagination ; JSON-LD Dataset + fil d'Ariane
- API /api/ventes (mêmes filtres, Cache-Control 5 min)
- page municipalité : section « Dernières ventes à … » (12 dernières, n et
  médiane 12 mois, lien vers le registre filtré)
- navigation : entrée « Ventes » (header + menu mobile), pied de page, bouton
  « Toutes les ventes récentes » sous le flux de l'accueil, onglet Marché actif,
  sitemap
- lib/ventes.ts + index idx_tx_city_date (city NOCASE, date DESC, amount DESC)
- connecteur ingest-jdm.mjs : jeton externe QUB_TOKEN / --token (court-circuite
  le login Scrapfly) ; scripts/qub-token-safari.sh (laptop, Safari piloté par
  AppleScript + cliclick, ~/.config/vrai-prix/qub.env) et ingest-via-safari.sh

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Oct 20, 2026) parent e4a1c0c Browse files

14 changed files +795 −8

modified README.md +3 −2
@@ -260,7 +260,7 @@ Application Next.js 16 (App Router) — SQLite better-sqlite3 (units + FTS5
260 |---|---|---|---| 260 |---|---|---|---|
261 | **MAMH — rôles d'évaluation foncière géoréférencés** (données ouvertes, Données Québec) | 6 millésimes 2021-2026, caractéristiques complètes des unités | 3 747 008 propriétés · 22 150 285 observations | à chaque millésime annuel (réingestion `scripts/`) | 261 | **MAMH — rôles d'évaluation foncière géoréférencés** (données ouvertes, Données Québec) | 6 millésimes 2021-2026, caractéristiques complètes des unités | 3 747 008 propriétés · 22 150 285 observations | à chaque millésime annuel (réingestion `scripts/`) |
262 | **Transactions réelles** (fusion spatiale XY, EPSG:32198) | prix de vente appariés aux unités (99,88 %, distance médiane 0,6 m) | 745 119 transactions | lors du réentraînement du modèle | 262 | **Transactions réelles** (fusion spatiale XY, EPSG:32198) | prix de vente appariés aux unités (99,88 %, distance médiane 0,6 m) | 745 119 transactions | lors du réentraînement du modèle |
263−| **Connecteur « nouvelles ventes »** (JdM / api.qub.ca) | ingestion incrémentale de transactions récentes | à la demande | `scripts/ingest-jdm.mjs` (process PM2 `vrai-prix-ingest`) | 263+| **Connecteur « nouvelles ventes »** (Journal de Québec / Journal de Montréal — flux « Transactions immobilières », api.qub.ca) | ingestion incrémentale de transactions récentes, publiées sur [`/ventes`](https://www.vrai-prix.com/ventes) et sur chaque page municipalité | quotidienne (PM2 cron 08:00) | `scripts/ingest-jdm.mjs` (process PM2 `vrai-prix-ingest`) ; repli laptop sans Scrapfly : `scripts/ingest-via-safari.sh` (jeton QUB via Safari/AppleScript) |
264 | **Modèle hédonique LightGBM** | log-prix, 18 variables, quantiles P10/P90 — MdAPE 11,0 %, ratio médian 0,995 | estimations pré-calculées sur ~3,75 M unités | réentraînement `scripts/hedonic-retrain.py` (hors ligne) | 264 | **Modèle hédonique LightGBM** | log-prix, 18 variables, quantiles P10/P90 — MdAPE 11,0 %, ratio médian 0,995 | estimations pré-calculées sur ~3,75 M unités | réentraînement `scripts/hedonic-retrain.py` (hors ligne) |
265 265
266 - Contrairement aux apps à connecteurs du Groupe KA, Vrai-Prix ne dépend d'aucune synchronisation horaire : la base est **pré-calculée** et versionnée par millésime — l'app sert les estimations en lecture seule. 266 - Contrairement aux apps à connecteurs du Groupe KA, Vrai-Prix ne dépend d'aucune synchronisation horaire : la base est **pré-calculée** et versionnée par millésime — l'app sert les estimations en lecture seule.
@@ -340,7 +340,7 @@ Noms seulement — **aucun secret n'est versionné** (`.env.local` sur le nœud)
340 |---|---|---| 340 |---|---|---|
341 | `vrai-prix` | `next start -H 0.0.0.0 -p 8090` | l'app Next.js — pages SSR + API | 341 | `vrai-prix` | `next start -H 0.0.0.0 -p 8090` | l'app Next.js — pages SSR + API |
342 | `vrai-prix-ngrok` | `ngrok http --url=www.vrai-prix.com 8090` | tunnel vers www.vrai-prix.com | 342 | `vrai-prix-ngrok` | `ngrok http --url=www.vrai-prix.com 8090` | tunnel vers www.vrai-prix.com |
343−| `vrai-prix-ingest` | `node scripts/ingest-jdm.mjs` | connecteur incrémental « nouvelles ventes » — lancé à la demande, arrêté au repos | 343+| `vrai-prix-ingest` | `node scripts/ingest-jdm.mjs` | connecteur incrémental « nouvelles ventes » (JdQ/JdM, api.qub.ca) — cron PM2 quotidien 08:00, arrêté au repos ; `QUB_TOKEN=… node scripts/ingest-jdm.mjs` pour réutiliser un jeton obtenu autrement (`scripts/qub-token-safari.sh` sur le laptop) |
344 | `vrai-prix-immoka-sync` | `npm run immoka:sync` (cron `30 3 * * *`) | snapshot `.backup` d'Immo-Ka sur M4M64b (SSH LAN, clé gardien), rsync delta, ré-évaluation de toutes les annonces vivantes (shards = cœurs − 4), table `vp_eval` + `data/marche-stats.json`, index/FTS, bascule atomique, `pm2 restart vrai-prix` | 344 | `vrai-prix-immoka-sync` | `npm run immoka:sync` (cron `30 3 * * *`) | snapshot `.backup` d'Immo-Ka sur M4M64b (SSH LAN, clé gardien), rsync delta, ré-évaluation de toutes les annonces vivantes (shards = cœurs − 4), table `vp_eval` + `data/marche-stats.json`, index/FTS, bascule atomique, `pm2 restart vrai-prix` |
345 | `vrai-prix-cost-sync` | `npm run cost:sync` (cron `15 5 * * *`) | connecteurs de la base de coûts (APCHQ, CCQ, StatCan, Canac, BMR, Patrick Morin) → `data/cost.db` | 345 | `vrai-prix-cost-sync` | `npm run cost:sync` (cron `15 5 * * *`) | connecteurs de la base de coûts (APCHQ, CCQ, StatCan, Canac, BMR, Patrick Morin) → `data/cost.db` |
346 346
@@ -370,6 +370,7 @@ Le manifeste `mld` (`M1M32:~/dispatch/apps/vrai-prix.json`) exclut `data/`, `tmp
370 | 2026-08-13 | Vrai-Prix Maps (framework Ka Maps), SSO KA ID, SEO programmatique (sitemaps 3,7 M de fiches) | 370 | 2026-08-13 | Vrai-Prix Maps (framework Ka Maps), SSO KA ID, SEO programmatique (sitemaps 3,7 M de fiches) |
371 | 2026-08-17 | harmonisation Groupe KA (ka-ui, footer commun) + API publique `/api/stats` + assets OG | 371 | 2026-08-17 | harmonisation Groupe KA (ka-ui, footer commun) + API publique `/api/stats` + assets OG |
372 | 2026-08-19 | stats v2 (`/stats` ultra complet + 5 rapports PDF), KA Agent v2 plein écran, socle mobile | 372 | 2026-08-19 | stats v2 (`/stats` ultra complet + 5 rapports PDF), KA Agent v2 plein écran, socle mobile |
373+| 2026-10-20 | **registre des ventes récentes** : page [`/ventes`](https://www.vrai-prix.com/ventes) (filtres municipalité / type / prix / période, pagination, sans JS requis), API `/api/ventes`, section « Dernières ventes » sur chaque page municipalité, lien dans la navigation ; connecteur JdQ/JdM : jeton externe `QUB_TOKEN` + repli Safari/AppleScript depuis le laptop |
373 | 2026-08-22 | ré-estimation hédonique sur le nœud (terrain/autre + recalibrage P10/P90), connecteur « nouvelles ventes » JdM, upgrade apparence (contexte marché, fourchette visuelle) | 374 | 2026-08-22 | ré-estimation hédonique sur le nœud (terrain/autre + recalibrage P10/P90), connecteur « nouvelles ventes » JdM, upgrade apparence (contexte marché, fourchette visuelle) |
374 | 2026-08-23/24 | stats v3 (rapports PDF personnalisés), favoris « Mon univers Ka », page `/doc` + PDF, README v3 | 375 | 2026-08-23/24 | stats v3 (rapports PDF personnalisés), favoris « Mon univers Ka », page `/doc` + PDF, README v3 |
375 | 2026-08-25 | refonte front-end v2 « terminal éditorial », KA Tabbar v1, widget ka-agent v4, galerie WebP mobile+desktop | 376 | 2026-08-25 | refonte front-end v2 « terminal éditorial », KA Tabbar v1, widget ka-agent v4, galerie WebP mobile+desktop |
modified scripts/ingest-jdm.mjs +12 −5
@@ -19,10 +19,13 @@
19 // garde la base à jour sans doublon. L'app lit le .db en WAL, les nouvelles 19 // garde la base à jour sans doublon. L'app lit le .db en WAL, les nouvelles
20 // lignes sont visibles immédiatement, sans redémarrage. 20 // lignes sont visibles immédiatement, sans redémarrage.
21 // 21 //
22−// Env : SCRAPFLY_KEY, QUB_EMAIL, QUB_PASSWORD (login), VRAIPRIX_DB (optionnel). 22+// Env : SCRAPFLY_KEY, QUB_EMAIL, QUB_PASSWORD (login), VRAIPRIX_DB (optionnel),
23+// QUB_TOKEN (jeton Bearer déjà obtenu — p. ex. scripts/qub-token-safari.sh
24+// depuis le laptop — court-circuite le login Scrapfly).
23 // Args : --since=YYYY-MM-DD date plancher (défaut : max(date en base) - 45 j) 25 // Args : --since=YYYY-MM-DD date plancher (défaut : max(date en base) - 45 j)
24 // --region="Nom" ne traiter que ce(s) secteur(s) (sous-chaîne) 26 // --region="Nom" ne traiter que ce(s) secteur(s) (sous-chaîne)
25 // --dry-run n'écrit rien, affiche seulement le décompte 27 // --dry-run n'écrit rien, affiche seulement le décompte
28+// --token=JWT idem QUB_TOKEN
26 // --concurrency=N requêtes /map simultanées (défaut 6) 29 // --concurrency=N requêtes /map simultanées (défaut 6)
27 30
28 import Database from 'better-sqlite3'; 31 import Database from 'better-sqlite3';
@@ -218,10 +221,14 @@ async function main() {
218 console.log(`[ingest-jdm] date max en base : ${maxDate} → plancher SINCE=${SINCE}`); 221 console.log(`[ingest-jdm] date max en base : ${maxDate} → plancher SINCE=${SINCE}`);
219 if (DRY) console.log('[ingest-jdm] DRY-RUN : aucune écriture'); 222 if (DRY) console.log('[ingest-jdm] DRY-RUN : aucune écriture');
220 223
221− const token = await getQubToken({ 224+ const external = (typeof args.token === 'string' && args.token) || process.env.QUB_TOKEN || null;
222− session: process.env.QUB_SESSION || 'qub-vraiprix', 225+ if (external) console.log('[ingest-jdm] jeton QUB fourni (QUB_TOKEN / --token), login Scrapfly ignoré');
223− log: (m) => console.log('[ingest-jdm]', m), 226+ const token =
224− }); 227+ external ||
228+ (await getQubToken({
229+ session: process.env.QUB_SESSION || 'qub-vraiprix',
230+ log: (m) => console.log('[ingest-jdm]', m),
231+ }));
225 232
226 let locations = await fetchLocations(token); 233 let locations = await fetchLocations(token);
227 if (REGION) locations = locations.filter((l) => l.name.toLowerCase().includes(REGION)); 234 if (REGION) locations = locations.filter((l) => l.name.toLowerCase().includes(REGION));
added scripts/ingest-via-safari.sh +10 −0
@@ -0,0 +1,10 @@
1+#!/bin/zsh
2+# Laptop → nœud : jeton QUB via Safari (AppleScript), puis ingestion des ventes
3+# récentes sur le nœud qui héberge Vrai-Prix (sans passer par Scrapfly).
4+# scripts/ingest-via-safari.sh [--since=YYYY-MM-DD] [--region=Nom] [--dry-run]
5+set -eu
6+NODE=${VRAIPRIX_NODE:-M3U96a}
7+DIR=${VRAIPRIX_DIR:-'~/apps/vrai-prix'}
8+tok=$("$(dirname "$0")/qub-token-safari.sh")
9+[[ -n "$tok" ]] || { echo "pas de jeton" >&2; exit 1; }
10+ssh "$NODE" "cd $DIR && QUB_TOKEN='$tok' /opt/homebrew/bin/node scripts/ingest-jdm.mjs $*"
added scripts/qub-token-safari.sh +72 −0
@@ -0,0 +1,72 @@
1+#!/bin/zsh
2+# Jeton Bearer QUB (Cognito id-token, valide 1 h) obtenu en pilotant Safari par
3+# AppleScript — solution de repli (ou de secours) à scripts/qub-token.mjs, qui
4+# passe par Scrapfly et échoue parfois sur l'anti-bot de connect.qub.ca.
5+#
6+# S'exécute SUR LE LAPTOP (Safari + cliclick). Imprime le jeton sur stdout, les
7+# étapes sur stderr. Identifiants : QUB_EMAIL / QUB_PASSWORD dans l'environnement
8+# ou dans ~/.config/vrai-prix/qub.env (chmod 600).
9+#
10+# scripts/qub-token-safari.sh # → jeton
11+# scripts/ingest-via-safari.sh [--since=…] # jeton + ingestion sur le nœud
12+set -u
13+PAGE='https://www.journaldequebec.com/argent/immobilier/transactions-immobilieres'
14+[[ -f ~/.config/vrai-prix/qub.env ]] && source ~/.config/vrai-prix/qub.env
15+: "${QUB_EMAIL:?QUB_EMAIL manquant}" "${QUB_PASSWORD:?QUB_PASSWORD manquant}"
16+command -v cliclick >/dev/null || { echo "cliclick requis (brew install cliclick)" >&2; exit 2; }
17+
18+log() { print -u2 -- "[qub-token-safari] $*"; }
19+js() { local code=${1//\"/\\\"}; osascript -e "tell application \"Safari\" to do JavaScript \"$code\" in front document" 2>/dev/null; }
20+url() { osascript -e 'tell application "Safari" to return URL of front document' 2>/dev/null; }
21+
22+# jeton via /api/checklogin (XHR synchrone, même origine) — vide si non connecté
23+grab() {
24+ js '(function(){try{var x=new XMLHttpRequest();x.open("GET","/api/checklogin",false);x.send();var j=JSON.parse(x.responseText);return j.userToken||""}catch(e){return ""}})()'
25+}
26+
27+osascript -e "tell application \"Safari\"
28+ activate
29+ make new document with properties {URL:\"$PAGE\"}
30+end tell" >/dev/null
31+sleep 6
32+
33+tok=$(grab)
34+if [[ -n "$tok" ]]; then log "session déjà connectée"; print -- "$tok"; exit 0; fi
35+
36+log "non connecté : ouverture de connect.qub.ca"
37+js 'Array.from(document.querySelectorAll("a")).filter(e=>/Me connecter|Se connecter/i.test(e.innerText))[0].click()' >/dev/null
38+sleep 7
39+
40+# écran « Sélectionner un compte » → formulaire classique
41+if js 'document.body.innerText.includes("Changer de compte")' | grep -q true; then
42+ js 'Array.from(document.querySelectorAll("a")).filter(e=>/Changer de compte/i.test(e.innerText))[0].click()' >/dev/null
43+ sleep 5
44+fi
45+
46+if ! js 'String(!!document.querySelector("input[type=email]"))' | grep -q true; then
47+ log "formulaire de connexion introuvable ($(url))"; exit 3
48+fi
49+
50+# remplissage (setter natif + événements React), puis fermeture du remplissage
51+# automatique Touch ID qui s'ouvre au focus, puis VRAI clic souris sur le bouton
52+# (un click() JavaScript n'est pas honoré par le formulaire connect.qub.ca)
53+js "(function(){function set(el,v){Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set.call(el,v);el.dispatchEvent(new Event('input',{bubbles:true}));el.dispatchEvent(new Event('change',{bubbles:true}))}set(document.querySelector('input[type=email]'),'$QUB_EMAIL');set(document.querySelector('input[type=password]'),'$QUB_PASSWORD');return 'ok'})()" >/dev/null
54+sleep 1
55+osascript -e 'tell application "System Events" to key code 53' # Échap : popup Touch ID
56+sleep 1
57+pos=$(js '(function(){var b=document.querySelector("button[type=submit]");var r=b.getBoundingClientRect();return [Math.round(r.left+r.width/2),Math.round(r.top+r.height/2),window.outerHeight-window.innerHeight].join(",")})()')
58+bounds=$(osascript -e 'tell application "Safari" to get bounds of front window')
59+x0=${${(s:, :)bounds}[1]}; y0=${${(s:, :)bounds}[2]}
60+bx=${${(s:,:)pos}[1]}; by=${${(s:,:)pos}[2]}; chrome=${${(s:,:)pos}[3]}
61+osascript -e 'tell application "Safari" to activate'; sleep 0.5
62+cliclick c:$((x0 + bx)),$((y0 + chrome + by))
63+log "formulaire soumis, attente de l'OAuth…"
64+
65+for i in {1..10}; do
66+ sleep 3
67+ if [[ "$(url)" == *journaldequebec.com* ]]; then
68+ tok=$(grab)
69+ if [[ -n "$tok" ]]; then log "jeton obtenu (essai $i)"; print -- "$tok"; exit 0; fi
70+ fi
71+done
72+log "échec : $(url)"; exit 4
modified src/app/HomeClient.tsx +5 −0
@@ -372,6 +372,11 @@ export default function HomeClient({
372 ); 372 );
373 })} 373 })}
374 </div> 374 </div>
375+ <div className="mt-5">
376+ <Link href="/ventes" className="btn btn-ghost">
377+ {fr ? "Toutes les ventes récentes — registre complet" : "All recent sales — full registry"} →
378+ </Link>
379+ </div>
375 </section> 380 </section>
376 )} 381 )}
377 382
added src/app/api/ventes/route.ts +42 −0
@@ -0,0 +1,42 @@
1+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+// Registre des ventes récentes en JSON (mêmes filtres que /ventes) :
3+// GET /api/ventes?q=Québec&type=condo&min=200000&max=600000&periode=90&page=1&limit=50
4+import { NextRequest, NextResponse } from "next/server";
5+import { parseVenteFilters, ventesOverview, ventesRecentes, vsRolePct } from "@/lib/ventes";
6+
7+export function GET(req: NextRequest) {
8+ const f = parseVenteFilters(req.nextUrl.searchParams);
9+ try {
10+ const page = ventesRecentes(f);
11+ const ov = ventesOverview();
12+ return NextResponse.json(
13+ {
14+ source: "Transactions publiées — flux Journal de Québec / Journal de Montréal (api.qub.ca)",
15+ updatedTo: ov.maxDate,
16+ total: page.total,
17+ page: page.page,
18+ pages: page.pages,
19+ limit: page.limit,
20+ since: page.since,
21+ filters: page.filters,
22+ rows: page.rows.map((r) => ({
23+ id: r.id,
24+ date: r.date,
25+ amount: r.amount,
26+ street: r.street,
27+ city: r.city,
28+ propertyType: r.property_type,
29+ yearBuilt: r.year_built,
30+ floorArea: r.floor_area,
31+ landArea: r.land_area,
32+ idProvinc: r.id_provinc,
33+ valeurRole: r.valeur_role,
34+ vsRolePct: vsRolePct(r.amount, r.valeur_role),
35+ })),
36+ },
37+ { headers: { "Cache-Control": "public, max-age=300, s-maxage=600" } }
38+ );
39+ } catch (e) {
40+ return NextResponse.json({ error: (e as Error).message }, { status: 500 });
41+ }
42+}
modified src/app/municipalite/[slug]/page.tsx +62 −0
@@ -18,6 +18,7 @@ import {
18 moneyFr, 18 moneyFr,
19 numFr, 19 numFr,
20 } from "@/lib/seo"; 20 } from "@/lib/seo";
21+import { VENTE_TYPE_LABELS, dateFrLong, ventesParMunicipalite, vsRolePct } from "@/lib/ventes";
21 22
22 export const dynamic = "force-dynamic"; 23 export const dynamic = "force-dynamic";
23 24
@@ -53,6 +54,7 @@ export default async function MunicipalitePage({
53 const s = munStats(mun.nom)!; 54 const s = munStats(mun.nom)!;
54 const samples = sampleUnits(mun.nom, null, 24); 55 const samples = sampleUnits(mun.nom, null, 24);
55 const voisines = neighborMunicipalities(mun.nom, 10); 56 const voisines = neighborMunicipalities(mun.nom, 10);
57+ const ventes = ventesParMunicipalite(mun.nom, 12);
56 const url = `${SITE_URL}/municipalite/${slug}`; 58 const url = `${SITE_URL}/municipalite/${slug}`;
57 59
58 const jsonLd = { 60 const jsonLd = {
@@ -207,6 +209,66 @@ export default async function MunicipalitePage({
207 </section> 209 </section>
208 )} 210 )}
209 211
212+ {/* ---- Dernières ventes réelles (registre /ventes) ---- */}
213+ {ventes.rows.length > 0 && (
214+ <section className="mt-12">
215+ <span className="kicker">Ventes réelles</span>
216+ <h2 className="vp-display mt-3 text-[clamp(20px,3vw,30px)] font-bold uppercase tracking-[-0.02em]">
217+ Dernières ventes à {mun.nom}
218+ </h2>
219+ <p className="mt-2 text-[13.5px] text-ink-2">
220+ {numFr(ventes.n365)} ventes publiées sur 12 mois
221+ {ventes.median365 != null ? ` · prix médian ${moneyFr(ventes.median365)}` : ""}.
222+ Transactions captées chaque jour et appariées au rôle foncier.
223+ </p>
224+ <div className="mt-4 overflow-x-auto">
225+ <table className="w-full min-w-[620px] border-collapse text-[14px]">
226+ <thead>
227+ <tr className="border-b-2 border-ink text-left">
228+ <th className="klabel py-2 pr-4">Date</th>
229+ <th className="klabel py-2 pr-4">Adresse</th>
230+ <th className="klabel py-2 pr-4">Type</th>
231+ <th className="klabel py-2 pr-4 text-right">Prix de vente</th>
232+ <th className="klabel py-2 text-right">vs rôle</th>
233+ </tr>
234+ </thead>
235+ <tbody>
236+ {ventes.rows.map((v) => {
237+ const p = vsRolePct(v.amount, v.valeur_role);
238+ return (
239+ <tr key={v.id} className="border-b border-[rgba(20,24,20,0.12)]">
240+ <td className="vp-mono whitespace-nowrap py-2.5 pr-4 text-[12px] text-ink-2">{dateFrLong(v.date)}</td>
241+ <td className="py-2.5 pr-4">
242+ {v.id_provinc ? (
243+ <Link
244+ href={`/estimation/${encodeURIComponent(v.id_provinc)}`}
245+ className="font-medium underline decoration-[rgba(20,24,20,0.3)] underline-offset-2 hover:decoration-ink"
246+ >
247+ {v.street ?? "Adresse non publiée"}
248+ </Link>
249+ ) : (
250+ <span className="font-medium">{v.street ?? "Adresse non publiée"}</span>
251+ )}
252+ </td>
253+ <td className="py-2.5 pr-4 text-ink-2">{VENTE_TYPE_LABELS[v.property_type ?? ""] ?? v.property_type ?? "—"}</td>
254+ <td className="vp-display py-2.5 pr-4 text-right text-[15px] font-bold tracking-[-0.01em]">{moneyFr(v.amount)}</td>
255+ <td className={`vp-mono py-2.5 text-right ${p != null && p < 0 ? "text-[var(--danger)]" : "text-ink-2"}`}>
256+ {p == null ? "—" : `${p >= 0 ? "+" : "−"}${Math.abs(p).toFixed(0)} %`}
257+ </td>
258+ </tr>
259+ );
260+ })}
261+ </tbody>
262+ </table>
263+ </div>
264+ <p className="mt-4">
265+ <Link href={`/ventes?q=${encodeURIComponent(mun.nom)}&periode=365`} className="btn btn-ghost">
266+ Toutes les ventes à {mun.nom} →
267+ </Link>
268+ </p>
269+ </section>
270+ )}
271+
210 {/* ---- Municipalités voisines ---- */} 272 {/* ---- Municipalités voisines ---- */}
211 {voisines.length > 0 && ( 273 {voisines.length > 0 && (
212 <section className="mt-12"> 274 <section className="mt-12">
modified src/app/sitemap.ts +1 −0
@@ -15,6 +15,7 @@ export default function sitemap(): MetadataRoute.Sitemap {
15 { url: `${SITE_URL}/`, lastModified: LASTMOD, changeFrequency: "weekly", priority: 1 }, 15 { url: `${SITE_URL}/`, lastModified: LASTMOD, changeFrequency: "weekly", priority: 1 },
16 { url: `${SITE_URL}/municipalites`, lastModified: LASTMOD, changeFrequency: "monthly", priority: 0.9 }, 16 { url: `${SITE_URL}/municipalites`, lastModified: LASTMOD, changeFrequency: "monthly", priority: 0.9 },
17 { url: `${SITE_URL}/a-vendre`, lastModified: LASTMOD, changeFrequency: "daily", priority: 0.9 }, 17 { url: `${SITE_URL}/a-vendre`, lastModified: LASTMOD, changeFrequency: "daily", priority: 0.9 },
18+ { url: `${SITE_URL}/ventes`, lastModified: LASTMOD, changeFrequency: "daily", priority: 0.9 },
18 { url: `${SITE_URL}/cout`, lastModified: LASTMOD, changeFrequency: "daily", priority: 0.9 }, 19 { url: `${SITE_URL}/cout`, lastModified: LASTMOD, changeFrequency: "daily", priority: 0.9 },
19 { url: `${SITE_URL}/cout/assemblage`, lastModified: LASTMOD, changeFrequency: "weekly", priority: 0.7 }, 20 { url: `${SITE_URL}/cout/assemblage`, lastModified: LASTMOD, changeFrequency: "weekly", priority: 0.7 },
20 { url: `${SITE_URL}/carte`, lastModified: LASTMOD, changeFrequency: "monthly", priority: 0.8 }, 21 { url: `${SITE_URL}/carte`, lastModified: LASTMOD, changeFrequency: "monthly", priority: 0.8 },
added src/app/ventes/page.tsx +322 −0
@@ -0,0 +1,322 @@
1+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+/**
3+ * /ventes — registre des ventes récentes au Québec.
4+ * Source : transactions publiées (flux « Transactions immobilières » du Journal
5+ * de Québec / Journal de Montréal, api.qub.ca), ingérées chaque jour par le
6+ * connecteur scripts/ingest-jdm.mjs et appariées à leur unité d'évaluation.
7+ * Filtres en formulaire GET (fonctionne sans JavaScript), pagination par liens.
8+ */
9+import type { Metadata } from "next";
10+import Link from "next/link";
11+import {
12+ PERIODE_LABELS,
13+ VENTE_PERIODES,
14+ VENTE_TYPES,
15+ VENTE_TYPE_LABELS,
16+ dateFrLong,
17+ parseVenteFilters,
18+ ventesOverview,
19+ ventesRecentes,
20+ vsRolePct,
21+} from "@/lib/ventes";
22+import { munBySlug } from "@/lib/municipalites";
23+import { SITE_URL, moneyFr, numFr, slugify } from "@/lib/seo";
24+
25+export const dynamic = "force-dynamic";
26+
27+export const metadata: Metadata = {
28+ title: "Ventes récentes au Québec — registre des transactions immobilières",
29+ description:
30+ "Les dernières ventes immobilières publiées au Québec, mises à jour chaque jour : prix de vente, adresse, type, écart avec l'évaluation municipale. Filtrez par municipalité, type et prix ; chaque vente mène à l'estimation Vrai-Prix de la propriété.",
31+ alternates: { canonical: "/ventes" },
32+ openGraph: {
33+ type: "website",
34+ title: "Ventes récentes au Québec — registre des transactions immobilières",
35+ description:
36+ "Les dernières ventes immobilières publiées au Québec, mises à jour chaque jour, appariées au rôle d'évaluation.",
37+ url: "/ventes",
38+ },
39+};
40+
41+type SP = Record<string, string | string[] | undefined>;
42+
43+function hrefWith(sp: SP, patch: Record<string, string | number | undefined>): string {
44+ const u = new URLSearchParams();
45+ for (const [k, v] of Object.entries(sp)) {
46+ const s = Array.isArray(v) ? v[0] : v;
47+ if (s) u.set(k, s);
48+ }
49+ for (const [k, v] of Object.entries(patch)) {
50+ if (v === undefined || v === "" || v === null) u.delete(k);
51+ else u.set(k, String(v));
52+ }
53+ const qs = u.toString();
54+ return qs ? `/ventes?${qs}` : "/ventes";
55+}
56+
57+function pct(p: number | null): string {
58+ if (p == null) return "—";
59+ return `${p >= 0 ? "+" : "−"}${Math.abs(p).toFixed(0)} %`;
60+}
61+
62+export default async function VentesPage({ searchParams }: { searchParams: Promise<SP> }) {
63+ const sp = await searchParams;
64+ const f = parseVenteFilters(sp);
65+ const page = ventesRecentes(f);
66+ const ov = ventesOverview();
67+ const url = `${SITE_URL}/ventes`;
68+
69+ const jsonLd = {
70+ "@context": "https://schema.org",
71+ "@graph": [
72+ {
73+ "@type": "Dataset",
74+ "@id": `${url}#dataset`,
75+ name: "Ventes immobilières récentes au Québec",
76+ description:
77+ "Transactions immobilières publiées au Québec, appariées aux unités d'évaluation du rôle foncier (MAMH).",
78+ url,
79+ isAccessibleForFree: true,
80+ creator: { "@type": "Organization", name: "Vrai-Prix", url: SITE_URL },
81+ temporalCoverage: ov.maxDate ? `2021-01-01/${ov.maxDate}` : undefined,
82+ spatialCoverage: { "@type": "Place", name: "Québec, Canada" },
83+ },
84+ {
85+ "@type": "BreadcrumbList",
86+ itemListElement: [
87+ { "@type": "ListItem", position: 1, name: "Accueil", item: SITE_URL },
88+ { "@type": "ListItem", position: 2, name: "Ventes récentes", item: url },
89+ ],
90+ },
91+ ],
92+ };
93+
94+ const tiles: [string, string][] = [
95+ ["Ventes · 30 jours", numFr(ov.n30)],
96+ ["Ventes · 90 jours", numFr(ov.n90)],
97+ ["Prix médian · 30 jours", moneyFr(ov.median30)],
98+ ["Dernière vente captée", ov.maxDate ? dateFrLong(ov.maxDate) : "—"],
99+ ];
100+
101+ const first = page.total ? (page.page - 1) * page.limit + 1 : 0;
102+ const last = Math.min(page.total, page.page * page.limit);
103+ const filtered = !!(f.q || f.type || f.min != null || f.max != null);
104+
105+ return (
106+ <div className="pb-6 pt-10 sm:pt-14">
107+ <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
108+ <nav className="vp-mono text-[11px] uppercase tracking-[0.08em] text-ink-2">
109+ <Link href="/" className="hover:underline">
110+ Accueil
111+ </Link>{" "}
112+ / <span className="text-ink">Ventes récentes</span>
113+ </nav>
114+
115+ <section className="mt-6">
116+ <span className="kicker">Registre · ventes réelles</span>
117+ <h1 className="vp-display mt-3 max-w-4xl text-[clamp(28px,5.4vw,56px)] font-bold uppercase leading-[1.0] tracking-[-0.03em]">
118+ Les dernières ventes <span className="hl">au Québec</span>
119+ </h1>
120+ <p className="mt-4 max-w-2xl text-[15.5px] text-ink-2">
121+ Transactions immobilières publiées, captées chaque jour et appariées
122+ automatiquement à leur unité d&apos;évaluation. Chaque vente mène à
123+ l&apos;estimation complète de la propriété — et à l&apos;écart entre
124+ le prix payé et la valeur au rôle. Il s&apos;écoule plusieurs semaines
125+ entre la signature et la publication d&apos;une vente.
126+ </p>
127+ </section>
128+
129+ {/* ---- Tuiles ---- */}
130+ <section className="mt-8 grid grid-cols-2 gap-3 lg:grid-cols-4">
131+ {tiles.map(([label, val]) => (
132+ <div key={label} className="vp-card p-4">
133+ <p className="klabel">{label}</p>
134+ <p className="vp-display mt-1 break-words text-[clamp(17px,2.2vw,26px)] font-bold tracking-[-0.02em]">
135+ {val}
136+ </p>
137+ </div>
138+ ))}
139+ </section>
140+
141+ {ov.byType30.length > 0 && (
142+ <ul className="mt-4 flex flex-wrap gap-x-5 gap-y-2">
143+ {ov.byType30.map((t) => (
144+ <li key={t.type} className="stat-chip">
145+ <b>{moneyFr(t.median)}</b> {VENTE_TYPE_LABELS[t.type] ?? t.type} · {numFr(t.n)} / 30 j
146+ </li>
147+ ))}
148+ </ul>
149+ )}
150+
151+ {/* ---- Filtres (GET, sans JS) ---- */}
152+ <section className="mt-10">
153+ <span className="kicker">Filtrer</span>
154+ <form method="get" action="/ventes" className="mt-4 grid gap-x-6 gap-y-4 sm:grid-cols-2 lg:grid-cols-[1.4fr_1fr_1fr_1fr_1.2fr_auto]">
155+ <label className="block">
156+ <span className="klabel">Municipalité</span>
157+ <input
158+ type="search"
159+ name="q"
160+ defaultValue={f.q}
161+ placeholder="ex. Québec, Lévis, Gatineau"
162+ className="vp-input mt-1"
163+ autoComplete="off"
164+ />
165+ </label>
166+ <label className="block">
167+ <span className="klabel">Type</span>
168+ <select name="type" defaultValue={f.type} className="vp-input mt-1 cursor-pointer">
169+ <option value="">Tous les types</option>
170+ {VENTE_TYPES.map((t) => (
171+ <option key={t} value={t}>
172+ {VENTE_TYPE_LABELS[t]}
173+ </option>
174+ ))}
175+ </select>
176+ </label>
177+ <label className="block">
178+ <span className="klabel">Prix min ($)</span>
179+ <input name="min" defaultValue={f.min ?? ""} inputMode="numeric" placeholder="0" className="vp-input mt-1" />
180+ </label>
181+ <label className="block">
182+ <span className="klabel">Prix max ($)</span>
183+ <input name="max" defaultValue={f.max ?? ""} inputMode="numeric" placeholder="∞" className="vp-input mt-1" />
184+ </label>
185+ <label className="block">
186+ <span className="klabel">Période</span>
187+ <select name="periode" defaultValue={f.periode} className="vp-input mt-1 cursor-pointer">
188+ {Object.keys(VENTE_PERIODES).map((k) => (
189+ <option key={k} value={k}>
190+ {PERIODE_LABELS[k]}
191+ </option>
192+ ))}
193+ </select>
194+ </label>
195+ <div className="flex items-end gap-3">
196+ <button type="submit" className="btn btn-primary">
197+ Filtrer
198+ </button>
199+ {filtered && (
200+ <Link href="/ventes" className="vp-mono text-[11px] uppercase tracking-[0.08em] text-ink-2 underline underline-offset-2">
201+ Effacer
202+ </Link>
203+ )}
204+ </div>
205+ </form>
206+ </section>
207+
208+ {/* ---- Résultats ---- */}
209+ <section className="mt-8">
210+ <p className="vp-mono text-[11px] uppercase tracking-[0.08em] text-ink-2">
211+ {page.total === 0
212+ ? "Aucune vente ne correspond à ces critères."
213+ : `${numFr(page.total)} ventes${page.since ? ` depuis le ${dateFrLong(page.since)}` : ""} · ${numFr(first)}–${numFr(last)} · page ${page.page} / ${page.pages}`}
214+ </p>
215+
216+ {page.rows.length > 0 && (
217+ <div className="mt-4 overflow-x-auto">
218+ <table className="w-full min-w-[760px] border-collapse text-[14px]">
219+ <thead>
220+ <tr className="border-b-2 border-ink text-left">
221+ <th className="klabel py-2 pr-4">Date</th>
222+ <th className="klabel py-2 pr-4">Adresse</th>
223+ <th className="klabel py-2 pr-4">Municipalité</th>
224+ <th className="klabel py-2 pr-4">Type</th>
225+ <th className="klabel py-2 pr-4 text-right">Prix de vente</th>
226+ <th className="klabel py-2 pr-4 text-right">Rôle</th>
227+ <th className="klabel py-2 text-right">vs rôle</th>
228+ </tr>
229+ </thead>
230+ <tbody>
231+ {page.rows.map((r) => {
232+ const p = vsRolePct(r.amount, r.valeur_role);
233+ const slug = r.city ? slugify(r.city) : "";
234+ const munOk = slug ? !!munBySlug(slug) : false;
235+ return (
236+ <tr key={r.id} className="border-b border-[rgba(20,24,20,0.12)]">
237+ <td className="vp-mono whitespace-nowrap py-2.5 pr-4 text-[12px] text-ink-2">{dateFrLong(r.date)}</td>
238+ <td className="py-2.5 pr-4">
239+ {r.id_provinc ? (
240+ <Link
241+ href={`/estimation/${encodeURIComponent(r.id_provinc)}`}
242+ className="font-medium underline decoration-[rgba(20,24,20,0.3)] underline-offset-2 hover:decoration-ink"
243+ >
244+ {r.street ?? "Adresse non publiée"}
245+ </Link>
246+ ) : (
247+ <span className="font-medium">{r.street ?? "Adresse non publiée"}</span>
248+ )}
249+ {(r.year_built || r.floor_area) && (
250+ <span className="vp-mono ml-2 text-[10.5px] uppercase tracking-[0.05em] text-ink-3">
251+ {r.year_built ? `${r.year_built}` : ""}
252+ {r.year_built && r.floor_area ? " · " : ""}
253+ {r.floor_area ? `${Math.round(r.floor_area)} m²` : ""}
254+ </span>
255+ )}
256+ </td>
257+ <td className="py-2.5 pr-4">
258+ {munOk ? (
259+ <Link href={`/municipalite/${slug}`} className="hover:underline">
260+ {r.city}
261+ </Link>
262+ ) : (
263+ r.city ?? "—"
264+ )}
265+ </td>
266+ <td className="py-2.5 pr-4 text-ink-2">{VENTE_TYPE_LABELS[r.property_type ?? ""] ?? r.property_type ?? "—"}</td>
267+ <td className="vp-display py-2.5 pr-4 text-right text-[15px] font-bold tracking-[-0.01em]">{moneyFr(r.amount)}</td>
268+ <td className="vp-mono py-2.5 pr-4 text-right text-ink-2">{moneyFr(r.valeur_role)}</td>
269+ <td className={`vp-mono py-2.5 text-right ${p != null && p < 0 ? "text-[var(--danger)]" : "text-ink-2"}`}>{pct(p)}</td>
270+ </tr>
271+ );
272+ })}
273+ </tbody>
274+ </table>
275+ </div>
276+ )}
277+
278+ {page.pages > 1 && (
279+ <nav className="mt-6 flex flex-wrap items-center justify-between gap-3" aria-label="Pagination">
280+ {page.page > 1 ? (
281+ <Link href={hrefWith(sp, { page: page.page - 1 })} className="btn btn-ghost" rel="prev">
282+ ← Plus récentes
283+ </Link>
284+ ) : (
285+ <span />
286+ )}
287+ <span className="vp-mono text-[11px] uppercase tracking-[0.08em] text-ink-2">
288+ page {page.page} / {page.pages}
289+ </span>
290+ {page.page < page.pages ? (
291+ <Link href={hrefWith(sp, { page: page.page + 1 })} className="btn btn-ghost" rel="next">
292+ Plus anciennes →
293+ </Link>
294+ ) : (
295+ <span />
296+ )}
297+ </nav>
298+ )}
299+ </section>
300+
301+ <section className="mt-12 max-w-3xl text-[13.5px] text-ink-2">
302+ <span className="kicker">À propos de ces données</span>
303+ <p className="mt-3">
304+ Les ventes proviennent des transactions publiées au registre foncier et
305+ reprises par le flux « Transactions immobilières » du Journal de Québec
306+ et du Journal de Montréal (Québecor). Chaque vente est captée
307+ quotidiennement, puis appariée à l&apos;unité d&apos;évaluation la plus
308+ proche du rôle foncier (MAMH) : l&apos;écart « vs rôle » compare le prix
309+ payé à la valeur inscrite au rôle en vigueur. Les regroupements de types
310+ suivent ceux de la source : détaché, jumelé et en rangée forment
311+ « Unifamiliale » ; « Autre » englobe les terrains, les condos neufs et
312+ les ventes non classées. Données indicatives : consultez un
313+ professionnel avant toute décision, et la{" "}
314+ <Link href="/methodologie" className="underline">
315+ méthodologie
316+ </Link>{" "}
317+ pour les limites connues.
318+ </p>
319+ </section>
320+ </div>
321+ );
322+}
modified src/components/FooterNav.tsx +3 −0
@@ -15,6 +15,9 @@ export default function FooterNav() {
15 <Link href="/a-vendre" className={cls}> 15 <Link href="/a-vendre" className={cls}>
16 {fr ? "À vendre" : "For sale"} 16 {fr ? "À vendre" : "For sale"}
17 </Link> 17 </Link>
18+ <Link href="/ventes" className={cls}>
19+ {fr ? "Ventes récentes" : "Recent sales"}
20+ </Link>
18 <Link href="/cout" className={cls}> 21 <Link href="/cout" className={cls}>
19 {fr ? "Méthode du coût" : "Cost approach"} 22 {fr ? "Méthode du coût" : "Cost approach"}
20 </Link> 23 </Link>
modified src/components/HeaderNav.tsx +1 −0
@@ -30,6 +30,7 @@ export default function HeaderNav() {
30 const items = [ 30 const items = [
31 { href: "/", label: fr ? "Estimer" : "Estimate" }, 31 { href: "/", label: fr ? "Estimer" : "Estimate" },
32 { href: "/a-vendre", label: fr ? "À vendre" : "For sale" }, 32 { href: "/a-vendre", label: fr ? "À vendre" : "For sale" },
33+ { href: "/ventes", label: fr ? "Ventes" : "Sales" },
33 { href: "/cout", label: fr ? "Coût" : "Cost" }, 34 { href: "/cout", label: fr ? "Coût" : "Cost" },
34 { href: "/carte", label: fr ? "Carte" : "Map" }, 35 { href: "/carte", label: fr ? "Carte" : "Map" },
35 { href: "/ka", label: fr ? "Ka · IA" : "Ka · AI" }, 36 { href: "/ka", label: fr ? "Ka · IA" : "Ka · AI" },
modified src/components/MobileTabBar.tsx +1 −1
@@ -61,7 +61,7 @@ export default function MobileTabBar() {
61 label: fr ? "Marché" : "Market", 61 label: fr ? "Marché" : "Market",
62 icon: "trendup", 62 icon: "trendup",
63 to: "/stats", 63 to: "/stats",
64− active: path.startsWith("/stats") || path.startsWith("/municipalite"), 64+ active: path.startsWith("/stats") || path.startsWith("/municipalite") || path.startsWith("/ventes"),
65 }, 65 },
66 { 66 {
67 label: fr ? "Coût" : "Cost", 67 label: fr ? "Coût" : "Cost",
modified src/lib/db.ts +2 −0
@@ -21,6 +21,8 @@ export function getDb(): Database.Database {
21 CREATE INDEX IF NOT EXISTS idx_tx_recent 21 CREATE INDEX IF NOT EXISTS idx_tx_recent
22 ON transactions(date DESC, amount DESC) 22 ON transactions(date DESC, amount DESC)
23 WHERE id_provinc IS NOT NULL AND street IS NOT NULL AND amount >= 50000; 23 WHERE id_provinc IS NOT NULL AND street IS NOT NULL AND amount >= 50000;
24+ CREATE INDEX IF NOT EXISTS idx_tx_city_date
25+ ON transactions(city COLLATE NOCASE, date DESC, amount DESC);
24 `); 26 `);
25 } 27 }
26 return db; 28 return db;
added src/lib/ventes.ts +259 −0
@@ -0,0 +1,259 @@
1+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+// Registre des ventes récentes : lecture de la table `transactions`, alimentée
3+// en continu par le connecteur scripts/ingest-jdm.mjs (flux « Transactions
4+// immobilières » du Journal de Québec / Journal de Montréal — api.qub.ca,
5+// même feed pour les deux quotidiens). Chaque vente est appariée à son unité
6+// d'évaluation (id_provinc) par le connecteur : le registre mène donc à la
7+// fiche d'estimation de la propriété.
8+import { getDb } from "./db";
9+
10+export const VENTE_TYPES = ["unifamilial", "condo", "plex", "indéterminé"] as const;
11+export type VenteType = (typeof VENTE_TYPES)[number];
12+
13+/** Libellés français des types de la source (regroupement du rôle, cf. méthodologie JdQ). */
14+export const VENTE_TYPE_LABELS: Record<string, string> = {
15+ unifamilial: "Unifamiliale",
16+ condo: "Condo",
17+ plex: "Plex",
18+ indéterminé: "Autre",
19+};
20+
21+/** Périodes proposées (jours) ; `all` = tout le registre. */
22+export const VENTE_PERIODES: Record<string, number | null> = {
23+ "30": 30,
24+ "90": 90,
25+ "365": 365,
26+ all: null,
27+};
28+export const PERIODE_LABELS: Record<string, string> = {
29+ "30": "30 derniers jours",
30+ "90": "90 derniers jours",
31+ "365": "12 derniers mois",
32+ all: "Tout le registre (depuis 2021)",
33+};
34+
35+export const VENTES_PAGE_SIZE = 50;
36+const MAX_PAGE_SIZE = 200;
37+
38+export interface VenteFilters {
39+ /** municipalité (nom exact, insensible à la casse) ou début de nom */
40+ q: string;
41+ type: string;
42+ min: number | null;
43+ max: number | null;
44+ periode: string;
45+ page: number;
46+ limit: number;
47+}
48+
49+export interface VenteRow {
50+ id: string;
51+ date: string;
52+ amount: number;
53+ street: string | null;
54+ city: string | null;
55+ property_type: string | null;
56+ year_built: number | null;
57+ floor_area: number | null;
58+ land_area: number | null;
59+ id_provinc: string | null;
60+ valeur_role: number | null;
61+}
62+
63+export interface VentesPage {
64+ rows: VenteRow[];
65+ total: number;
66+ page: number;
67+ pages: number;
68+ limit: number;
69+ since: string | null;
70+ filters: VenteFilters;
71+}
72+
73+export interface VentesOverview {
74+ maxDate: string | null;
75+ n30: number;
76+ n90: number;
77+ n365: number;
78+ median30: number | null;
79+ byType30: { type: string; n: number; median: number | null }[];
80+}
81+
82+function isoDaysAgo(days: number): string {
83+ const d = new Date();
84+ d.setUTCDate(d.getUTCDate() - days);
85+ return d.toISOString().slice(0, 10);
86+}
87+
88+function toInt(v: unknown, fallback: number): number {
89+ const n = typeof v === "string" ? parseInt(v.replace(/[^\d]/g, ""), 10) : Number(v);
90+ return Number.isFinite(n) && n >= 0 ? n : fallback;
91+}
92+
93+/** Normalise des paramètres d'URL (page ou API) en filtres sûrs. */
94+export function parseVenteFilters(
95+ sp: Record<string, string | string[] | undefined> | URLSearchParams
96+): VenteFilters {
97+ const get = (k: string): string => {
98+ const v = sp instanceof URLSearchParams ? sp.get(k) : sp[k];
99+ const s = Array.isArray(v) ? v[0] : v;
100+ return (s ?? "").toString().trim();
101+ };
102+ const type = get("type");
103+ const periode = get("periode");
104+ const minS = get("min");
105+ const maxS = get("max");
106+ return {
107+ q: get("q").slice(0, 80),
108+ type: (VENTE_TYPES as readonly string[]).includes(type) ? type : "",
109+ min: minS ? toInt(minS, 0) || null : null,
110+ max: maxS ? toInt(maxS, 0) || null : null,
111+ periode: periode in VENTE_PERIODES ? periode : "90",
112+ page: Math.max(1, toInt(get("page"), 1)),
113+ limit: Math.min(MAX_PAGE_SIZE, Math.max(1, toInt(get("limit"), VENTES_PAGE_SIZE))),
114+ };
115+}
116+
117+/** Clause WHERE + paramètres pour un jeu de filtres. */
118+function whereFor(f: VenteFilters): { where: string; args: (string | number)[]; since: string | null } {
119+ const conds: string[] = ["amount > 0"];
120+ const args: (string | number)[] = [];
121+ const days = VENTE_PERIODES[f.periode] ?? 90;
122+ const since = days == null ? null : isoDaysAgo(days);
123+ if (since) {
124+ conds.push("date >= ?");
125+ args.push(since);
126+ }
127+ if (f.q) {
128+ // nom exact d'abord (index city NOCASE) ; sinon début de nom
129+ const exact = (
130+ getDb()
131+ .prepare("SELECT 1 AS ok FROM transactions WHERE city = ? COLLATE NOCASE LIMIT 1")
132+ .get(f.q) as { ok: number } | undefined
133+ )?.ok;
134+ if (exact) {
135+ conds.push("city = ? COLLATE NOCASE");
136+ args.push(f.q);
137+ } else {
138+ conds.push("city LIKE ? COLLATE NOCASE");
139+ args.push(`${f.q.replace(/[%_]/g, "")}%`);
140+ }
141+ }
142+ if (f.type) {
143+ conds.push("property_type = ?");
144+ args.push(f.type);
145+ }
146+ if (f.min != null) {
147+ conds.push("amount >= ?");
148+ args.push(f.min);
149+ }
150+ if (f.max != null) {
151+ conds.push("amount <= ?");
152+ args.push(f.max);
153+ }
154+ return { where: conds.join(" AND "), args, since };
155+}
156+
157+/** Page du registre (tri : date décroissante, puis montant). */
158+export function ventesRecentes(f: VenteFilters): VentesPage {
159+ const db = getDb();
160+ const { where, args, since } = whereFor(f);
161+ const total = (
162+ db.prepare(`SELECT COUNT(*) AS n FROM transactions WHERE ${where}`).get(...args) as { n: number }
163+ ).n;
164+ const pages = Math.max(1, Math.ceil(total / f.limit));
165+ const page = Math.min(f.page, pages);
166+ const rows = db
167+ .prepare(
168+ `SELECT id, date, amount, street, city, property_type, year_built, floor_area,
169+ land_area, id_provinc, valeur_role
170+ FROM transactions WHERE ${where}
171+ ORDER BY date DESC, amount DESC
172+ LIMIT ? OFFSET ?`
173+ )
174+ .all(...args, f.limit, (page - 1) * f.limit) as VenteRow[];
175+ return { rows, total, page, pages, limit: f.limit, since, filters: { ...f, page } };
176+}
177+
178+function medianAmount(where: string, args: (string | number)[]): number | null {
179+ const db = getDb();
180+ const n = (db.prepare(`SELECT COUNT(*) AS n FROM transactions WHERE ${where}`).get(...args) as { n: number }).n;
181+ if (!n) return null;
182+ const row = db
183+ .prepare(`SELECT amount FROM transactions WHERE ${where} ORDER BY amount LIMIT 1 OFFSET ?`)
184+ .get(...args, Math.floor(n / 2)) as { amount: number } | undefined;
185+ return row?.amount ?? null;
186+}
187+
188+let overviewCache: { at: number; value: VentesOverview } | null = null;
189+const OVERVIEW_TTL_MS = 10 * 60 * 1000;
190+
191+/** Tuiles de tête du registre (cache mémoire 10 min — le connecteur est quotidien). */
192+export function ventesOverview(): VentesOverview {
193+ if (overviewCache && Date.now() - overviewCache.at < OVERVIEW_TTL_MS) return overviewCache.value;
194+ const db = getDb();
195+ const count = (since: string) =>
196+ (db.prepare("SELECT COUNT(*) AS n FROM transactions WHERE date >= ? AND amount > 0").get(since) as { n: number }).n;
197+ const d30 = isoDaysAgo(30);
198+ const maxDate = (db.prepare("SELECT MAX(date) AS d FROM transactions").get() as { d: string | null }).d;
199+ const byType30 = (
200+ db
201+ .prepare(
202+ `SELECT property_type AS type, COUNT(*) AS n FROM transactions
203+ WHERE date >= ? AND amount > 0 AND property_type IS NOT NULL
204+ GROUP BY property_type ORDER BY n DESC`
205+ )
206+ .all(d30) as { type: string; n: number }[]
207+ ).map((t) => ({
208+ ...t,
209+ median: medianAmount("date >= ? AND amount > 0 AND property_type = ?", [d30, t.type]),
210+ }));
211+ const value: VentesOverview = {
212+ maxDate,
213+ n30: count(d30),
214+ n90: count(isoDaysAgo(90)),
215+ n365: count(isoDaysAgo(365)),
216+ median30: medianAmount("date >= ? AND amount > 0", [d30]),
217+ byType30,
218+ };
219+ overviewCache = { at: Date.now(), value };
220+ return value;
221+}
222+
223+export interface VentesMunicipalite {
224+ rows: VenteRow[];
225+ n365: number;
226+ median365: number | null;
227+}
228+
229+/** Dernières ventes d'une municipalité (nom du rôle ; la source emploie les mêmes noms à 99 %). */
230+export function ventesParMunicipalite(nom: string, limit = 12): VentesMunicipalite {
231+ const db = getDb();
232+ const rows = db
233+ .prepare(
234+ `SELECT id, date, amount, street, city, property_type, year_built, floor_area,
235+ land_area, id_provinc, valeur_role
236+ FROM transactions
237+ WHERE city = ? COLLATE NOCASE AND amount > 0
238+ ORDER BY date DESC, amount DESC LIMIT ?`
239+ )
240+ .all(nom, limit) as VenteRow[];
241+ const since = isoDaysAgo(365);
242+ const w = "city = ? COLLATE NOCASE AND amount > 0 AND date >= ?";
243+ const n365 = (db.prepare(`SELECT COUNT(*) AS n FROM transactions WHERE ${w}`).get(nom, since) as { n: number }).n;
244+ return { rows, n365, median365: n365 ? medianAmount(w, [nom, since]) : null };
245+}
246+
247+/** « 7 oct. 2026 » */
248+export function dateFrLong(iso: string): string {
249+ const d = new Date(`${iso}T12:00:00Z`);
250+ if (Number.isNaN(d.getTime())) return iso;
251+ return d.toLocaleDateString("fr-CA", { day: "numeric", month: "short", year: "numeric", timeZone: "UTC" });
252+}
253+
254+/** Écart prix de vente / valeur au rôle, en % (null si rôle absent ou aberrant). */
255+export function vsRolePct(amount: number, valeurRole: number | null): number | null {
256+ if (valeurRole == null || valeurRole <= 0) return null;
257+ const p = (amount / valeurRole - 1) * 100;
258+ return Math.abs(p) < 400 ? p : null;
259+}
260
Menu

Theme