SPB Git forge

spb/lou-ka

Public

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

232commits 1branches 0releases
172.9 MBsize
maindefault branch
3 days agolast push
HTML 98.9% Python 0.6%

[ka2] snapshot pré-mission kangalou

Simon-Pierre Boucher committed 1 mo ago (Aug 24, 2026) parent 8efd8cb

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