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 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 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 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 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 341 | `vrai-prix` | `next start -H 0.0.0.0 -p 8090` | l'app Next.js — pages SSR + API |
342 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 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 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 370 | 2026-08-13 | Vrai-Prix Maps (framework Ka Maps), SSO KA ID, SEO programmatique (sitemaps 3,7 M de fiches) |
371 371 | 2026-08-17 | harmonisation Groupe KA (ka-ui, footer commun) + API publique `/api/stats` + assets OG |
372 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 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 375 | 2026-08-23/24 | stats v3 (rapports PDF personnalisés), favoris « Mon univers Ka », page `/doc` + PDF, README v3 |
375 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 19 // garde la base à jour sans doublon. L'app lit le .db en WAL, les nouvelles
20 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 25 // Args : --since=YYYY-MM-DD date plancher (défaut : max(date en base) - 45 j)
24 26 // --region="Nom" ne traiter que ce(s) secteur(s) (sous-chaîne)
25 27 // --dry-run n'écrit rien, affiche seulement le décompte
28 +// --token=JWT idem QUB_TOKEN
26 29 // --concurrency=N requêtes /map simultanées (défaut 6)
27 30
28 31 import Database from 'better-sqlite3';
@@ -218,10 +221,14 @@ async function main() {
218 221 console.log(`[ingest-jdm] date max en base : ${maxDate} → plancher SINCE=${SINCE}`);
219 222 if (DRY) console.log('[ingest-jdm] DRY-RUN : aucune écriture');
220 223
221 − const token = await getQubToken({
222 − session: process.env.QUB_SESSION || 'qub-vraiprix',
223 − log: (m) => console.log('[ingest-jdm]', m),
224 − });
224 + const external = (typeof args.token === 'string' && args.token) || process.env.QUB_TOKEN || null;
225 + if (external) console.log('[ingest-jdm] jeton QUB fourni (QUB_TOKEN / --token), login Scrapfly ignoré');
226 + const token =
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 233 let locations = await fetchLocations(token);
227 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 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 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 18 moneyFr,
19 19 numFr,
20 20 } from "@/lib/seo";
21 +import { VENTE_TYPE_LABELS, dateFrLong, ventesParMunicipalite, vsRolePct } from "@/lib/ventes";
21 22
22 23 export const dynamic = "force-dynamic";
23 24
@@ -53,6 +54,7 @@ export default async function MunicipalitePage({
53 54 const s = munStats(mun.nom)!;
54 55 const samples = sampleUnits(mun.nom, null, 24);
55 56 const voisines = neighborMunicipalities(mun.nom, 10);
57 + const ventes = ventesParMunicipalite(mun.nom, 12);
56 58 const url = `${SITE_URL}/municipalite/${slug}`;
57 59
58 60 const jsonLd = {
@@ -207,6 +209,66 @@ export default async function MunicipalitePage({
207 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 272 {/* ---- Municipalités voisines ---- */}
211 273 {voisines.length > 0 && (
212 274 <section className="mt-12">
modified src/app/sitemap.ts +1 −0
@@ -15,6 +15,7 @@ export default function sitemap(): MetadataRoute.Sitemap {
15 15 { url: `${SITE_URL}/`, lastModified: LASTMOD, changeFrequency: "weekly", priority: 1 },
16 16 { url: `${SITE_URL}/municipalites`, lastModified: LASTMOD, changeFrequency: "monthly", priority: 0.9 },
17 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 19 { url: `${SITE_URL}/cout`, lastModified: LASTMOD, changeFrequency: "daily", priority: 0.9 },
19 20 { url: `${SITE_URL}/cout/assemblage`, lastModified: LASTMOD, changeFrequency: "weekly", priority: 0.7 },
20 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 15 <Link href="/a-vendre" className={cls}>
16 16 {fr ? "À vendre" : "For sale"}
17 17 </Link>
18 + <Link href="/ventes" className={cls}>
19 + {fr ? "Ventes récentes" : "Recent sales"}
20 + </Link>
18 21 <Link href="/cout" className={cls}>
19 22 {fr ? "Méthode du coût" : "Cost approach"}
20 23 </Link>
modified src/components/HeaderNav.tsx +1 −0
@@ -30,6 +30,7 @@ export default function HeaderNav() {
30 30 const items = [
31 31 { href: "/", label: fr ? "Estimer" : "Estimate" },
32 32 { href: "/a-vendre", label: fr ? "À vendre" : "For sale" },
33 + { href: "/ventes", label: fr ? "Ventes" : "Sales" },
33 34 { href: "/cout", label: fr ? "Coût" : "Cost" },
34 35 { href: "/carte", label: fr ? "Carte" : "Map" },
35 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 61 label: fr ? "Marché" : "Market",
62 62 icon: "trendup",
63 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 67 label: fr ? "Coût" : "Cost",
modified src/lib/db.ts +2 −0
@@ -21,6 +21,8 @@ export function getDb(): Database.Database {
21 21 CREATE INDEX IF NOT EXISTS idx_tx_recent
22 22 ON transactions(date DESC, amount DESC)
23 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 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