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>
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'évaluation. Chaque vente mène à | |
| 123 | + l'estimation complète de la propriété — et à l'écart entre | |
| 124 | + le prix payé et la valeur au rôle. Il s'écoule plusieurs semaines | |
| 125 | + entre la signature et la publication d'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'unité d'évaluation la plus | |
| 308 | + proche du rôle foncier (MAMH) : l'é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 | ||