[ka2] snapshot pré-mission kangalou
7 changed files +670 −0
modified
frontend/src/api.ts
+146 −0
@@ -118,6 +118,10 @@ export interface Listing { | ||
| 118 | 118 | ks_services?: number | null; |
| 119 | 119 | ks_global?: number | null; |
| 120 | 120 | kascores?: KaScores | null; // détail complet (fiche seulement) |
| 121 | + // dossier d'intelligence locative (fiche seulement) | |
| 122 | + building_key?: string | null; | |
| 123 | + immeuble?: Immeuble | null; // passeport de l'immeuble (précalculé) | |
| 124 | + hiver?: Hiver | null; // vie quotidienne en hiver (0-100) | |
| 121 | 125 | } |
| 122 | 126 | |
| 123 | 127 | // --- KA Scores — famille de scores maison Lou-Ka (louka/kascores.py) -------- |
@@ -264,6 +268,148 @@ export const fetchTal = (address: string, city?: string | null) => | ||
| 264 | 268 | get<TalHistory>(`/api/tal?address=${encodeURIComponent(address)}` + |
| 265 | 269 | (city ? `&city=${encodeURIComponent(city)}` : "")); |
| 266 | 270 | |
| 271 | +// --- Dossier d'intelligence locative ---------------------------------------- | |
| 272 | +// Chaque donnée porte son statut : observed (vu par les crawls), calculated | |
| 273 | +// (calcul déterministe), estimated (estimation sourcée), inferred (inférence | |
| 274 | +// explicable), unknown (dit tel quel — jamais inventé). | |
| 275 | + | |
| 276 | +export type Statut = "observed" | "calculated" | "estimated" | "inferred" | "included" | "unknown"; | |
| 277 | + | |
| 278 | +export interface CoutReelLigne { | |
| 279 | + poste: string; | |
| 280 | + statut: Statut; | |
| 281 | + montant: number | null; | |
| 282 | + source?: string | null; | |
| 283 | + note?: string | null; | |
| 284 | +} | |
| 285 | + | |
| 286 | +export interface CoutReel { | |
| 287 | + uid: string; | |
| 288 | + loyer: number | null; | |
| 289 | + lignes: CoutReelLigne[]; | |
| 290 | + total_estime: number | null; | |
| 291 | + annuel_estime: number | null; | |
| 292 | + postes_inconnus: string[]; | |
| 293 | + total_pi2?: number; | |
| 294 | + pi2?: { | |
| 295 | + valeur: number; | |
| 296 | + percentile_ville?: number; n_ville?: number; portee_ville?: string; | |
| 297 | + percentile_secteur?: number; n_secteur?: number; | |
| 298 | + percentile_note?: string; | |
| 299 | + } | null; | |
| 300 | + methode: string; | |
| 301 | +} | |
| 302 | + | |
| 303 | +export const fetchCoutReel = (uid: string) => | |
| 304 | + get<CoutReel>(`/api/listings/${encodeURIComponent(uid)}/cout-reel`); | |
| 305 | + | |
| 306 | +export interface HistoriqueLouka { | |
| 307 | + premiere_observation: number | null; | |
| 308 | + derniere_observation: number | null; | |
| 309 | + active: boolean; | |
| 310 | + jours_en_ligne: number; | |
| 311 | + prix_initial: number | null; | |
| 312 | + prix_actuel: number | null; | |
| 313 | + variation: number | null; | |
| 314 | + modifications: number; | |
| 315 | + timeline: { | |
| 316 | + ts: number; type: string; statut: string; | |
| 317 | + prix?: number | null; prix_avant?: number | null; | |
| 318 | + avant?: unknown; apres?: unknown; | |
| 319 | + }[]; | |
| 320 | + methode: string; | |
| 321 | +} | |
| 322 | + | |
| 323 | +export const fetchHistorique = (uid: string) => | |
| 324 | + get<HistoriqueLouka>(`/api/listings/${encodeURIComponent(uid)}/historique`); | |
| 325 | + | |
| 326 | +export interface Recyclees { | |
| 327 | + matches: { | |
| 328 | + uid: string; source: string; prix: number | null; | |
| 329 | + unit_type: string | null; | |
| 330 | + derniere_observation: number | null; premiere_observation: number | null; | |
| 331 | + confiance: number; signaux: string[]; | |
| 332 | + }[]; | |
| 333 | + statut: string; | |
| 334 | + methode: string; | |
| 335 | +} | |
| 336 | + | |
| 337 | +export const fetchRecyclees = (uid: string) => | |
| 338 | + get<Recyclees>(`/api/listings/${encodeURIComponent(uid)}/recyclees`); | |
| 339 | + | |
| 340 | +export interface Immeuble { | |
| 341 | + bkey: string; | |
| 342 | + address: string | null; | |
| 343 | + city: string | null; | |
| 344 | + computed_at: number; | |
| 345 | + annonces_total: number; | |
| 346 | + annonces_actives: number; | |
| 347 | + annonces_30j: number; | |
| 348 | + annonces_90j: number; | |
| 349 | + annonces_12m: number; | |
| 350 | + unites_identifiees: number; | |
| 351 | + unites_estimees: number; | |
| 352 | + loyer_median: number | null; | |
| 353 | + loyer_median_par_cc: Record<string, number> | null; | |
| 354 | + pi2_median: number | null; | |
| 355 | + pression_loyers: { | |
| 356 | + variation_12m: number; mediane_12m: number; mediane_12_24m: number; | |
| 357 | + n_12m: number; n_12_24m: number; statut: string; | |
| 358 | + } | null; | |
| 359 | + rotation: { | |
| 360 | + statut: string; classe?: string; ratio?: number; | |
| 361 | + annonces_12m?: number; unites_estimees?: number; | |
| 362 | + observation_jours?: number; methode?: string; | |
| 363 | + }; | |
| 364 | + gestionnaires: string[]; | |
| 365 | + sources: Record<string, number>; | |
| 366 | + premiere_observation: number; | |
| 367 | + derniere_observation: number; | |
| 368 | + unites_actives: { uid: string; unit_type: string | null; price: number | null; bedrooms: number | null }[]; | |
| 369 | +} | |
| 370 | + | |
| 371 | +export interface Hiver { | |
| 372 | + score: number; | |
| 373 | + classe: string; | |
| 374 | + detail: { critere: string; score: number; distance_m: number | null; minutes?: number; nom?: string | null; note?: string }[]; | |
| 375 | + statut: string; | |
| 376 | + methode: string; | |
| 377 | +} | |
| 378 | + | |
| 379 | +export interface Gestionnaire { | |
| 380 | + source_id: string; | |
| 381 | + nom: string | null; | |
| 382 | + site_web: string | null; | |
| 383 | + telephone: string | null; | |
| 384 | + annonces_actives: number; | |
| 385 | + google_maps?: { | |
| 386 | + statut: string; | |
| 387 | + nom?: string; adresse?: string; note?: number | null; | |
| 388 | + nombre_avis?: number | null; confiance_association?: number; | |
| 389 | + signaux?: Record<string, unknown>; | |
| 390 | + methode?: string; note_methode?: string; | |
| 391 | + }; | |
| 392 | + avis?: { | |
| 393 | + n: number; moyenne?: number; distribution?: Record<string, number>; | |
| 394 | + pct_negatif?: number; pct_positif?: number; | |
| 395 | + moyenne_12m?: number; n_12m?: number; | |
| 396 | + tendance?: string; tendance_delta?: number; | |
| 397 | + avec_reponse_proprietaire?: number; | |
| 398 | + themes?: Record<string, { mentions: number; negatif: number; positif: number }>; | |
| 399 | + plaintes_frequentes?: string[]; | |
| 400 | + methode?: string; | |
| 401 | + } | null; | |
| 402 | + avis_recents?: { | |
| 403 | + note: number | null; texte: string | null; date: string | null; | |
| 404 | + auteur: string | null; reponse_proprietaire: boolean; | |
| 405 | + analyse: { topics?: string[]; sentiment?: string; severite?: string }; | |
| 406 | + }[]; | |
| 407 | + derniere_synchro?: number | null; | |
| 408 | +} | |
| 409 | + | |
| 410 | +export const fetchGestionnaire = (sourceId: string) => | |
| 411 | + get<Gestionnaire>(`/api/managers/${encodeURIComponent(sourceId)}`); | |
| 412 | + | |
| 267 | 413 | /** 250 -> « 250 m », 1240 -> « 1,2 km » */ |
| 268 | 414 | export const fmtDist = (m: number): string => |
| 269 | 415 | m < 1000 ? `${Math.round(m / 10) * 10} m` : `${(m / 1000).toFixed(1).replace(".", ",")} km`; |
added
frontend/src/components/CoutReel.tsx
+98 −0
@@ -0,0 +1,98 @@ | ||
| 1 | +// ----------------------------------------------------------------------------- | |
| 2 | +// Lou-Ka — Agrégateur de logements à louer (province de Québec) | |
| 3 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +// components/CoutReel.tsx : bloc « Coût réel mensuel » (fiche) | |
| 5 | +// Loyer + frais non inclus, ligne par ligne, chaque poste étiqueté : | |
| 6 | +// inclus (bail) / observé (source) / estimé (avec sa source) / inconnu | |
| 7 | +// (dit tel quel — jamais chiffré arbitrairement). Prix au pi² avec | |
| 8 | +// percentile réellement calculé sur les comparables. | |
| 9 | +// ----------------------------------------------------------------------------- | |
| 10 | +import { useEffect, useState } from "react"; | |
| 11 | +import { CoutReel as CoutReelT, fetchCoutReel, fmtPrice } from "../api"; | |
| 12 | + | |
| 13 | +const NBSP = " "; | |
| 14 | + | |
| 15 | +const STATUT_META: Record<string, { label: string; cls: string }> = { | |
| 16 | + included: { label: "inclus", cls: "st-included" }, | |
| 17 | + observed: { label: "observé", cls: "st-observed" }, | |
| 18 | + estimated: { label: "estimé", cls: "st-estimated" }, | |
| 19 | + unknown: { label: "inconnu", cls: "st-unknown" }, | |
| 20 | +}; | |
| 21 | + | |
| 22 | +export default function CoutReel({ uid }: { uid: string }) { | |
| 23 | + const [d, setD] = useState<CoutReelT | null>(null); | |
| 24 | + useEffect(() => { | |
| 25 | + setD(null); | |
| 26 | + fetchCoutReel(uid).then(setD).catch(() => setD(null)); | |
| 27 | + }, [uid]); | |
| 28 | + if (!d || d.loyer == null) return null; | |
| 29 | + | |
| 30 | + const pi2 = d.pi2; | |
| 31 | + return ( | |
| 32 | + <section className="f-bloc f-coutreel" id="cout-reel"> | |
| 33 | + <h2>Coût réel mensuel</h2> | |
| 34 | + <table className="cr-table"> | |
| 35 | + <tbody> | |
| 36 | + {d.lignes.map((li) => { | |
| 37 | + const m = STATUT_META[li.statut] ?? STATUT_META.unknown; | |
| 38 | + return ( | |
| 39 | + <tr key={li.poste}> | |
| 40 | + <td className="cr-poste"> | |
| 41 | + {li.poste} | |
| 42 | + <span className={`st-pill ${m.cls}`}>{m.label}</span> | |
| 43 | + </td> | |
| 44 | + <td className="cr-montant"> | |
| 45 | + {li.montant != null && li.montant > 0 && fmtPrice(li.montant)} | |
| 46 | + {li.montant === 0 && li.statut === "included" && `0${NBSP}$`} | |
| 47 | + {li.montant == null && "—"} | |
| 48 | + </td> | |
| 49 | + </tr> | |
| 50 | + ); | |
| 51 | + })} | |
| 52 | + </tbody> | |
| 53 | + {d.total_estime != null && ( | |
| 54 | + <tfoot> | |
| 55 | + <tr> | |
| 56 | + <td className="cr-poste"><b>Total estimé</b></td> | |
| 57 | + <td className="cr-montant"><b>≈{NBSP}{fmtPrice(d.total_estime)}{NBSP}/mois</b></td> | |
| 58 | + </tr> | |
| 59 | + {d.annuel_estime != null && ( | |
| 60 | + <tr className="cr-annuel"> | |
| 61 | + <td className="cr-poste">soit sur 12 mois</td> | |
| 62 | + <td className="cr-montant">≈{NBSP}{fmtPrice(d.annuel_estime)}</td> | |
| 63 | + </tr> | |
| 64 | + )} | |
| 65 | + </tfoot> | |
| 66 | + )} | |
| 67 | + </table> | |
| 68 | + {d.postes_inconnus.length > 0 && ( | |
| 69 | + <p className="cr-inconnus"> | |
| 70 | + Postes non chiffrables avec les données publiées :{" "} | |
| 71 | + {d.postes_inconnus.join(", ").toLowerCase()} — le total réel peut | |
| 72 | + être plus élevé. | |
| 73 | + </p> | |
| 74 | + )} | |
| 75 | + {pi2 && ( | |
| 76 | + <div className="cr-pi2"> | |
| 77 | + <span className="zi-badge zi-nc"> | |
| 78 | + {pi2.valeur.toFixed(2).replace(".", ",")}{NBSP}$/pi² | |
| 79 | + </span> | |
| 80 | + {pi2.percentile_secteur != null && ( | |
| 81 | + <span> | |
| 82 | + {" "}moins cher que <b>{100 - pi2.percentile_secteur}{NBSP}%</b> des{" "} | |
| 83 | + {pi2.n_secteur} logements comparables du secteur (~2{NBSP}km) | |
| 84 | + </span> | |
| 85 | + )} | |
| 86 | + {pi2.percentile_secteur == null && pi2.percentile_ville != null && ( | |
| 87 | + <span> | |
| 88 | + {" "}moins cher que <b>{100 - pi2.percentile_ville}{NBSP}%</b> des{" "} | |
| 89 | + {pi2.n_ville} comparables ({pi2.portee_ville}) | |
| 90 | + </span> | |
| 91 | + )} | |
| 92 | + {pi2.percentile_note && <span> {pi2.percentile_note}</span>} | |
| 93 | + </div> | |
| 94 | + )} | |
| 95 | + <p className="fine">{d.methode}</p> | |
| 96 | + </section> | |
| 97 | + ); | |
| 98 | +} | |
added
frontend/src/components/GestionnaireBloc.tsx
+129 −0
@@ -0,0 +1,129 @@ | ||
| 1 | +// ----------------------------------------------------------------------------- | |
| 2 | +// Lou-Ka — Agrégateur de logements à louer (province de Québec) | |
| 3 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +// components/GestionnaireBloc.tsx : bloc « Qui gère ce logement? » (fiche) | |
| 5 | +// Fiche du gestionnaire (source directe) + réputation Google : distribution | |
| 6 | +// réelle des notes, moyenne récente, tendance et thèmes récurrents calculés | |
| 7 | +// sur les avis synchronisés dans NOTRE base (louka/managers.py) — pas | |
| 8 | +// seulement la moyenne affichée par Google, et zéro appel API au chargement. | |
| 9 | +// La fiche Google n'est montrée que si l'association est confiante. | |
| 10 | +// ----------------------------------------------------------------------------- | |
| 11 | +import { useEffect, useState } from "react"; | |
| 12 | +import { Gestionnaire, fetchGestionnaire } from "../api"; | |
| 13 | + | |
| 14 | +const NBSP = " "; | |
| 15 | + | |
| 16 | +const SENT_CLS: Record<string, string> = { | |
| 17 | + "négatif": "zi-eleve", "neutre": "zi-nc", "positif": "zi-ok", | |
| 18 | +}; | |
| 19 | + | |
| 20 | +export default function GestionnaireBloc({ source }: { source: string }) { | |
| 21 | + const [d, setD] = useState<Gestionnaire | null>(null); | |
| 22 | + useEffect(() => { | |
| 23 | + setD(null); | |
| 24 | + fetchGestionnaire(source).then(setD).catch(() => setD(null)); | |
| 25 | + }, [source]); | |
| 26 | + // sans fiche Google associée, le bloc « Détails pratiques » suffit | |
| 27 | + if (!d || !d.google_maps || d.google_maps.statut === "non_associe") return null; | |
| 28 | + | |
| 29 | + const g = d.google_maps; | |
| 30 | + const avis = d.avis; | |
| 31 | + const dist = avis?.distribution; | |
| 32 | + const total = dist ? Object.values(dist).reduce((a, b) => a + b, 0) : 0; | |
| 33 | + const recents = (d.avis_recents ?? []).filter((a) => a.texte).slice(0, 3); | |
| 34 | + | |
| 35 | + return ( | |
| 36 | + <section className="f-bloc f-gest" id="gestionnaire"> | |
| 37 | + <h2>Qui gère ce logement?</h2> | |
| 38 | + <div className="gest-head"> | |
| 39 | + <div className="gest-nom">{d.nom}</div> | |
| 40 | + <div className="gest-meta"> | |
| 41 | + {d.annonces_actives}{NBSP}annonce{d.annonces_actives > 1 ? "s" : ""} active{d.annonces_actives > 1 ? "s" : ""} sur Lou-Ka | |
| 42 | + {d.site_web && ( | |
| 43 | + <> | |
| 44 | + {" · "} | |
| 45 | + <a href={d.site_web} target="_blank" rel="noopener noreferrer">site web ↗</a> | |
| 46 | + </> | |
| 47 | + )} | |
| 48 | + </div> | |
| 49 | + </div> | |
| 50 | + | |
| 51 | + {g.note != null && ( | |
| 52 | + <div className="gest-google"> | |
| 53 | + <span className="gest-note">★ {g.note.toFixed(1).replace(".", ",")}</span> | |
| 54 | + <span className="gest-navis"> | |
| 55 | + {g.nombre_avis}{NBSP}avis Google — fiche «{NBSP}{g.nom}{NBSP}» | |
| 56 | + </span> | |
| 57 | + </div> | |
| 58 | + )} | |
| 59 | + | |
| 60 | + {avis && avis.n > 0 && ( | |
| 61 | + <> | |
| 62 | + {dist && total > 0 && ( | |
| 63 | + <div className="gest-bars" aria-label="Distribution des notes (avis analysés)"> | |
| 64 | + {[5, 4, 3, 2, 1].map((n) => { | |
| 65 | + const c = dist[String(n)] ?? 0; | |
| 66 | + return ( | |
| 67 | + <div className="gest-bar" key={n}> | |
| 68 | + <span className="gb-n">{n}★</span> | |
| 69 | + <span className="gb-track"> | |
| 70 | + <span className={`gb-fill ${n <= 2 ? "neg" : n >= 4 ? "pos" : ""}`} | |
| 71 | + style={{ width: `${Math.round((100 * c) / total)}%` }} /> | |
| 72 | + </span> | |
| 73 | + <span className="gb-c">{c}</span> | |
| 74 | + </div> | |
| 75 | + ); | |
| 76 | + })} | |
| 77 | + </div> | |
| 78 | + )} | |
| 79 | + <div className="gest-stats"> | |
| 80 | + {avis.moyenne_12m != null && ( | |
| 81 | + <span>Moyenne des 12 derniers mois : <b>{avis.moyenne_12m.toFixed(1).replace(".", ",")}</b> ({avis.n_12m} avis)</span> | |
| 82 | + )} | |
| 83 | + {avis.tendance && ( | |
| 84 | + <span className={`zi-badge ${avis.tendance === "en amélioration" ? "zi-ok" : avis.tendance === "en dégradation" ? "zi-modere" : "zi-nc"}`}> | |
| 85 | + {avis.tendance} | |
| 86 | + </span> | |
| 87 | + )} | |
| 88 | + </div> | |
| 89 | + {(avis.plaintes_frequentes?.length ?? 0) > 0 && ( | |
| 90 | + <div className="gest-themes"> | |
| 91 | + <span className="k">Plaintes récurrentes dans les avis :</span>{" "} | |
| 92 | + {avis.plaintes_frequentes!.map((t) => ( | |
| 93 | + <span className="zi-badge zi-modere" key={t}>{t}</span> | |
| 94 | + ))} | |
| 95 | + </div> | |
| 96 | + )} | |
| 97 | + {recents.length > 0 && ( | |
| 98 | + <details className="gest-avis"> | |
| 99 | + <summary>Extraits d'avis récents ({recents.length})</summary> | |
| 100 | + {recents.map((a, i) => ( | |
| 101 | + <blockquote className="gest-citation" key={i}> | |
| 102 | + <span className={`zi-badge ${SENT_CLS[a.analyse.sentiment ?? ""] ?? "zi-nc"}`}> | |
| 103 | + {a.note != null ? `${a.note}★` : "—"} | |
| 104 | + </span>{" "} | |
| 105 | + {a.texte} | |
| 106 | + <footer> | |
| 107 | + {a.date ? new Date(a.date).toLocaleDateString("fr-CA", { month: "long", year: "numeric" }) : ""} | |
| 108 | + {a.reponse_proprietaire && " · le gestionnaire a répondu"} | |
| 109 | + </footer> | |
| 110 | + </blockquote> | |
| 111 | + ))} | |
| 112 | + </details> | |
| 113 | + )} | |
| 114 | + </> | |
| 115 | + )} | |
| 116 | + | |
| 117 | + <p className="fine"> | |
| 118 | + Fiche Google Maps associée automatiquement (confiance{" "} | |
| 119 | + {Math.round((g.confiance_association ?? 0) * 100)}{NBSP}% —{" "} | |
| 120 | + {g.methode}). Statistiques calculées sur les {avis?.n ?? 0} avis | |
| 121 | + synchronisés dans la base Lou-Ka | |
| 122 | + {g.nombre_avis && avis && avis.n < g.nombre_avis | |
| 123 | + ? ` (échantillon des plus récents ; Google en annonce ${g.nombre_avis})` | |
| 124 | + : ""}. Thèmes détectés par lexique — inférence indicative, pas une | |
| 125 | + lecture humaine. | |
| 126 | + </p> | |
| 127 | + </section> | |
| 128 | + ); | |
| 129 | +} | |
added
frontend/src/components/HistoriqueLouka.tsx
+126 −0
@@ -0,0 +1,126 @@ | ||
| 1 | +// ----------------------------------------------------------------------------- | |
| 2 | +// Lou-Ka — Agrégateur de logements à louer (province de Québec) | |
| 3 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +// components/HistoriqueLouka.tsx : bloc « Historique Lou-Ka » (fiche) | |
| 5 | +// Timeline des observations RÉELLES des synchronisations : changements de | |
| 6 | +// prix, description, superficie, disponibilité, inclusions, photos, | |
| 7 | +// retraits/retours. + « Vies antérieures » : annonces recyclées probables | |
| 8 | +// du même logement (score multi-signaux, inférence explicable). | |
| 9 | +// ----------------------------------------------------------------------------- | |
| 10 | +import { useEffect, useState } from "react"; | |
| 11 | +import { | |
| 12 | + HistoriqueLouka as HistoT, Recyclees, | |
| 13 | + fetchHistorique, fetchRecyclees, fmtPrice, | |
| 14 | +} from "../api"; | |
| 15 | + | |
| 16 | +const NBSP = " "; | |
| 17 | + | |
| 18 | +const fmtTs = (ts: number | null | undefined): string => | |
| 19 | + ts | |
| 20 | + ? new Date(ts * 1000).toLocaleDateString("fr-CA", | |
| 21 | + { day: "numeric", month: "short", year: "numeric" }) | |
| 22 | + : "—"; | |
| 23 | + | |
| 24 | +const EVENT_LABEL: Record<string, string> = { | |
| 25 | + prix: "Prix", | |
| 26 | + description: "Description modifiée", | |
| 27 | + superficie: "Superficie modifiée", | |
| 28 | + dispo: "Disponibilité modifiée", | |
| 29 | + inclusions: "Inclusions modifiées", | |
| 30 | + photos: "Photos modifiées", | |
| 31 | + disparition: "Annonce retirée", | |
| 32 | + reapparition: "Annonce republiée", | |
| 33 | +}; | |
| 34 | + | |
| 35 | +export default function HistoriqueLouka({ uid }: { uid: string }) { | |
| 36 | + const [d, setD] = useState<HistoT | null>(null); | |
| 37 | + const [rec, setRec] = useState<Recyclees | null>(null); | |
| 38 | + useEffect(() => { | |
| 39 | + setD(null); setRec(null); | |
| 40 | + fetchHistorique(uid).then(setD).catch(() => setD(null)); | |
| 41 | + fetchRecyclees(uid).then(setRec).catch(() => setRec(null)); | |
| 42 | + }, [uid]); | |
| 43 | + if (!d) return null; | |
| 44 | + | |
| 45 | + const variationPct = d.variation != null | |
| 46 | + ? `${d.variation > 0 ? "+" : "−"}${Math.abs(Math.round(d.variation * 100))}${NBSP}%` | |
| 47 | + : null; | |
| 48 | + const items = d.timeline.slice(0, 12); | |
| 49 | + const matches = rec?.matches ?? []; | |
| 50 | + | |
| 51 | + return ( | |
| 52 | + <section className="f-bloc f-histolk" id="historique-louka"> | |
| 53 | + <h2>Historique Lou-Ka</h2> | |
| 54 | + <div className="kv"> | |
| 55 | + <div className="cell"> | |
| 56 | + <div className="k">Suivie depuis</div> | |
| 57 | + <div className="v">{fmtTs(d.premiere_observation)}</div> | |
| 58 | + </div> | |
| 59 | + <div className="cell"> | |
| 60 | + <div className="k">En ligne</div> | |
| 61 | + <div className="v">{d.jours_en_ligne}{NBSP}jour{d.jours_en_ligne > 1 ? "s" : ""}</div> | |
| 62 | + </div> | |
| 63 | + {d.prix_initial != null && d.prix_actuel != null && d.prix_initial !== d.prix_actuel && ( | |
| 64 | + <div className="cell"> | |
| 65 | + <div className="k">Prix initial → actuel</div> | |
| 66 | + <div className="v"> | |
| 67 | + {fmtPrice(d.prix_initial)} → {fmtPrice(d.prix_actuel)} | |
| 68 | + {variationPct && ` (${variationPct})`} | |
| 69 | + </div> | |
| 70 | + </div> | |
| 71 | + )} | |
| 72 | + <div className="cell"> | |
| 73 | + <div className="k">Modifications observées</div> | |
| 74 | + <div className="v">{d.modifications}</div> | |
| 75 | + </div> | |
| 76 | + </div> | |
| 77 | + | |
| 78 | + {items.length > 0 && ( | |
| 79 | + <ul className="hl-timeline"> | |
| 80 | + {items.map((it, i) => ( | |
| 81 | + <li key={`${it.ts}-${it.type}-${i}`} | |
| 82 | + className={`hl-item hl-${it.type}`}> | |
| 83 | + <span className="hl-date">{fmtTs(it.ts)}</span> | |
| 84 | + <span className="hl-texte"> | |
| 85 | + {it.type === "prix" ? ( | |
| 86 | + it.prix_avant != null | |
| 87 | + ? <>Prix {it.prix_avant! > (it.prix ?? 0) ? "baissé" : "monté"} de {fmtPrice(it.prix_avant ?? null)} à <b>{fmtPrice(it.prix ?? null)}</b></> | |
| 88 | + : <>Premier prix observé : <b>{fmtPrice(it.prix ?? null)}</b></> | |
| 89 | + ) : ( | |
| 90 | + EVENT_LABEL[it.type] ?? it.type | |
| 91 | + )} | |
| 92 | + </span> | |
| 93 | + </li> | |
| 94 | + ))} | |
| 95 | + </ul> | |
| 96 | + )} | |
| 97 | + {items.length === 0 && ( | |
| 98 | + <p className="fine"> | |
| 99 | + Aucune modification observée depuis la première synchronisation de | |
| 100 | + cette annonce. | |
| 101 | + </p> | |
| 102 | + )} | |
| 103 | + | |
| 104 | + {matches.length > 0 && ( | |
| 105 | + <div className="hl-recyclees"> | |
| 106 | + <h3>Vies antérieures probables de ce logement</h3> | |
| 107 | + {matches.slice(0, 3).map((m) => ( | |
| 108 | + <div className="hl-match" key={m.uid}> | |
| 109 | + <div> | |
| 110 | + <span className="zi-badge zi-modere"> | |
| 111 | + republication probable ({m.confiance}{NBSP}% de confiance) | |
| 112 | + </span>{" "} | |
| 113 | + {m.prix != null && <>affiché {fmtPrice(m.prix)}</>} | |
| 114 | + {m.derniere_observation != null && <> jusqu'en {fmtTs(m.derniere_observation)}</>} | |
| 115 | + </div> | |
| 116 | + <div className="hl-signaux">{m.signaux.join(" · ")}</div> | |
| 117 | + </div> | |
| 118 | + ))} | |
| 119 | + <p className="fine">{rec?.methode} — inférence, sans fusion automatique.</p> | |
| 120 | + </div> | |
| 121 | + )} | |
| 122 | + | |
| 123 | + <p className="fine">{d.methode}</p> | |
| 124 | + </section> | |
| 125 | + ); | |
| 126 | +} | |
added
frontend/src/components/HiverScore.tsx
+43 −0
@@ -0,0 +1,43 @@ | ||
| 1 | +// ----------------------------------------------------------------------------- | |
| 2 | +// Lou-Ka — Agrégateur de logements à louer (province de Québec) | |
| 3 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +// components/HiverScore.tsx : bloc « Vie quotidienne en hiver » (fiche) | |
| 5 | +// Peut-on vivre son quotidien à pied à -20 °C? Score 0-100 déterministe | |
| 6 | +// (louka/hiver.py) : épicerie, pharmacie, bus/métro, dépanneur à distance | |
| 7 | +// de marche. La méthodologie assume ce qui n'est PAS pris en compte. | |
| 8 | +// ----------------------------------------------------------------------------- | |
| 9 | +import { Hiver } from "../api"; | |
| 10 | + | |
| 11 | +const NBSP = " "; | |
| 12 | + | |
| 13 | +const CLS: Record<string, string> = { | |
| 14 | + "très pratique": "zi-ok", "pratique": "zi-ok", | |
| 15 | + "exigeant": "zi-modere", "difficile": "zi-eleve", | |
| 16 | +}; | |
| 17 | + | |
| 18 | +export default function HiverScore({ h }: { h: Hiver }) { | |
| 19 | + return ( | |
| 20 | + <section className="f-bloc f-hiver" id="hiver"> | |
| 21 | + <h2>Vie quotidienne en hiver</h2> | |
| 22 | + <div className="hiver-head"> | |
| 23 | + <span className="hiver-score">{h.score}</span> | |
| 24 | + <span className={`zi-badge ${CLS[h.classe] ?? "zi-nc"}`}>{h.classe}</span> | |
| 25 | + <span className="hiver-sur">quotidien à pied, même à −20{NBSP}°C</span> | |
| 26 | + </div> | |
| 27 | + <ul className="hiver-detail"> | |
| 28 | + {h.detail.map((c) => ( | |
| 29 | + <li key={c.critere}> | |
| 30 | + <span className="hd-crit">{c.critere}</span> | |
| 31 | + <span className="hd-val"> | |
| 32 | + {c.distance_m != null | |
| 33 | + ? `≈${NBSP}${c.minutes}${NBSP}min à pied${c.nom ? ` (${c.nom})` : ""}` | |
| 34 | + : c.note} | |
| 35 | + </span> | |
| 36 | + <span className="hd-score">{c.score}</span> | |
| 37 | + </li> | |
| 38 | + ))} | |
| 39 | + </ul> | |
| 40 | + <p className="fine">{h.methode}</p> | |
| 41 | + </section> | |
| 42 | + ); | |
| 43 | +} | |
added
frontend/src/components/ImmeubleBloc.tsx
+113 −0
@@ -0,0 +1,113 @@ | ||
| 1 | +// ----------------------------------------------------------------------------- | |
| 2 | +// Lou-Ka — Agrégateur de logements à louer (province de Québec) | |
| 3 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +// components/ImmeubleBloc.tsx : bloc « Passeport de l'immeuble » (fiche) | |
| 5 | +// Tout est calculé sur ce que Lou-Ka observe réellement (annonces actives | |
| 6 | +// ET historiques du même bâtiment) : unités, loyers médians, rotation, | |
| 7 | +// pression sur les loyers. Un indicateur sans échantillon suffisant est | |
| 8 | +// affiché « données insuffisantes » — jamais inventé. | |
| 9 | +// ----------------------------------------------------------------------------- | |
| 10 | +import { Immeuble, fmtPrice } from "../api"; | |
| 11 | + | |
| 12 | +const NBSP = " "; | |
| 13 | + | |
| 14 | +const fmtTs = (ts: number | null | undefined): string => | |
| 15 | + ts | |
| 16 | + ? new Date(ts * 1000).toLocaleDateString("fr-CA", { month: "long", year: "numeric" }) | |
| 17 | + : "—"; | |
| 18 | + | |
| 19 | +const ROTATION_CLS: Record<string, string> = { | |
| 20 | + "faible": "zi-ok", | |
| 21 | + "normale": "zi-nc", | |
| 22 | + "élevée": "zi-modere", | |
| 23 | + "très élevée": "zi-eleve", | |
| 24 | +}; | |
| 25 | + | |
| 26 | +export default function ImmeubleBloc({ im }: { im: Immeuble }) { | |
| 27 | + // au moins 2 annonces regroupées, sinon le « passeport » n'apporte rien | |
| 28 | + if (!im || im.annonces_total < 2) return null; | |
| 29 | + const rot = im.rotation; | |
| 30 | + const pres = im.pression_loyers; | |
| 31 | + const parCc = im.loyer_median_par_cc; | |
| 32 | + | |
| 33 | + return ( | |
| 34 | + <section className="f-bloc f-immeuble" id="immeuble"> | |
| 35 | + <h2>Passeport de l'immeuble</h2> | |
| 36 | + <div className="kv"> | |
| 37 | + <div className="cell"> | |
| 38 | + <div className="k">Annonces observées</div> | |
| 39 | + <div className="v">{im.annonces_total} <small>dont {im.annonces_actives} active{im.annonces_actives > 1 ? "s" : ""}</small></div> | |
| 40 | + </div> | |
| 41 | + <div className="cell"> | |
| 42 | + <div className="k">Unités estimées</div> | |
| 43 | + <div className="v">≥{NBSP}{im.unites_estimees}</div> | |
| 44 | + </div> | |
| 45 | + {im.loyer_median != null && ( | |
| 46 | + <div className="cell"> | |
| 47 | + <div className="k">Loyer médian (actives)</div> | |
| 48 | + <div className="v">{fmtPrice(im.loyer_median)}</div> | |
| 49 | + </div> | |
| 50 | + )} | |
| 51 | + {im.pi2_median != null && ( | |
| 52 | + <div className="cell"> | |
| 53 | + <div className="k">Médiane $/pi²</div> | |
| 54 | + <div className="v">{im.pi2_median.toFixed(2).replace(".", ",")}{NBSP}$</div> | |
| 55 | + </div> | |
| 56 | + )} | |
| 57 | + </div> | |
| 58 | + | |
| 59 | + {parCc && Object.keys(parCc).length > 0 && ( | |
| 60 | + <p className="im-parcc"> | |
| 61 | + Par nombre de chambres :{" "} | |
| 62 | + {Object.entries(parCc) | |
| 63 | + .map(([cc, v]) => `${cc}${NBSP}ch. ${fmtPrice(v)}`) | |
| 64 | + .join(" · ")} | |
| 65 | + </p> | |
| 66 | + )} | |
| 67 | + | |
| 68 | + <div className="im-indicateurs"> | |
| 69 | + <div className="im-indic"> | |
| 70 | + <span className="k">Rotation des logements</span>{" "} | |
| 71 | + {rot.statut === "calculated" ? ( | |
| 72 | + <> | |
| 73 | + <span className={`zi-badge ${ROTATION_CLS[rot.classe ?? ""] ?? "zi-nc"}`}> | |
| 74 | + {rot.classe} | |
| 75 | + </span>{" "} | |
| 76 | + <span className="im-detail"> | |
| 77 | + {rot.annonces_12m} annonce{(rot.annonces_12m ?? 0) > 1 ? "s" : ""} sur | |
| 78 | + 12 mois pour ≥{NBSP}{rot.unites_estimees} unités | |
| 79 | + </span> | |
| 80 | + </> | |
| 81 | + ) : ( | |
| 82 | + <span className="zi-badge zi-nc">données insuffisantes</span> | |
| 83 | + )} | |
| 84 | + </div> | |
| 85 | + <div className="im-indic"> | |
| 86 | + <span className="k">Pression sur les loyers</span>{" "} | |
| 87 | + {pres ? ( | |
| 88 | + <> | |
| 89 | + <span className={`zi-badge ${pres.variation_12m > 0.05 ? "zi-modere" : pres.variation_12m < -0.02 ? "zi-ok" : "zi-nc"}`}> | |
| 90 | + {pres.variation_12m > 0 ? "+" : "−"} | |
| 91 | + {Math.abs(Math.round(pres.variation_12m * 100))}{NBSP}% sur 12 mois | |
| 92 | + </span>{" "} | |
| 93 | + <span className="im-detail"> | |
| 94 | + médiane des prix d'entrée : {fmtPrice(pres.mediane_12_24m)} →{" "} | |
| 95 | + {fmtPrice(pres.mediane_12m)} ({pres.n_12_24m} vs {pres.n_12m} annonces) | |
| 96 | + </span> | |
| 97 | + </> | |
| 98 | + ) : ( | |
| 99 | + <span className="zi-badge zi-nc">échantillon insuffisant</span> | |
| 100 | + )} | |
| 101 | + </div> | |
| 102 | + </div> | |
| 103 | + | |
| 104 | + <p className="fine"> | |
| 105 | + Immeuble suivi par Lou-Ka depuis {fmtTs(im.premiere_observation)} | |
| 106 | + {rot.statut === "calculated" && rot.methode ? ` — ${rot.methode}` : ( | |
| 107 | + " — indicateurs calculés uniquement sur les annonces observées par " + | |
| 108 | + "les synchronisations Lou-Ka (pas un recensement du bâtiment)." | |
| 109 | + )} | |
| 110 | + </p> | |
| 111 | + </section> | |
| 112 | + ); | |
| 113 | +} | |
modified
frontend/src/pages/Listing.tsx
+15 −0
@@ -24,6 +24,11 @@ import QualiteAir from "../components/QualiteAir"; | ||
| 24 | 24 | import EssenceProche from "../components/EssenceProche"; |
| 25 | 25 | import HydroEstimation from "../components/HydroEstimation"; |
| 26 | 26 | import CommercesProches from "../components/CommercesProches"; |
| 27 | +import CoutReel from "../components/CoutReel"; | |
| 28 | +import HistoriqueLouka from "../components/HistoriqueLouka"; | |
| 29 | +import ImmeubleBloc from "../components/ImmeubleBloc"; | |
| 30 | +import GestionnaireBloc from "../components/GestionnaireBloc"; | |
| 31 | +import HiverScore from "../components/HiverScore"; | |
| 27 | 32 | import { IcoAlert, IcoDoc } from "../components/Icons"; |
| 28 | 33 | import KaScoresBlock from "../components/KaScoresBlock"; |
| 29 | 34 | import { markSeen } from "../search/seen"; |
@@ -465,6 +470,14 @@ export default function ListingPage() { | ||
| 465 | 470 | <div className="f-col"> |
| 466 | 471 | <PriceAnalysis uid={l.uid} price={l.price} /> |
| 467 | 472 | |
| 473 | + <CoutReel uid={l.uid} /> | |
| 474 | + | |
| 475 | + <HistoriqueLouka uid={l.uid} /> | |
| 476 | + | |
| 477 | + {l.immeuble && <ImmeubleBloc im={l.immeuble} />} | |
| 478 | + | |
| 479 | + <GestionnaireBloc source={l.source} /> | |
| 480 | + | |
| 468 | 481 | <RegistreLoyers lat={l.lat} lng={l.lng} price={l.price} bedrooms={l.bedrooms} /> |
| 469 | 482 | |
| 470 | 483 | {l.lat != null && l.lng != null && ( |
@@ -494,6 +507,8 @@ export default function ListingPage() { | ||
| 494 | 507 | |
| 495 | 508 | {l.kascores && <KaScoresBlock ks={l.kascores} />} |
| 496 | 509 | |
| 510 | + {l.hiver && <HiverScore h={l.hiver} />} | |
| 511 | + | |
| 497 | 512 | <section className="f-bloc f-quartier" id="quartier"> |
| 498 | 513 | {l.quartier ? <QuartierBlock q={l.quartier} /> : null} |
| 499 | 514 | </section> |
| 500 | 515 | |