SPB Git forge

spb/immo-ka

Public

Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)

112commits 1branches 0releases
125.4 MBsize
maindefault branch
13 days agolast push
Python 47.5% HTML 27.9% TypeScript 15.5% CSS 7.2% JavaScript 2%

Stats : panneaux territoire (registre des loyers, population, essence)

Portage lou-ka : immoka/statsextra.py (3 panneaux, cache 30 min ; filtre actives
adaptatif — immo-ka n a pas dup_of) ; rdl.db copié sur le nœud ; dashboard clé
panels ; Stats.tsx rendu groupé réutilisant le kit. Registre des loyers +
recensement des secteurs (population 7,1 M, revenu, % locataires) + prix essence
par région.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 24, 2026) parent 74639f6

3 changed files +386 −1

modified frontend/src/pages/Stats.tsx +39 −1
@@ -38,6 +38,17 @@ interface Dashboard {
38 38 hourly?: { title: string; cells: HourCell[] };
39 39 tables: TableSpec[];
40 40 records: RecordFact[];
41 + panels?: Panel[];
42 +}
43 +
44 +interface Panel {
45 + id: string;
46 + title: string;
47 + subtitle?: string;
48 + kpis?: Kpi[];
49 + breakdowns?: { id: string; title: string; kind?: string; items: BreakItem[] }[];
50 + distributions?: Distribution[];
51 + tables?: TableSpec[];
41 52 }
42 53
43 54 const gridAutoFit = (min: number): CSSProperties => ({
@@ -345,7 +356,34 @@ export default function StatsPage() {
345 356 <section aria-label="Records et faits marquants">
346 357 <p className="klabel" style={{ margin: "0 0 8px" }}>Records & faits marquants</p>
347 358 <div style={gridAutoFit(300)}>
348 − {dash.records.map((r, i) => <RecordCard r={r} key={i} />)}
359 + {/* --- panneaux « données du territoire » --------------------------- */}
360 + {dash.panels?.map((p) => {
361 + const pDonuts = p.breakdowns?.filter((b) => b.kind === "donut") ?? [];
362 + const pBars = p.breakdowns?.filter((b) => b.kind !== "donut") ?? [];
363 + return (
364 + <section key={p.id} aria-label={p.title} style={{ display: "grid", gap: 12, marginTop: 6 }}>
365 + <div>
366 + <h2 style={{ margin: 0, fontFamily: "var(--font-display)", fontSize: 20 }}>{p.title}</h2>
367 + {p.subtitle && <p className="klabel" style={{ margin: "4px 0 0" }}>{p.subtitle}</p>}
368 + </div>
369 + {p.kpis && p.kpis.length > 0 && (
370 + <div style={{ display: "grid", gap: 12, gridTemplateColumns: "repeat(auto-fit, minmax(170px, 1fr))" }}>
371 + {p.kpis.map((k) => <KpiCard key={k.id} k={k} />)}
372 + </div>
373 + )}
374 + {(pDonuts.length > 0 || pBars.length > 0 || (p.distributions?.length ?? 0) > 0) && (
375 + <div style={{ display: "grid", gap: 18, gridTemplateColumns: "repeat(auto-fit, minmax(min(340px, 100%), 1fr))" }}>
376 + {pDonuts.map((b) => <Donut key={b.id} title={b.title} items={b.items} />)}
377 + {pBars.map((b) => <BarChart key={b.id} title={b.title} items={b.items} />)}
378 + {p.distributions?.map((d) => <Histogram key={d.id} dist={d} />)}
379 + </div>
380 + )}
381 + {p.tables?.map((tb) => <DataTable key={tb.id} spec={tb} />)}
382 + </section>
383 + );
384 + })}
385 +
386 + {dash.records.map((r, i) => <RecordCard r={r} key={i} />)}
349 387 </div>
350 388 </section>
351 389 ) : null}
modified immoka/stats.py +7 −0
@@ -1006,6 +1006,13 @@ def _compute_con(con, frm_q, to_q, period) -> dict:
1006 1006 out["heatmap"] = heatmap
1007 1007 if hourly:
1008 1008 out["hourly"] = hourly
1009 + try:
1010 + from . import statsextra
1011 + pnls = statsextra.panels(con)
1012 + if pnls:
1013 + out["panels"] = pnls
1014 + except Exception:
1015 + pass
1009 1016 return out
1010 1017
1011 1018
added immoka/statsextra.py +340 −0
@@ -0,0 +1,340 @@
1 +# -----------------------------------------------------------------------------
2 +# Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# statsextra.py : panneaux « données du territoire » de l'onglet Statistiques
5 +# Trois blocs indépendants du marché des annonces, calculés sur nos jeux de
6 +# données ouverts et mis en cache :
7 +# · Registre des loyers (loyers réellement déclarés, data/rdl.db) ;
8 +# · Population & territoire (recensement des secteurs couverts,
9 +# quartier.db.da_stats joint aux annonces par aire de diffusion) ;
10 +# · Prix de l'essence (stations gazquebec.ca, data/gaz.db).
11 +# Chaque panneau réutilise les rendus existants du kit stats (kpis, donut/
12 +# barres, histogramme, tableau) — aucune donnée inventée : un bloc absent si
13 +# sa source ne répond pas.
14 +# -----------------------------------------------------------------------------
15 +from __future__ import annotations
16 +
17 +import sqlite3
18 +import time
19 +from pathlib import Path
20 +from statistics import median
21 +
22 +ROOT = Path(__file__).resolve().parent.parent
23 +DATA = ROOT / "data"
24 +_CACHE: dict[str, tuple[float, list]] = {}
25 +_TTL = 1800
26 +
27 +
28 +def _ro(name: str) -> sqlite3.Connection | None:
29 + p = DATA / name
30 + if not p.exists():
31 + return None
32 + con = sqlite3.connect(f"file:{p}?mode=ro", uri=True)
33 + con.row_factory = sqlite3.Row
34 + return con
35 +
36 +
37 +def _money(v) -> str:
38 + return f"{round(v):,}".replace(",", " ") + " $" if v is not None else "—"
39 +
40 +
41 +def _q(vals: list[float], f: float) -> float:
42 + s = sorted(vals)
43 + if not s:
44 + return 0
45 + i = f * (len(s) - 1)
46 + lo = int(i)
47 + hi = min(lo + 1, len(s) - 1)
48 + return s[lo] + (s[hi] - s[lo]) * (i - lo)
49 +
50 +
51 +# --- Panneau 1 : Registre des loyers -----------------------------------------
52 +def _panel_rdl() -> dict | None:
53 + con = _ro("rdl.db")
54 + if con is None:
55 + return None
56 + try:
57 + rows = con.execute(
58 + "SELECT price, rooms, year, city FROM rdl_housings "
59 + "WHERE price > 100 AND price < 20000").fetchall()
60 + except sqlite3.Error:
61 + con.close()
62 + return None
63 + con.close()
64 + if len(rows) < 100:
65 + return None
66 + prices = [r["price"] for r in rows]
67 + recent = [r["price"] for r in rows if (r["year"] or 0) >= 2023]
68 + villes = {r["city"] for r in rows if r["city"]}
69 +
70 + kpis = [
71 + {"id": "rdl_n", "label": "Loyers déclarés", "value": len(rows)},
72 + {"id": "rdl_med", "label": "Loyer médian déclaré",
73 + "value": round(median(prices)), "unit": "$"},
74 + {"id": "rdl_rec", "label": "Médiane depuis 2023",
75 + "value": round(median(recent)) if recent else None, "unit": "$"},
76 + {"id": "rdl_villes", "label": "Villes représentées", "value": len(villes)},
77 + ]
78 +
79 + # médiane par nombre de chambres (barres)
80 + byr: dict[int, list[float]] = {}
81 + for r in rows:
82 + if r["rooms"] is not None and 0 <= r["rooms"] <= 6:
83 + byr.setdefault(r["rooms"], []).append(r["price"])
84 + bars = [{"label": f"{k} ch.", "value": round(median(v))}
85 + for k, v in sorted(byr.items()) if len(v) >= 10]
86 +
87 + # histogramme des loyers déclarés (récents de préférence)
88 + base = recent if len(recent) >= 500 else prices
89 + lo, hi = round(_q(base, 0.02)), round(_q(base, 0.98))
90 + step = max(50, round((hi - lo) / 14 / 50) * 50)
91 + bins = []
92 + x = lo
93 + while x < hi:
94 + n = sum(1 for p in base if x <= p < x + step)
95 + bins.append({"label": f"{x}", "value": n})
96 + x += step
97 +
98 + # top villes par nombre de déclarations
99 + vcount: dict[str, list[float]] = {}
100 + for r in rows:
101 + if r["city"]:
102 + vcount.setdefault(r["city"], []).append(r["price"])
103 + top = sorted(vcount.items(), key=lambda kv: -len(kv[1]))[:15]
104 + table = {"id": "rdl_villes_t",
105 + "title": "Loyers déclarés par ville (top 15)",
106 + "columns": ["Ville", "Déclarations", "Loyer médian"],
107 + "rows": [[v, len(p), _money(median(p))] for v, p in top]}
108 +
109 + return {
110 + "id": "registre_loyers",
111 + "title": "Registre des loyers — loyers réellement payés",
112 + "subtitle": "Loyers déclarés volontairement par des locataires au "
113 + "Registre des loyers (Vivre en ville). Données citoyennes, "
114 + "indicatives.",
115 + "kpis": [k for k in kpis if k.get("value") is not None],
116 + "breakdowns": [{"id": "rdl_rooms", "title": "Loyer médian déclaré par "
117 + "nombre de chambres", "kind": "bar", "items": bars}]
118 + if bars else [],
119 + "distributions": [{"id": "rdl_hist",
120 + "title": "Distribution des loyers déclarés"
121 + + (" (depuis 2023)" if base is recent else ""),
122 + "unit": "$", "bins": bins}] if len(bins) >= 4 else [],
123 + "tables": [table],
124 + }
125 +
126 +
127 +# --- Panneau 2 : Population & territoire --------------------------------------
128 +def _active_where(con: sqlite3.Connection) -> str:
129 + """Filtre « annonces actives » adapté au schéma (lou-ka a dup_of, pas immo)."""
130 + cols = {r[1] for r in con.execute("PRAGMA table_info(listings)")}
131 + w = "active=1" if "active" in cols else "1=1"
132 + if "dup_of" in cols:
133 + w += " AND dup_of IS NULL"
134 + return w
135 +
136 +
137 +def _panel_population(con: sqlite3.Connection) -> dict | None:
138 + qc = _ro("quartier.db")
139 + if qc is None:
140 + return None
141 + aw = _active_where(con)
142 + try:
143 + # aires de diffusion couvertes par au moins une annonce active
144 + dauids = [r[0] for r in con.execute(
145 + f"SELECT DISTINCT dauid FROM listings WHERE {aw} "
146 + "AND dauid IS NOT NULL AND dauid<>''")]
147 + except sqlite3.Error:
148 + dauids = []
149 + if len(dauids) < 20:
150 + qc.close()
151 + return None
152 + # charger le recensement des DA couvertes
153 + rows = []
154 + CH = 400
155 + for i in range(0, len(dauids), CH):
156 + chunk = dauids[i:i + CH]
157 + ph = ",".join("?" * len(chunk))
158 + rows += qc.execute(
159 + f"SELECT * FROM da_stats WHERE dauid IN ({ph})", chunk).fetchall()
160 + qc.close()
161 + if not rows:
162 + return None
163 +
164 + def med(col):
165 + v = [r[col] for r in rows if r[col] is not None]
166 + return median(v) if v else None
167 +
168 + pop_tot = sum(r["population"] or 0 for r in rows)
169 + kpis = [
170 + {"id": "pop_tot", "label": "Population des secteurs couverts",
171 + "value": round(pop_tot)},
172 + {"id": "pop_rev", "label": "Revenu médian des ménages (médiane)",
173 + "value": round(med("revenu_median")) if med("revenu_median") else None,
174 + "unit": "$"},
175 + {"id": "pop_loc", "label": "Part de locataires (médiane)",
176 + "value": round(med("pct_locataires")) if med("pct_locataires") is not None else None,
177 + "unit": "%"},
178 + {"id": "pop_age", "label": "Âge médian (médiane des secteurs)",
179 + "value": round(med("age_median")) if med("age_median") else None,
180 + "unit": "ans"},
181 + {"id": "pop_univ", "label": "Diplôme universitaire (médiane)",
182 + "value": round(med("pct_univ")) if med("pct_univ") is not None else None,
183 + "unit": "%"},
184 + ]
185 +
186 + # répartition de la population par quintile de défavorisation matérielle
187 + defav = _ro("quartier.db")
188 + donut_items = []
189 + try:
190 + dmap = {r["dauid"]: r["quintile_materiel"] for r in defav.execute(
191 + "SELECT dauid, quintile_materiel FROM da_defav")}
192 + buckets: dict[int, float] = {}
193 + for r in rows:
194 + qv = dmap.get(r["dauid"])
195 + if qv:
196 + buckets[qv] = buckets.get(qv, 0) + (r["population"] or 0)
197 + labels = {1: "Très favorisé", 2: "Favorisé", 3: "Moyen",
198 + 4: "Défavorisé", 5: "Très défavorisé"}
199 + donut_items = [{"label": labels.get(k, str(k)), "value": round(v)}
200 + for k, v in sorted(buckets.items())]
201 + except sqlite3.Error:
202 + pass
203 + finally:
204 + if defav:
205 + defav.close()
206 +
207 + # tableau par ville : population, revenu médian, % locataires
208 + qc2 = _ro("quartier.db")
209 + table = None
210 + try:
211 + # villes avec le plus d'annonces actives
212 + top_cities = [r["city"] for r in con.execute(
213 + f"SELECT city, COUNT(*) n FROM listings WHERE {aw} "
214 + "AND city<>'' GROUP BY city ORDER BY n DESC LIMIT 15")]
215 + st = {r["dauid"]: r for r in qc2.execute("SELECT * FROM da_stats")}
216 + trows = []
217 + for city in top_cities:
218 + das = [r["dauid"] for r in con.execute(
219 + f"SELECT DISTINCT dauid FROM listings WHERE {aw} "
220 + "AND city=? AND dauid IS NOT NULL", (city,))]
221 + recs = [st[d] for d in das if d in st]
222 + if len(recs) < 3:
223 + continue
224 + pop = sum(x["population"] or 0 for x in recs)
225 + rev = [x["revenu_median"] for x in recs if x["revenu_median"]]
226 + loc = [x["pct_locataires"] for x in recs
227 + if x["pct_locataires"] is not None]
228 + trows.append([city, f"{round(pop):,}".replace(",", " "),
229 + _money(median(rev)) if rev else "—",
230 + f"{round(median(loc))} %" if loc else "—"])
231 + if trows:
232 + table = {"id": "pop_villes",
233 + "title": "Profil des secteurs couverts par ville",
234 + "columns": ["Ville", "Population", "Revenu médian",
235 + "Locataires"],
236 + "rows": trows}
237 + except sqlite3.Error:
238 + pass
239 + finally:
240 + qc2.close()
241 +
242 + return {
243 + "id": "population",
244 + "title": "Population & territoire",
245 + "subtitle": "Portrait sociodémographique des secteurs (aires de "
246 + "diffusion) où se trouvent les annonces — recensement.",
247 + "kpis": [k for k in kpis if k.get("value") is not None],
248 + "breakdowns": [{"id": "pop_defav",
249 + "title": "Population par niveau de défavorisation "
250 + "matérielle", "kind": "donut", "items": donut_items}]
251 + if donut_items else [],
252 + "tables": [table] if table else [],
253 + }
254 +
255 +
256 +# --- Panneau 3 : Prix de l'essence -------------------------------------------
257 +def _panel_gaz() -> dict | None:
258 + try:
259 + from . import gaz
260 + gcon = gaz._connect()
261 + row = gcon.execute("SELECT v FROM meta WHERE k='maj'").fetchone()
262 + if row is None or time.time() - float(row["v"]) > gaz.TTL:
263 + try:
264 + gaz.refresh(gcon)
265 + except Exception:
266 + pass
267 + rows = gcon.execute(
268 + "SELECT region, prix_regulier, prix_super, prix_diesel "
269 + "FROM gaz_stations WHERE prix_regulier > 50").fetchall()
270 + gcon.close()
271 + except Exception:
272 + return None
273 + if len(rows) < 30:
274 + return None
275 + reg = [r["prix_regulier"] for r in rows]
276 + sup = [r["prix_super"] for r in rows if r["prix_super"]]
277 + die = [r["prix_diesel"] for r in rows if r["prix_diesel"]]
278 +
279 + def c(v):
280 + return round(v, 1) if v is not None else None
281 +
282 + kpis = [
283 + {"id": "gaz_n", "label": "Stations suivies", "value": len(rows)},
284 + {"id": "gaz_reg", "label": "Régulier médian",
285 + "value": c(median(reg)), "unit": "¢/L"},
286 + {"id": "gaz_min", "label": "Meilleur prix (régulier)",
287 + "value": c(min(reg)), "unit": "¢/L"},
288 + {"id": "gaz_sup", "label": "Super médian",
289 + "value": c(median(sup)) if sup else None, "unit": "¢/L"},
290 + {"id": "gaz_die", "label": "Diesel médian",
291 + "value": c(median(die)) if die else None, "unit": "¢/L"},
292 + ]
293 +
294 + # prix régulier médian par région
295 + byreg: dict[str, list[float]] = {}
296 + for r in rows:
297 + if r["region"]:
298 + byreg.setdefault(r["region"], []).append(r["prix_regulier"])
299 + bars = sorted(
300 + ([{"label": k, "value": round(median(v), 1)}
301 + for k, v in byreg.items() if len(v) >= 5]),
302 + key=lambda x: x["value"])
303 + # tableau régions
304 + treg = sorted(([k, len(v), round(median(v), 1)]
305 + for k, v in byreg.items() if len(v) >= 5),
306 + key=lambda x: x[2])
307 + table = {"id": "gaz_regions",
308 + "title": "Prix de l'essence régulière par région",
309 + "columns": ["Région", "Stations", "Régulier médian (¢/L)"],
310 + "rows": [[r[0], r[1], f"{r[2]:.1f}"] for r in treg]}
311 +
312 + return {
313 + "id": "essence",
314 + "title": "Prix de l'essence au Québec",
315 + "subtitle": "Prix courants des stations-service (gazquebec.ca), toutes "
316 + "régions confondues.",
317 + "kpis": [k for k in kpis if k.get("value") is not None],
318 + "breakdowns": [{"id": "gaz_reg_bar",
319 + "title": "Essence régulière — médiane par région (¢/L)",
320 + "kind": "bar", "items": bars}] if bars else [],
321 + "tables": [table],
322 + }
323 +
324 +
325 +def panels(con: sqlite3.Connection) -> list[dict]:
326 + """Retourne les panneaux « territoire » (cache 30 min). `con` = base des
327 + annonces (pour la jointure recensement via l'aire de diffusion)."""
328 + hit = _CACHE.get("panels")
329 + if hit and time.time() - hit[0] < _TTL:
330 + return hit[1]
331 + out = []
332 + for fn in (_panel_rdl, lambda: _panel_population(con), _panel_gaz):
333 + try:
334 + p = fn()
335 + if p and (p.get("kpis") or p.get("tables") or p.get("breakdowns")):
336 + out.append(p)
337 + except Exception:
338 + continue
339 + _CACHE["panels"] = (time.time(), out)
340 + return out
341