Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1// -----------------------------------------------------------------------------2// Lou-Ka — Agrégateur de logements à louer (province de Québec)3// Auteur : Simon-Pierre Boucher — contact@spboucher.ai4// fiche/synthese.ts : synthèse DÉTERMINISTE de la fiche (aucun LLM, aucune5// donnée inventée) — trois briques réutilisées par le héro, le score,6// « En bref », l'aside desktop et le CTA sticky :7// · comparaisonPrix : position du loyer vs juste valeur (fairvalue.py),8// sinon vs médiane du Registre des loyers du même nombre de chambres ;9// · louKaScore : 0-100 = 60 % emplacement (KA Score global) + 40 % prix10// (écart à la juste valeur, 0 % → 65, −30 % → 100, +30 % → 20). Score11// PARTIEL et dit tel quel quand une composante manque ;12// · enBref : 4 à 6 constats priorisés (prix, accessibilité, air, inondation,13// coûts, TAL, chaleur, baisse de prix), chacun avec son ton et sa preuve.14// -----------------------------------------------------------------------------15import {16 AirNearby, CoutReel, FairValueDetail, Inondation, Listing, RdlNearby, TalHistory,17 fmtDist, fmtPrice, kaLabel,18} from "../api";19import { Tone, NBSP, fmtPct } from "./ui";2021export interface ComparaisonPrix {22 tone: "good" | "ok" | "high";23 pct: number; // écart signé en % (négatif = moins cher)24 label: string; // « Très bon prix » / « Dans le marché » / « Au-dessus du marché »25 court: string; // « ↓ 13 % vs juste valeur »26 base: "fv" | "rdl";27 ref: number; // valeur de référence ($)28 refLabel: string; // « juste valeur estimée » / « médiane 2 ch. du secteur »29 n?: number;30}3132export function comparaisonPrix(l: Listing, rdl: RdlNearby | null): ComparaisonPrix | null {33 if (l.price == null) return null;34 if (l.fv != null && l.fv > 0 && l.fv_verdict) {35 const pct = Math.round(((l.price - l.fv) / l.fv) * 100);36 return {37 tone: l.fv_verdict === "sous" ? "good" : l.fv_verdict === "sur" ? "high" : "ok",38 pct, base: "fv", ref: l.fv, refLabel: "juste valeur estimée",39 label: l.fv_verdict === "sous" ? "Très bon prix" : l.fv_verdict === "sur" ? "Au-dessus du marché" : "Dans le marché",40 court: `${pct < 0 ? "↓" : pct > 0 ? "↑" : "≈"} ${Math.abs(pct)}${NBSP}% vs juste valeur`,41 };42 }43 if (rdl && l.bedrooms != null) {44 const k = String(Math.round(l.bedrooms));45 const b = rdl.by_rooms?.[k];46 const ref = b && b.n >= 5 ? b.median : (rdl.median_recent ?? rdl.median);47 if (!ref) return null;48 const pct = Math.round(((l.price - ref) / ref) * 100);49 const tone = pct <= -10 ? "good" : pct <= 8 ? "ok" : "high";50 return {51 tone, pct, base: "rdl", ref, n: b && b.n >= 5 ? b.n : rdl.n,52 refLabel: b && b.n >= 5 ? `médiane ${k}${NBSP}ch. du secteur` : "médiane du secteur",53 label: tone === "good" ? "Très bon prix" : tone === "ok" ? "Dans le marché" : "Au-dessus du marché",54 court: `${pct < 0 ? "↓" : pct > 0 ? "↑" : "≈"} ${Math.abs(pct)}${NBSP}% vs loyers déclarés`,55 };56 }57 return null;58}5960/* --- Lou-Ka Score ------------------------------------------------------------- */61export interface LouKaScore {62 value: number | null; // score affiché (0-100) ou null si rien de fiable63 partial: boolean; // une composante manque64 emplacement: number | null; // KA Score global65 prix: number | null; // composante prix (0-100)66 label: string | null;67 explication: string;68}6970export function scorePrix(deviation: number | null | undefined): number | null {71 if (deviation == null) return null;72 // 0 % d'écart → 65 ; −30 % → 100 ; +30 % → 20 (borné 5-100)73 return Math.round(Math.max(5, Math.min(100, 65 - deviation * 150)));74}7576export function louKaScore(l: Listing): LouKaScore {77 const emplacement = l.kascores?.global ?? l.ks_global ?? null;78 const prix = l.fv_verdict ? scorePrix(l.fv_deviation) : null;79 let value: number | null = null;80 let partial = false;81 if (emplacement != null && prix != null) value = Math.round(emplacement * 0.6 + prix * 0.4);82 else if (emplacement != null) { value = Math.round(emplacement); partial = true; }83 else if (prix != null) { value = prix; partial = true; }84 const explication =85 "Le Lou-Ka Score combine l'emplacement (KA Score global : marche, transport, vélo, calme, " +86 "services — 60 %) et le prix (écart du loyer à la juste valeur estimée sur les annonces " +87 "comparables — 40 %). Quand une composante manque, le score est dit partiel et repose sur " +88 "la seule composante disponible. Il n'intègre ni l'état du logement ni le gestionnaire.";89 return { value, partial, emplacement: emplacement != null ? Math.round(emplacement) : null,90 prix, label: value != null ? kaLabel(value) : null, explication };91}9293/* --- En bref ------------------------------------------------------------------ */94export interface Constat { tone: Tone; titre: string; detail: string; cle: string; }9596export function enBref(l: Listing, x: {97 rdl: RdlNearby | null; air: AirNearby | null; inondation: Inondation | null;98 cout: CoutReel | null; tal: TalHistory | null; loadingRisques: boolean;99}): Constat[] {100 const out: Constat[] = [];101 const cmp = comparaisonPrix(l, x.rdl);102103 // 1. prix104 if (cmp) {105 const refTxt = `${fmtPrice(cmp.ref)} (${cmp.refLabel}${cmp.n ? `, ${cmp.n} loyers` : ""})`;106 out.push({107 cle: "prix",108 tone: cmp.tone === "good" ? "good" : cmp.tone === "high" ? "warn" : "neutral",109 titre: cmp.tone === "good" ? "Très bon prix" : cmp.tone === "high" ? "Loyer au-dessus du marché" : "Loyer dans le marché",110 detail: `${Math.abs(cmp.pct)}${NBSP}% ${cmp.pct < 0 ? "sous" : cmp.pct > 0 ? "au-dessus de" : "≈"} ${refTxt}`,111 });112 } else if (l.price != null) {113 out.push({ cle: "prix", tone: "neutral", titre: "Prix non comparé",114 detail: "Pas assez d'annonces ni de loyers déclarés comparables dans ce secteur." });115 }116117 // 2. accessibilité (KA Scores) ou proximité StatCan118 const ks = l.kascores;119 if (ks && (ks.walk != null || ks.transit != null)) {120 const g = ks.global ?? Math.max(ks.walk ?? 0, ks.transit ?? 0);121 const metro = ks.details?.transit?.station_metro_m;122 const bus = ks.details?.transit?.arret_bus_m;123 const bits: string[] = [];124 if (ks.walk != null) bits.push(`marche ${Math.round(ks.walk)}`);125 if (ks.transit != null) bits.push(`transport ${Math.round(ks.transit)}`);126 if (metro != null) bits.push(`métro à ${fmtDist(metro)}`);127 else if (bus != null) bits.push(`bus à ${fmtDist(bus)}`);128 out.push({129 cle: "acces",130 tone: g >= 70 ? "good" : g >= 45 ? "neutral" : "warn",131 titre: g >= 85 ? "Quartier exceptionnellement accessible" : g >= 70 ? "Quartier très accessible"132 : g >= 45 ? "Accessibilité moyenne" : "Secteur peu accessible sans voiture",133 detail: bits.join(" · "),134 });135 } else if (l.quartier?.proximite) {136 const p = l.quartier.proximite;137 const vals = ["prox_epicerie", "prox_transport", "prox_pharmacie", "prox_parc"]138 .map((k) => p[k]).filter((v): v is number => typeof v === "number");139 if (vals.length) {140 const m = Math.round((vals.reduce((a, b) => a + b, 0) / vals.length) * 100);141 out.push({ cle: "acces", tone: m >= 70 ? "good" : m >= 45 ? "neutral" : "warn",142 titre: m >= 70 ? "Services à proximité" : m >= 45 ? "Accessibilité moyenne" : "Peu de services à proximité",143 detail: `Indice de proximité StatCan ${m}/100 (épiceries, transport, pharmacies, parcs)` });144 }145 }146147 // 3. qualité de l'air148 if (x.air?.station) {149 const pm = x.air.mesures?.["PM2.5"];150 if (pm?.ref) {151 const r = pm.moyenne / pm.ref;152 out.push({153 cle: "air", tone: r <= 2 ? "good" : r <= 3 ? "warn" : "bad",154 titre: r <= 1 ? "Air de très bonne qualité" : r <= 2 ? "Bonne qualité de l'air" : r <= 3 ? "Qualité de l'air passable" : "Particules fines élevées",155 detail: `PM2,5 ${pm.moyenne.toLocaleString("fr-CA")}${NBSP}µg/m³ · station ${x.air.station}${x.air.distance_km != null ? ` (${x.air.distance_km.toLocaleString("fr-CA")} km)` : ""} · ${pm.annee}`,156 });157 }158 }159160 // 4. inondation161 if (x.inondation) {162 const d = x.inondation;163 if (d.statut === "en_zone")164 out.push({ cle: "inond", tone: d.severite === "eleve" ? "bad" : "warn", titre: "Adresse en zone inondable",165 detail: d.zones[0] ? `${d.zones[0].type}${d.zones[0].recurrence ? ` (${d.zones[0].recurrence})` : ""} — carte officielle BDZI` : "Cartographie officielle BDZI" });166 else if (d.statut === "a_proximite")167 out.push({ cle: "inond", tone: "warn", titre: "Zone inondable à proximité",168 detail: d.zones[0] ? `${d.zones[0].type} à ~${d.zones[0].distance_m} m` : "Selon la cartographie BDZI" });169 else if (d.statut === "hors_zone")170 out.push({ cle: "inond", tone: "good", titre: "Hors zone inondable", detail: "Secteur cartographié (BDZI, gouvernement du Québec)" });171 else172 out.push({ cle: "inond", tone: "neutral", titre: "Risque d'inondation indéterminé", detail: "Secteur non couvert par la cartographie officielle" });173 } else if (x.loadingRisques && l.lat != null) {174 out.push({ cle: "inond", tone: "neutral", titre: "Risque d'inondation", detail: "Vérification en cours…" });175 }176177 // 5. coûts : inclusions puis coût réel178 const inc = l.details?.inclusions ?? {};179 if (inc.heating && inc.electricity && inc.hot_water)180 out.push({ cle: "cout", tone: "good", titre: "Chauffage, électricité et eau chaude inclus", detail: "Aucun frais énergétique à ajouter au loyer" });181 else if (x.cout?.total_estime != null && l.price != null && x.cout.total_estime > l.price)182 out.push({ cle: "cout", tone: "neutral", titre: `Coût réel estimé ≈ ${fmtPrice(x.cout.total_estime)}${NBSP}/mois`,183 detail: `Loyer + ${x.cout.lignes.filter((li) => li.statut === "estimated" && li.montant).map((li) => li.poste.toLowerCase()).join(", ") || "frais estimés"}` });184185 // 6. TAL (seulement si décisions à l'initiative du propriétaire / éviction)186 if (x.tal?.status === "ok" && ((x.tal.contre_locataire ?? 0) > 0 || (x.tal.eviction ?? 0) > 0))187 out.push({ cle: "tal", tone: "warn", titre: `${x.tal.n} décision${(x.tal.n ?? 0) > 1 ? "s" : ""} au TAL à cette adresse`,188 detail: `${x.tal.contre_locataire ?? 0} à l'initiative du propriétaire — voir le dossier de l'immeuble` });189190 // 7. îlot de chaleur marqué191 if (l.quartier?.chaleur && l.quartier.chaleur.classe >= 8)192 out.push({ cle: "chaleur", tone: "warn", titre: "Îlot de chaleur urbain",193 detail: `Secteur parmi les plus chauds (classe ${l.quartier.chaleur.classe}/9${l.quartier.chaleur.ecart != null ? `, +${l.quartier.chaleur.ecart.toFixed(1)}${NBSP}°C` : ""}) — INSPQ` });194195 // 8. baisse de prix observée196 const hist = (l.price_history ?? []).filter((h) => h.price != null);197 if (hist.length >= 2 && hist[0].price! < hist[1].price!)198 out.push({ cle: "baisse", tone: "good", titre: "Prix en baisse",199 detail: `${fmtPrice(hist[1].price!)} → ${fmtPrice(hist[0].price!)} (${fmtPct(((hist[0].price! - hist[1].price!) / hist[1].price!) * 100)})` });200201 return out.slice(0, 6);202}203204/** Ligne résumé du logement : « 2 chambres · 4½ · disponible maintenant » */205export function ligneResume(l: Listing, dispo: string | null): string[] {206 const p: string[] = [];207 if (l.bedrooms != null) p.push(l.bedrooms === 0 ? "Studio" : `${Math.round(l.bedrooms)} chambre${l.bedrooms > 1 ? "s" : ""}`);208 if (l.unit_type && !(l.bedrooms === 0 && /studio/i.test(l.unit_type))) p.push(l.unit_type);209 if (l.area_sqft) p.push(`${Math.round(l.area_sqft).toLocaleString("fr-CA")}${NBSP}pi²`);210 if (dispo) p.push(dispo === "Maintenant" ? "disponible maintenant" : `disponible le ${dispo}`);211 return p;212}213