SPB Git

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

[ka2] snapshot pré-mission kangalou

Simon-Pierre Boucher committed (Aug 24, 2026) parent 8efd8cb Browse files

7 changed files +670 −0

modified frontend/src/api.ts +146 −0
@@ -118,6 +118,10 @@ export interface Listing {
118 ks_services?: number | null; 118 ks_services?: number | null;
119 ks_global?: number | null; 119 ks_global?: number | null;
120 kascores?: KaScores | null; // détail complet (fiche seulement) 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 // --- KA Scores — famille de scores maison Lou-Ka (louka/kascores.py) -------- 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 get<TalHistory>(`/api/tal?address=${encodeURIComponent(address)}` + 268 get<TalHistory>(`/api/tal?address=${encodeURIComponent(address)}` +
265 (city ? `&city=${encodeURIComponent(city)}` : "")); 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 /** 250 -> « 250 m », 1240 -> « 1,2 km » */ 413 /** 250 -> « 250 m », 1240 -> « 1,2 km » */
268 export const fmtDist = (m: number): string => 414 export const fmtDist = (m: number): string =>
269 m < 1000 ? `${Math.round(m / 10) * 10} m` : `${(m / 1000).toFixed(1).replace(".", ",")} km`; 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 import EssenceProche from "../components/EssenceProche"; 24 import EssenceProche from "../components/EssenceProche";
25 import HydroEstimation from "../components/HydroEstimation"; 25 import HydroEstimation from "../components/HydroEstimation";
26 import CommercesProches from "../components/CommercesProches"; 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 import { IcoAlert, IcoDoc } from "../components/Icons"; 32 import { IcoAlert, IcoDoc } from "../components/Icons";
28 import KaScoresBlock from "../components/KaScoresBlock"; 33 import KaScoresBlock from "../components/KaScoresBlock";
29 import { markSeen } from "../search/seen"; 34 import { markSeen } from "../search/seen";
@@ -465,6 +470,14 @@ export default function ListingPage() {
465 <div className="f-col"> 470 <div className="f-col">
466 <PriceAnalysis uid={l.uid} price={l.price} /> 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 <RegistreLoyers lat={l.lat} lng={l.lng} price={l.price} bedrooms={l.bedrooms} /> 481 <RegistreLoyers lat={l.lat} lng={l.lng} price={l.price} bedrooms={l.bedrooms} />
469 482
470 {l.lat != null && l.lng != null && ( 483 {l.lat != null && l.lng != null && (
@@ -494,6 +507,8 @@ export default function ListingPage() {
494 507
495 {l.kascores && <KaScoresBlock ks={l.kascores} />} 508 {l.kascores && <KaScoresBlock ks={l.kascores} />}
496 509
510+ {l.hiver && <HiverScore h={l.hiver} />}
511+
497 <section className="f-bloc f-quartier" id="quartier"> 512 <section className="f-bloc f-quartier" id="quartier">
498 {l.quartier ? <QuartierBlock q={l.quartier} /> : null} 513 {l.quartier ? <QuartierBlock q={l.quartier} /> : null}
499 </section> 514 </section>
500 515
Menu

Theme