// ----------------------------------------------------------------------------- // Lou-Ka — Agrégateur de logements à louer (province de Québec) // Auteur : Simon-Pierre Boucher — contact@spboucher.ai // fiche/synthese.ts : synthèse DÉTERMINISTE de la fiche (aucun LLM, aucune // donnée inventée) — trois briques réutilisées par le héro, le score, // « En bref », l'aside desktop et le CTA sticky : // · comparaisonPrix : position du loyer vs juste valeur (fairvalue.py), // sinon vs médiane du Registre des loyers du même nombre de chambres ; // · louKaScore : 0-100 = 60 % emplacement (KA Score global) + 40 % prix // (écart à la juste valeur, 0 % → 65, −30 % → 100, +30 % → 20). Score // PARTIEL et dit tel quel quand une composante manque ; // · enBref : 4 à 6 constats priorisés (prix, accessibilité, air, inondation, // coûts, TAL, chaleur, baisse de prix), chacun avec son ton et sa preuve. // ----------------------------------------------------------------------------- import { AirNearby, CoutReel, FairValueDetail, Inondation, Listing, RdlNearby, TalHistory, fmtDist, fmtPrice, kaLabel, } from "../api"; import { Tone, NBSP, fmtPct } from "./ui"; export interface ComparaisonPrix { tone: "good" | "ok" | "high"; pct: number; // écart signé en % (négatif = moins cher) label: string; // « Très bon prix » / « Dans le marché » / « Au-dessus du marché » court: string; // « ↓ 13 % vs juste valeur » base: "fv" | "rdl"; ref: number; // valeur de référence ($) refLabel: string; // « juste valeur estimée » / « médiane 2 ch. du secteur » n?: number; } export function comparaisonPrix(l: Listing, rdl: RdlNearby | null): ComparaisonPrix | null { if (l.price == null) return null; if (l.fv != null && l.fv > 0 && l.fv_verdict) { const pct = Math.round(((l.price - l.fv) / l.fv) * 100); return { tone: l.fv_verdict === "sous" ? "good" : l.fv_verdict === "sur" ? "high" : "ok", pct, base: "fv", ref: l.fv, refLabel: "juste valeur estimée", label: l.fv_verdict === "sous" ? "Très bon prix" : l.fv_verdict === "sur" ? "Au-dessus du marché" : "Dans le marché", court: `${pct < 0 ? "↓" : pct > 0 ? "↑" : "≈"} ${Math.abs(pct)}${NBSP}% vs juste valeur`, }; } if (rdl && l.bedrooms != null) { const k = String(Math.round(l.bedrooms)); const b = rdl.by_rooms?.[k]; const ref = b && b.n >= 5 ? b.median : (rdl.median_recent ?? rdl.median); if (!ref) return null; const pct = Math.round(((l.price - ref) / ref) * 100); const tone = pct <= -10 ? "good" : pct <= 8 ? "ok" : "high"; return { tone, pct, base: "rdl", ref, n: b && b.n >= 5 ? b.n : rdl.n, refLabel: b && b.n >= 5 ? `médiane ${k}${NBSP}ch. du secteur` : "médiane du secteur", label: tone === "good" ? "Très bon prix" : tone === "ok" ? "Dans le marché" : "Au-dessus du marché", court: `${pct < 0 ? "↓" : pct > 0 ? "↑" : "≈"} ${Math.abs(pct)}${NBSP}% vs loyers déclarés`, }; } return null; } /* --- Lou-Ka Score ------------------------------------------------------------- */ export interface LouKaScore { value: number | null; // score affiché (0-100) ou null si rien de fiable partial: boolean; // une composante manque emplacement: number | null; // KA Score global prix: number | null; // composante prix (0-100) label: string | null; explication: string; } export function scorePrix(deviation: number | null | undefined): number | null { if (deviation == null) return null; // 0 % d'écart → 65 ; −30 % → 100 ; +30 % → 20 (borné 5-100) return Math.round(Math.max(5, Math.min(100, 65 - deviation * 150))); } export function louKaScore(l: Listing): LouKaScore { const emplacement = l.kascores?.global ?? l.ks_global ?? null; const prix = l.fv_verdict ? scorePrix(l.fv_deviation) : null; let value: number | null = null; let partial = false; if (emplacement != null && prix != null) value = Math.round(emplacement * 0.6 + prix * 0.4); else if (emplacement != null) { value = Math.round(emplacement); partial = true; } else if (prix != null) { value = prix; partial = true; } const explication = "Le Lou-Ka Score combine l'emplacement (KA Score global : marche, transport, vélo, calme, " + "services — 60 %) et le prix (écart du loyer à la juste valeur estimée sur les annonces " + "comparables — 40 %). Quand une composante manque, le score est dit partiel et repose sur " + "la seule composante disponible. Il n'intègre ni l'état du logement ni le gestionnaire."; return { value, partial, emplacement: emplacement != null ? Math.round(emplacement) : null, prix, label: value != null ? kaLabel(value) : null, explication }; } /* --- En bref ------------------------------------------------------------------ */ export interface Constat { tone: Tone; titre: string; detail: string; cle: string; } export function enBref(l: Listing, x: { rdl: RdlNearby | null; air: AirNearby | null; inondation: Inondation | null; cout: CoutReel | null; tal: TalHistory | null; loadingRisques: boolean; }): Constat[] { const out: Constat[] = []; const cmp = comparaisonPrix(l, x.rdl); // 1. prix if (cmp) { const refTxt = `${fmtPrice(cmp.ref)} (${cmp.refLabel}${cmp.n ? `, ${cmp.n} loyers` : ""})`; out.push({ cle: "prix", tone: cmp.tone === "good" ? "good" : cmp.tone === "high" ? "warn" : "neutral", titre: cmp.tone === "good" ? "Très bon prix" : cmp.tone === "high" ? "Loyer au-dessus du marché" : "Loyer dans le marché", detail: `${Math.abs(cmp.pct)}${NBSP}% ${cmp.pct < 0 ? "sous" : cmp.pct > 0 ? "au-dessus de" : "≈"} ${refTxt}`, }); } else if (l.price != null) { out.push({ cle: "prix", tone: "neutral", titre: "Prix non comparé", detail: "Pas assez d'annonces ni de loyers déclarés comparables dans ce secteur." }); } // 2. accessibilité (KA Scores) ou proximité StatCan const ks = l.kascores; if (ks && (ks.walk != null || ks.transit != null)) { const g = ks.global ?? Math.max(ks.walk ?? 0, ks.transit ?? 0); const metro = ks.details?.transit?.station_metro_m; const bus = ks.details?.transit?.arret_bus_m; const bits: string[] = []; if (ks.walk != null) bits.push(`marche ${Math.round(ks.walk)}`); if (ks.transit != null) bits.push(`transport ${Math.round(ks.transit)}`); if (metro != null) bits.push(`métro à ${fmtDist(metro)}`); else if (bus != null) bits.push(`bus à ${fmtDist(bus)}`); out.push({ cle: "acces", tone: g >= 70 ? "good" : g >= 45 ? "neutral" : "warn", titre: g >= 85 ? "Quartier exceptionnellement accessible" : g >= 70 ? "Quartier très accessible" : g >= 45 ? "Accessibilité moyenne" : "Secteur peu accessible sans voiture", detail: bits.join(" · "), }); } else if (l.quartier?.proximite) { const p = l.quartier.proximite; const vals = ["prox_epicerie", "prox_transport", "prox_pharmacie", "prox_parc"] .map((k) => p[k]).filter((v): v is number => typeof v === "number"); if (vals.length) { const m = Math.round((vals.reduce((a, b) => a + b, 0) / vals.length) * 100); out.push({ cle: "acces", tone: m >= 70 ? "good" : m >= 45 ? "neutral" : "warn", titre: m >= 70 ? "Services à proximité" : m >= 45 ? "Accessibilité moyenne" : "Peu de services à proximité", detail: `Indice de proximité StatCan ${m}/100 (épiceries, transport, pharmacies, parcs)` }); } } // 3. qualité de l'air if (x.air?.station) { const pm = x.air.mesures?.["PM2.5"]; if (pm?.ref) { const r = pm.moyenne / pm.ref; out.push({ cle: "air", tone: r <= 2 ? "good" : r <= 3 ? "warn" : "bad", 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", 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}`, }); } } // 4. inondation if (x.inondation) { const d = x.inondation; if (d.statut === "en_zone") out.push({ cle: "inond", tone: d.severite === "eleve" ? "bad" : "warn", titre: "Adresse en zone inondable", detail: d.zones[0] ? `${d.zones[0].type}${d.zones[0].recurrence ? ` (${d.zones[0].recurrence})` : ""} — carte officielle BDZI` : "Cartographie officielle BDZI" }); else if (d.statut === "a_proximite") out.push({ cle: "inond", tone: "warn", titre: "Zone inondable à proximité", detail: d.zones[0] ? `${d.zones[0].type} à ~${d.zones[0].distance_m} m` : "Selon la cartographie BDZI" }); else if (d.statut === "hors_zone") out.push({ cle: "inond", tone: "good", titre: "Hors zone inondable", detail: "Secteur cartographié (BDZI, gouvernement du Québec)" }); else out.push({ cle: "inond", tone: "neutral", titre: "Risque d'inondation indéterminé", detail: "Secteur non couvert par la cartographie officielle" }); } else if (x.loadingRisques && l.lat != null) { out.push({ cle: "inond", tone: "neutral", titre: "Risque d'inondation", detail: "Vérification en cours…" }); } // 5. coûts : inclusions puis coût réel const inc = l.details?.inclusions ?? {}; if (inc.heating && inc.electricity && inc.hot_water) out.push({ cle: "cout", tone: "good", titre: "Chauffage, électricité et eau chaude inclus", detail: "Aucun frais énergétique à ajouter au loyer" }); else if (x.cout?.total_estime != null && l.price != null && x.cout.total_estime > l.price) out.push({ cle: "cout", tone: "neutral", titre: `Coût réel estimé ≈ ${fmtPrice(x.cout.total_estime)}${NBSP}/mois`, detail: `Loyer + ${x.cout.lignes.filter((li) => li.statut === "estimated" && li.montant).map((li) => li.poste.toLowerCase()).join(", ") || "frais estimés"}` }); // 6. TAL (seulement si décisions à l'initiative du propriétaire / éviction) if (x.tal?.status === "ok" && ((x.tal.contre_locataire ?? 0) > 0 || (x.tal.eviction ?? 0) > 0)) out.push({ cle: "tal", tone: "warn", titre: `${x.tal.n} décision${(x.tal.n ?? 0) > 1 ? "s" : ""} au TAL à cette adresse`, detail: `${x.tal.contre_locataire ?? 0} à l'initiative du propriétaire — voir le dossier de l'immeuble` }); // 7. îlot de chaleur marqué if (l.quartier?.chaleur && l.quartier.chaleur.classe >= 8) out.push({ cle: "chaleur", tone: "warn", titre: "Îlot de chaleur urbain", 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` }); // 8. baisse de prix observée const hist = (l.price_history ?? []).filter((h) => h.price != null); if (hist.length >= 2 && hist[0].price! < hist[1].price!) out.push({ cle: "baisse", tone: "good", titre: "Prix en baisse", detail: `${fmtPrice(hist[1].price!)} → ${fmtPrice(hist[0].price!)} (${fmtPct(((hist[0].price! - hist[1].price!) / hist[1].price!) * 100)})` }); return out.slice(0, 6); } /** Ligne résumé du logement : « 2 chambres · 4½ · disponible maintenant » */ export function ligneResume(l: Listing, dispo: string | null): string[] { const p: string[] = []; if (l.bedrooms != null) p.push(l.bedrooms === 0 ? "Studio" : `${Math.round(l.bedrooms)} chambre${l.bedrooms > 1 ? "s" : ""}`); if (l.unit_type && !(l.bedrooms === 0 && /studio/i.test(l.unit_type))) p.push(l.unit_type); if (l.area_sqft) p.push(`${Math.round(l.area_sqft).toLocaleString("fr-CA")}${NBSP}pi²`); if (dispo) p.push(dispo === "Maintenant" ? "disponible maintenant" : `disponible le ${dispo}`); return p; }