SPB Git

spb/lou-ka Public

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

HTML 99.7%

Page Stats en 5 onglets + rapport PDF enrichi (source unique marketstats.py)

- louka/marketstats.py : tous les agrégats du marché (régions, offre,
  inclusions, prix au pi², baisses de prix 30 j, santé des sources) —
  partagés entre /api/stats/detailed et le rapport PDF
- Onglets : Vue d'ensemble (tuiles + régions + histogramme) · Loyers
  (histogramme, par taille, prix au pi², baisses de prix cliquables) ·
  Régions & villes · L'offre (inclusions %, dispo, animaux, superficie) ·
  Gestionnaires (top 20 + santé des syncs + alertes 24 h)
- Rapport PDF : page « Offre » ajoutée (barres d'inclusions, prix au pi²,
  baisses de prix), 4 pages, mêmes chiffres que le site

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed yesterday (Aug 9, 2026) parent 67f8441

Showing 6 changed files with +706 and −311

modified frontend/src/api.ts +26 −0
@@ -175,6 +175,7 @@ export function fetchListings(f: ListingFilters) {
175 175 export interface GroupStat {
176 176 key: string;
177 177 count: number;
178 + sources?: number;
178 179 avg_price: number | null;
179 180 min_price: number | null;
180 181 }
@@ -189,11 +190,36 @@ export interface DetailedStats {
189 190 max: number | null;
190 191 sources: number;
191 192 cities: number;
193 + regions: number;
194 + gps_pct: number | null;
195 + superficie_moyenne: number | null;
196 + dispo_now: number;
192 197 };
193 198 histogram: { lo: number; hi: number | null; count: number }[];
194 199 by_type: GroupStat[];
195 200 by_city: GroupStat[];
196 201 by_source: GroupStat[];
202 + by_region: GroupStat[];
203 + offre: {
204 + furnished_pct: number | null;
205 + pets_oui_pct: number | null;
206 + pets_connu: number;
207 + chauffage_pct: number | null;
208 + electricite_pct: number | null;
209 + eau_chaude_pct: number | null;
210 + internet_pct: number | null;
211 + clim_pct: number | null;
212 + stationnement_pct: number | null;
213 + balcon_pct: number | null;
214 + dispo_now: number;
215 + dispo_date: number;
216 + dispo_inconnue: number;
217 + superficie_moyenne: number | null;
218 + superficie_connue: number;
219 + prix_pi2: { key: string; count: number; val: number }[];
220 + };
221 + baisses: { uid: string; title: string; city: string; avant: number; apres: number; pct: number }[];
222 + sante: { sources_sync_24h: number; alertes_24h: { source: string; message: string; ts: number }[] };
197 223 }
198 224
199 225 export const fetchDetailedStats = () => get<DetailedStats>("/api/stats/detailed");
modified frontend/src/pages/Stats.tsx +289 −174
@@ -1,12 +1,12 @@
1 1 // -----------------------------------------------------------------------------
2 2 // Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 3 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 // pages/Stats.tsx : page Statistiques — tuiles héro, histogramme des loyers,
5 // répartitions par type / ville / gestionnaire (mono-série, encre verte,
6 // étiquettes directes, infobulles au survol, vue tableau par graphique)
4 +// pages/Stats.tsx : observatoire du marché — 5 onglets (Vue d'ensemble, Loyers,
5 +// Régions & villes, Offre, Gestionnaires), tuiles héro, histogramme,
6 +// barres mono-série, baisses de prix, santé des sources. Rapport PDF global.
7 7 // -----------------------------------------------------------------------------
8 8 import { useEffect, useMemo, useState } from "react";
9 import { Link } from "react-router-dom";
9 +import { Link, useSearchParams } from "react-router-dom";
10 10 import {
11 11 DetailedStats, GroupStat,
12 12 fetchDetailedStats, fetchSources, registerSourceNames, sourceName,
@@ -16,6 +16,15 @@ const fmt = (n: number | null | undefined) =>
16 16 n == null ? "—" : n.toLocaleString("fr-CA");
17 17 const fmt$ = (n: number | null | undefined) => (n == null ? "—" : `${fmt(n)} $`);
18 18
19 +const ONGLETS = [
20 + { id: "ensemble", label: "Vue d'ensemble", icon: "◎" },
21 + { id: "loyers", label: "Loyers", icon: "$" },
22 + { id: "regions", label: "Régions & villes", icon: "◈" },
23 + { id: "offre", label: "L'offre", icon: "🏠" },
24 + { id: "gestionnaires", label: "Gestionnaires", icon: "🗂" },
25 +] as const;
26 +type OngletId = (typeof ONGLETS)[number]["id"];
27 +
19 28 // ---- infobulle partagée ------------------------------------------------------
20 29 interface Tip { x: number; y: number; title: string; lines: string[]; }
21 30
@@ -25,47 +34,30 @@ function useTooltip() {
25 34 setTip({ x: e.clientX, y: e.clientY, title, lines });
26 35 const hide = () => setTip(null);
27 36 const node = tip && (
28 <div
29 className="viz-tip"
30 style={{
31 left: Math.min(tip.x + 14, window.innerWidth - 190),
32 top: tip.y + 14,
33 }}
34 role="status"
35 >
37 + <div className="viz-tip" role="status"
38 + style={{ left: Math.min(tip.x + 14, window.innerWidth - 190), top: tip.y + 14 }}>
36 39 <div className="viz-tip-title">{tip.title}</div>
37 {tip.lines.map((l) => (
38 <div key={l}>{l}</div>
39 ))}
40 + {tip.lines.map((l) => <div key={l}>{l}</div>)}
40 41 </div>
41 42 );
42 43 return { show, hide, node };
43 44 }
44 45
45 46 // ---- barres horizontales (une série) ----------------------------------------
46 function HBars({
47 data, unit, linkPrefix, tip,
48 }: {
47 +function HBars({ data, unit, tip }: {
49 48 data: { label: string; count: number; avg: number | null; href?: string }[];
50 49 unit: string;
51 linkPrefix?: string;
52 50 tip: ReturnType<typeof useTooltip>;
53 51 }) {
54 52 const max = Math.max(...data.map((d) => d.count), 1);
55 53 return (
56 54 <div className="hbars">
57 55 {data.map((d) => (
58 <div
59 className="hbar-row"
60 key={d.label}
61 onMouseMove={(e) =>
62 tip.show(e, d.label, [
63 `${fmt(d.count)} ${unit}`,
64 d.avg != null ? `loyer moyen ${fmt$(d.avg)}` : "loyer non affiché",
65 ])
66 }
67 onMouseLeave={tip.hide}
68 >
56 + <div className="hbar-row" key={d.label}
57 + onMouseMove={(e) => tip.show(e, d.label, [
58 + `${fmt(d.count)} ${unit}`,
59 + d.avg != null ? `loyer moyen ${fmt$(d.avg)}` : "loyer non affiché"])}
60 + onMouseLeave={tip.hide}>
69 61 <span className="hbar-label" title={d.label}>
70 62 {d.href ? <Link to={d.href}>{d.label}</Link> : d.label}
71 63 </span>
@@ -73,9 +65,25 @@ function HBars({
73 65 <span className="hbar-fill" style={{ width: `${(d.count / max) * 100}%` }} />
74 66 </span>
75 67 <span className="hbar-value">
76 {fmt(d.count)}
77 {d.avg != null && <em> · {fmt$(d.avg)}</em>}
68 + {fmt(d.count)}{d.avg != null && <em> · {fmt$(d.avg)}</em>}
69 + </span>
70 + </div>
71 + ))}
72 + </div>
73 + );
74 +}
75 +
76 +// ---- barres de pourcentage (inclusions) ---------------------------------------
77 +function PctBars({ data }: { data: { label: string; pct: number | null }[] }) {
78 + return (
79 + <div className="hbars">
80 + {data.filter((d) => d.pct != null).map((d) => (
81 + <div className="hbar-row" key={d.label}>
82 + <span className="hbar-label">{d.label}</span>
83 + <span className="hbar-track">
84 + <span className="hbar-fill" style={{ width: `${Math.min(100, d.pct!)}%` }} />
78 85 </span>
86 + <span className="hbar-value">{d.pct!.toLocaleString("fr-CA")} %</span>
79 87 </div>
80 88 ))}
81 89 </div>
@@ -93,10 +101,8 @@ function DataTable({ rows, unit }: { rows: GroupStat[]; unit: string }) {
93 101 <tbody>
94 102 {rows.map((r) => (
95 103 <tr key={r.key}>
96 <td>{r.key}</td>
97 <td>{fmt(r.count)}</td>
98 <td>{fmt$(r.avg_price)}</td>
99 <td>{fmt$(r.min_price)}</td>
104 + <td>{r.key}</td><td>{fmt(r.count)}</td>
105 + <td>{fmt$(r.avg_price)}</td><td>{fmt$(r.min_price)}</td>
100 106 </tr>
101 107 ))}
102 108 </tbody>
@@ -105,10 +111,21 @@ function DataTable({ rows, unit }: { rows: GroupStat[]; unit: string }) {
105 111 );
106 112 }
107 113
114 +function Tile({ v, k, hero }: { v: string; k: string; hero?: boolean }) {
115 + return (
116 + <div className={`tile ${hero ? "hero-tile" : ""}`}>
117 + <div className="tile-v">{v}</div>
118 + <div className="tile-k">{k}</div>
119 + </div>
120 + );
121 +}
122 +
108 123 // ---- page --------------------------------------------------------------------
109 124 export default function StatsPage() {
110 125 const [d, setD] = useState<DetailedStats | null>(null);
111 126 const [error, setError] = useState<string | null>(null);
127 + const [params, setParams] = useSearchParams();
128 + const onglet = (params.get("onglet") as OngletId) || "ensemble";
112 129 const tip = useTooltip();
113 130
114 131 useEffect(() => {
@@ -117,9 +134,7 @@ export default function StatsPage() {
117 134 }, []);
118 135
119 136 const histMax = useMemo(
120 () => Math.max(...(d?.histogram.map((h) => h.count) ?? [1]), 1),
121 [d]
122 );
137 + () => Math.max(...(d?.histogram.map((h) => h.count) ?? [1]), 1), [d]);
123 138
124 139 if (error)
125 140 return (
@@ -139,29 +154,46 @@ export default function StatsPage() {
139 154 );
140 155
141 156 const t = d.totals;
142 const types = d.by_type.slice(0, 9);
143 const cities = d.by_city.slice(0, 12);
144 const citiesRest = d.by_city.slice(12);
145 const sources = d.by_source.slice(0, 15);
146 const sourcesRest = d.by_source.slice(15);
157 + const o = d.offre;
147 158 const fold = (rest: GroupStat[]): GroupStat | null =>
148 rest.length === 0
149 ? null
150 : {
151 key: `Autres (${rest.length})`,
152 count: rest.reduce((s, r) => s + r.count, 0),
153 avg_price: null,
154 min_price: null,
155 };
159 + rest.length === 0 ? null : {
160 + key: `Autres (${rest.length})`,
161 + count: rest.reduce((s, r) => s + r.count, 0),
162 + avg_price: null, min_price: null,
163 + };
164 +
165 + const histogramme = (
166 + <section className="viz-card">
167 + <h2>Distribution des loyers</h2>
168 + <p className="viz-sub">{fmt(t.with_price)} annonces avec prix affiché — classes de 200 $</p>
169 + <div className="histo" role="img" aria-label="Histogramme des loyers mensuels">
170 + {d.histogram.map((h) => (
171 + <div className="histo-col" key={`${h.lo}`}
172 + onMouseMove={(e) => tip.show(e,
173 + h.hi ? `${fmt(h.lo)} – ${fmt(h.hi)} $` : `${fmt(h.lo)} $ et plus`,
174 + [`${fmt(h.count)} logements`,
175 + `${((h.count / Math.max(t.with_price, 1)) * 100).toFixed(1)} % du parc`])}
176 + onMouseLeave={tip.hide}>
177 + <div className="histo-bar-zone">
178 + <div className="histo-bar" style={{ height: `${(h.count / histMax) * 100}%` }} />
179 + </div>
180 + <div className="histo-x">
181 + {h.lo % 400 === 0 ? (h.lo >= 1000 ? `${h.lo / 1000}k` : h.lo) : ""}
182 + </div>
183 + </div>
184 + ))}
185 + </div>
186 + </section>
187 + );
156 188
157 189 return (
158 190 <div className="container stats-page">
159 191 {tip.node}
160 <span className="kicker">Observatoire — marché locatif</span>
192 + <span className="kicker">Observatoire — marché locatif québécois</span>
161 193 <h1 className="stats-title">Le marché, en chiffres</h1>
162 194 <p className="sub">
163 Calculé en direct sur les {fmt(t.total)} annonces actives agrégées par Lou-Ka.
164 Les loyers « à partir de » des sources sont utilisés tels quels.
195 + Calculé en direct sur les {fmt(t.total)} annonces actives de {fmt(t.sources)} gestionnaires,
196 + dans {fmt(t.cities)} villes et {fmt(t.regions)} régions.
165 197 </p>
166 198 <p>
167 199 <a className="btn btn-primary" href="/api/stats/rapport.pdf" download>
@@ -169,132 +201,215 @@ export default function StatsPage() {
169 201 </a>
170 202 </p>
171 203
172 <div className="tiles">
173 <div className="tile hero-tile">
174 <div className="tile-v">{fmt(t.total)}</div>
175 <div className="tile-k">logements actifs</div>
176 </div>
177 <div className="tile">
178 <div className="tile-v">{fmt$(t.median)}</div>
179 <div className="tile-k">loyer médian</div>
180 </div>
181 <div className="tile">
182 <div className="tile-v">{fmt$(t.avg)}</div>
183 <div className="tile-k">loyer moyen</div>
184 </div>
185 <div className="tile">
186 <div className="tile-v">{fmt(t.sources)}</div>
187 <div className="tile-k">gestionnaires</div>
188 </div>
189 <div className="tile">
190 <div className="tile-v">{fmt(t.cities)}</div>
191 <div className="tile-k">villes couvertes</div>
192 </div>
193 <div className="tile">
194 <div className="tile-v">{fmt$(t.min)}</div>
195 <div className="tile-k">loyer le plus bas</div>
196 </div>
197 </div>
204 + {/* barre d'onglets */}
205 + <nav className="onglets" role="tablist" aria-label="Sections des statistiques">
206 + {ONGLETS.map((g) => (
207 + <button key={g.id} role="tab" aria-selected={onglet === g.id}
208 + className={`onglet ${onglet === g.id ? "on" : ""}`}
209 + onClick={() => setParams(g.id === "ensemble" ? {} : { onglet: g.id })}>
210 + <span aria-hidden="true">{g.icon}</span> {g.label}
211 + </button>
212 + ))}
213 + </nav>
198 214
199 <section className="viz-card">
200 <h2>Distribution des loyers</h2>
201 <p className="viz-sub">
202 {fmt(t.with_price)} annonces avec prix affiché — classes de 200&nbsp;$
203 </p>
204 <div className="histo" role="img" aria-label="Histogramme de la distribution des loyers mensuels">
205 {d.histogram.map((h) => (
206 <div
207 className="histo-col"
208 key={`${h.lo}`}
209 onMouseMove={(e) =>
210 tip.show(
211 e,
212 h.hi ? `${fmt(h.lo)} – ${fmt(h.hi)} $` : `${fmt(h.lo)} $ et plus`,
213 [`${fmt(h.count)} logements`,
214 `${((h.count / Math.max(t.with_price, 1)) * 100).toFixed(1)} % du parc`]
215 )
216 }
217 onMouseLeave={tip.hide}
218 >
219 <div className="histo-bar-zone">
220 <div className="histo-bar" style={{ height: `${(h.count / histMax) * 100}%` }} />
221 </div>
222 <div className="histo-x">
223 {h.lo % 400 === 0 ? (h.lo >= 1000 ? `${h.lo / 1000}k` : h.lo) : ""}
215 + {/* ============ Vue d'ensemble ============ */}
216 + {onglet === "ensemble" && (
217 + <>
218 + <div className="tiles">
219 + <Tile hero v={fmt(t.total)} k="logements actifs" />
220 + <Tile v={fmt$(t.median)} k="loyer médian" />
221 + <Tile v={fmt$(t.avg)} k="loyer moyen" />
222 + <Tile v={fmt(t.dispo_now)} k="libres maintenant" />
223 + <Tile v={t.superficie_moyenne ? `${fmt(t.superficie_moyenne)} pi²` : "—"} k="superficie moyenne" />
224 + <Tile v={t.gps_pct != null ? `${t.gps_pct} %` : "—"} k="géolocalisées" />
225 + </div>
226 + <section className="viz-card">
227 + <h2>Couverture par région</h2>
228 + <p className="viz-sub">annonces actives · loyer moyen régional</p>
229 + <HBars tip={tip} unit="logements"
230 + data={d.by_region.map((r) => ({ label: r.key, count: r.count, avg: r.avg_price }))} />
231 + <DataTable rows={d.by_region} unit="Logements" />
232 + </section>
233 + {histogramme}
234 + </>
235 + )}
236 +
237 + {/* ============ Loyers ============ */}
238 + {onglet === "loyers" && (
239 + <>
240 + <div className="tiles">
241 + <Tile hero v={fmt$(t.median)} k="loyer médian" />
242 + <Tile v={fmt$(t.avg)} k="loyer moyen" />
243 + <Tile v={fmt$(t.min)} k="loyer le plus bas" />
244 + <Tile v={fmt$(t.max)} k="loyer le plus élevé" />
245 + </div>
246 + {histogramme}
247 + <div className="viz-grid">
248 + <section className="viz-card">
249 + <h2>Par taille de logement</h2>
250 + <p className="viz-sub">nombre d'annonces · loyer moyen</p>
251 + <HBars tip={tip} unit="logements"
252 + data={d.by_type.slice(0, 9).map((r) => ({
253 + label: r.key, count: r.count, avg: r.avg_price,
254 + href: `/?unit_type=${encodeURIComponent(r.key)}` }))} />
255 + <DataTable rows={d.by_type} unit="Logements" />
256 + </section>
257 + <section className="viz-card">
258 + <h2>Prix au pied carré</h2>
259 + <p className="viz-sub">loyer ÷ superficie, par taille (annonces publiant les deux)</p>
260 + <div className="hbars">
261 + {o.prix_pi2.map((r) => {
262 + const max = Math.max(...o.prix_pi2.map((x) => x.val), 0.01);
263 + return (
264 + <div className="hbar-row" key={r.key}>
265 + <span className="hbar-label">{r.key}</span>
266 + <span className="hbar-track">
267 + <span className="hbar-fill" style={{ width: `${(r.val / max) * 100}%` }} />
268 + </span>
269 + <span className="hbar-value">
270 + {r.val.toLocaleString("fr-CA")} $/pi²<em> · {r.count}</em>
271 + </span>
272 + </div>
273 + );
274 + })}
224 275 </div>
225 </div>
226 ))}
227 </div>
228 <details className="viz-table">
229 <summary>Voir les données</summary>
230 <table>
231 <thead><tr><th>Classe</th><th>Logements</th></tr></thead>
232 <tbody>
233 {d.histogram.map((h) => (
234 <tr key={`t${h.lo}`}>
235 <td>{h.hi ? `${fmt(h.lo)} – ${fmt(h.hi)} $` : `${fmt(h.lo)} $ +`}</td>
236 <td>{fmt(h.count)}</td>
237 </tr>
238 ))}
239 </tbody>
240 </table>
241 </details>
242 </section>
276 + </section>
277 + </div>
278 + {d.baisses.length > 0 && (
279 + <section className="viz-card">
280 + <h2>Baisses de prix récentes 📉</h2>
281 + <p className="viz-sub">30 derniers jours — leviers de négociation</p>
282 + <ul className="baisses">
283 + {d.baisses.map((b) => (
284 + <li key={b.uid}>
285 + <Link to={`/logement/${encodeURIComponent(b.uid)}`}>
286 + {b.title || b.uid}
287 + </Link>
288 + <span className="baisse-ville">{b.city}</span>
289 + <span className="baisse-prix">
290 + <s>{fmt$(b.avant)}</s> → <b>{fmt$(b.apres)}</b>
291 + <em className="baisse-pct">{b.pct.toLocaleString("fr-CA")} %</em>
292 + </span>
293 + </li>
294 + ))}
295 + </ul>
296 + </section>
297 + )}
298 + </>
299 + )}
243 300
244 <div className="viz-grid">
245 <section className="viz-card">
246 <h2>Par taille de logement</h2>
247 <p className="viz-sub">nombre d'annonces · loyer moyen</p>
248 <HBars
249 tip={tip}
250 unit="logements"
251 data={types.map((r) => ({
252 label: r.key, count: r.count, avg: r.avg_price,
253 href: `/?unit_type=${encodeURIComponent(r.key)}`,
254 }))}
255 />
256 <DataTable rows={d.by_type} unit="Logements" />
257 </section>
301 + {/* ============ Régions & villes ============ */}
302 + {onglet === "regions" && (
303 + <>
304 + <section className="viz-card">
305 + <h2>Par région</h2>
306 + <p className="viz-sub">annonces · gestionnaires · loyer moyen</p>
307 + <HBars tip={tip} unit="logements"
308 + data={d.by_region.map((r) => ({ label: r.key, count: r.count, avg: r.avg_price }))} />
309 + <details className="viz-table" open>
310 + <summary>Voir les données</summary>
311 + <table>
312 + <thead><tr><th>Région</th><th>Annonces</th><th>Sources</th><th>Loyer moyen</th></tr></thead>
313 + <tbody>
314 + {d.by_region.map((r) => (
315 + <tr key={r.key}>
316 + <td>{r.key}</td><td>{fmt(r.count)}</td>
317 + <td>{r.sources ?? "—"}</td><td>{fmt$(r.avg_price)}</td>
318 + </tr>
319 + ))}
320 + </tbody>
321 + </table>
322 + </details>
323 + </section>
324 + <section className="viz-card">
325 + <h2>Par ville</h2>
326 + <p className="viz-sub">top 20 — nombre d'annonces · loyer moyen</p>
327 + <HBars tip={tip} unit="logements"
328 + data={[...d.by_city.slice(0, 20).map((r) => ({
329 + label: r.key, count: r.count, avg: r.avg_price,
330 + href: `/?city=${encodeURIComponent(r.key)}` })),
331 + ...(fold(d.by_city.slice(20))
332 + ? [{ label: fold(d.by_city.slice(20))!.key,
333 + count: fold(d.by_city.slice(20))!.count, avg: null }] : [])]} />
334 + <DataTable rows={d.by_city} unit="Logements" />
335 + </section>
336 + </>
337 + )}
258 338
259 <section className="viz-card">
260 <h2>Par ville</h2>
261 <p className="viz-sub">top {cities.length} — nombre d'annonces · loyer moyen</p>
262 <HBars
263 tip={tip}
264 unit="logements"
265 data={[...cities.map((r) => ({
266 label: r.key, count: r.count, avg: r.avg_price,
267 href: `/?city=${encodeURIComponent(r.key)}`,
268 })), ...(fold(citiesRest) ? [{
269 label: fold(citiesRest)!.key, count: fold(citiesRest)!.count, avg: null,
270 }] : [])]}
271 />
272 <DataTable rows={d.by_city} unit="Logements" />
273 </section>
274 </div>
339 + {/* ============ L'offre ============ */}
340 + {onglet === "offre" && (
341 + <>
342 + <div className="tiles">
343 + <Tile hero v={fmt(o.dispo_now)} k="libres maintenant" />
344 + <Tile v={fmt(o.dispo_date)} k="libres à date future" />
345 + <Tile v={o.superficie_moyenne ? `${fmt(o.superficie_moyenne)} pi²` : "—"} k="superficie moyenne" />
346 + <Tile v={o.pets_oui_pct != null ? `${o.pets_oui_pct} %` : "—"}
347 + k={`acceptent les animaux (sur ${fmt(o.pets_connu)} précisées)`} />
348 + </div>
349 + <section className="viz-card">
350 + <h2>Inclusions et caractéristiques</h2>
351 + <p className="viz-sub">part du parc dont la source confirme l'inclusion — le reste est inconnu, pas absent</p>
352 + <PctBars data={[
353 + { label: "Balcon", pct: o.balcon_pct },
354 + { label: "Stationnement", pct: o.stationnement_pct },
355 + { label: "Climatisation", pct: o.clim_pct },
356 + { label: "Internet inclus", pct: o.internet_pct },
357 + { label: "Eau chaude incluse", pct: o.eau_chaude_pct },
358 + { label: "Chauffage inclus", pct: o.chauffage_pct },
359 + { label: "Électricité incluse", pct: o.electricite_pct },
360 + { label: "Meublé", pct: o.furnished_pct },
361 + ].sort((a, b) => (b.pct ?? 0) - (a.pct ?? 0))} />
362 + </section>
363 + <section className="viz-card">
364 + <h2>Par taille de logement</h2>
365 + <HBars tip={tip} unit="logements"
366 + data={d.by_type.slice(0, 9).map((r) => ({
367 + label: r.key, count: r.count, avg: r.avg_price,
368 + href: `/?unit_type=${encodeURIComponent(r.key)}` }))} />
369 + </section>
370 + </>
371 + )}
275 372
276 <section className="viz-card">
277 <h2>Par gestionnaire immobilier</h2>
278 <p className="viz-sub">top {sources.length} — nombre d'annonces · loyer moyen</p>
279 <HBars
280 tip={tip}
281 unit="logements"
282 data={[...sources.map((r) => ({
283 label: sourceName(r.key), count: r.count, avg: r.avg_price,
284 href: `/?source=${encodeURIComponent(r.key)}`,
285 })), ...(fold(sourcesRest) ? [{
286 label: fold(sourcesRest)!.key, count: fold(sourcesRest)!.count, avg: null,
287 }] : [])]}
288 />
289 <DataTable
290 rows={d.by_source.map((r) => ({ ...r, key: sourceName(r.key) }))}
291 unit="Logements"
292 />
293 </section>
373 + {/* ============ Gestionnaires ============ */}
374 + {onglet === "gestionnaires" && (
375 + <>
376 + <div className="tiles">
377 + <Tile hero v={fmt(t.sources)} k="gestionnaires connectés" />
378 + <Tile v={fmt(d.sante.sources_sync_24h)} k="synchronisés (24 h)" />
379 + <Tile v={fmt(d.sante.alertes_24h.length)} k="alertes (24 h)" />
380 + </div>
381 + <section className="viz-card">
382 + <h2>Par gestionnaire immobilier</h2>
383 + <p className="viz-sub">top 20 — nombre d'annonces · loyer moyen</p>
384 + <HBars tip={tip} unit="logements"
385 + data={[...d.by_source.slice(0, 20).map((r) => ({
386 + label: sourceName(r.key), count: r.count, avg: r.avg_price,
387 + href: `/?source=${encodeURIComponent(r.key)}` })),
388 + ...(fold(d.by_source.slice(20))
389 + ? [{ label: fold(d.by_source.slice(20))!.key,
390 + count: fold(d.by_source.slice(20))!.count, avg: null }] : [])]} />
391 + <DataTable rows={d.by_source.map((r) => ({ ...r, key: sourceName(r.key) }))}
392 + unit="Logements" />
393 + </section>
394 + {d.sante.alertes_24h.length > 0 && (
395 + <section className="viz-card">
396 + <h2>Alertes de synchronisation (24 h)</h2>
397 + <ul className="alertes">
398 + {d.sante.alertes_24h.map((a, i) => (
399 + <li key={i}><b>{sourceName(a.source)}</b> — {a.message}</li>
400 + ))}
401 + </ul>
402 + </section>
403 + )}
404 + <p className="stats-foot">
405 + <Link to="/sources">Voir le registre complet des sources →</Link>
406 + </p>
407 + </>
408 + )}
294 409
295 410 <p className="stats-foot">
296 Données recalculées à chaque synchronisation (horaire). Les catégories renvoient
297 vers les logements filtrés correspondants.
411 + Données recalculées à chaque synchronisation (horaire). Les catégories
412 + renvoient vers les logements filtrés correspondants.
298 413 </p>
299 414 </div>
300 415 );
modified frontend/src/styles.css +36 −0
@@ -929,3 +929,39 @@ html { scroll-padding-top: 76px; } /* header sticky au-dessus des ancres */
929 929
930 930 /* --- boutons PDF -------------------------------------------------------- */
931 931 .btn-pdf { display: block; text-align: center; margin-top: 10px; width: 100%; }
932 +
933 +/* --- Onglets de la page Stats -------------------------------------------- */
934 +.onglets {
935 + display: flex; gap: 6px; overflow-x: auto; margin: 26px 0 22px;
936 + padding-bottom: 4px; scrollbar-width: none;
937 + border-bottom: 2px solid var(--ink);
938 +}
939 +.onglets::-webkit-scrollbar { display: none; }
940 +.onglet {
941 + flex: 0 0 auto; display: inline-flex; align-items: center; gap: 7px;
942 + border: 1.5px solid var(--ink); border-bottom: 0;
943 + border-radius: var(--r-ctl) var(--r-ctl) 0 0;
944 + background: var(--surface); color: var(--ink-2); cursor: pointer;
945 + padding: 10px 16px; font-weight: 600; font-size: 13.5px; min-height: 44px;
946 + transition: background 0.12s ease, color 0.12s ease;
947 +}
948 +.onglet:hover { background: var(--lime-soft); color: var(--ink); }
949 +.onglet.on { background: var(--ink); color: var(--lime); }
950 +
951 +/* baisses de prix */
952 +.baisses { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
953 +.baisses li {
954 + display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
955 + padding: 9px 4px; border-bottom: 1px dashed var(--line); font-size: 13.5px;
956 +}
957 +.baisses li a { font-weight: 600; text-decoration: underline; text-underline-offset: 2px; }
958 +.baisse-ville { color: var(--ink-3); font-family: var(--font-mono); font-size: 11px; }
959 +.baisse-prix { margin-left: auto; }
960 +.baisse-prix s { color: var(--ink-3); }
961 +.baisse-pct {
962 + font-style: normal; font-family: var(--font-mono); font-weight: 700;
963 + background: var(--lime-soft); border: 1px solid var(--green);
964 + color: var(--green-deep); border-radius: 999px; padding: 2px 8px;
965 + font-size: 11px; margin-left: 8px;
966 +}
967 +.alertes { list-style: none; padding: 0; display: flex; flex-direction: column; gap: 6px; font-size: 13px; color: var(--ink-2); }
added louka/marketstats.py +241 −0
@@ -0,0 +1,241 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# marketstats.py : agrégats du marché partagés par /api/stats/detailed (page
5 +# Statistiques) et par le rapport PDF (pdfgen.rapport_pdf) — une seule
6 +# source de vérité pour tous les chiffres.
7 +# -----------------------------------------------------------------------------
8 +from __future__ import annotations
9 +
10 +import json
11 +import time
12 +
13 +from . import db
14 +
15 +# Régions administratives simplifiées (villes réellement présentes en base)
16 +REGIONS: list[tuple[str, set[str]]] = [
17 + ("Québec métro", {"Québec", "Lévis", "Saint-Augustin-de-Desmaures",
18 + "L'Ancienne-Lorette", "Pont-Rouge", "Shannon",
19 + "Sainte-Brigitte-de-Laval", "Saint-Raphaël", "La Malbaie"}),
20 + ("Outaouais", {"Gatineau", "Chelsea", "Thurso", "Perkins", "Maniwaki",
21 + "Val-des-Monts"}),
22 + ("Estrie / Montérégie-Est", {"Sherbrooke", "Magog", "Orford", "East Angus",
23 + "Waterville", "Granby", "Waterloo", "Bromont",
24 + "Cowansville", "Richmond"}),
25 + ("Mauricie / Centre-du-Québec", {"Trois-Rivières", "Bécancour", "Shawinigan",
26 + "Drummondville", "Victoriaville", "Nicolet",
27 + "Notre-Dame-du-Bon-Conseil", "Wickham",
28 + "Saint-Léonard-d'Aston", "Louiseville",
29 + "Saint-Narcisse", "Saint-Nicéphore"}),
30 + ("Lanaudière / Laurentides", {"Joliette", "Saint-Jérôme", "Berthierville",
31 + "Saint-Ambroise-de-Kildare", "Charlemagne",
32 + "Saint-Gabriel-de-Brandon", "Lachute",
33 + "Brownsburg-Chatham", "Saint-Charles-Borromée",
34 + "Mirabel", "Sainte-Agathe-des-Monts",
35 + "Sainte-Thérèse", "Blainville",
36 + "Notre-Dame-des-Prairies"}),
37 + ("Bas-Saint-Laurent / Gaspésie", {"Rimouski", "Rivière-du-Loup", "Matane",
38 + "Saint-Ulric", "Amqui", "Le Bic",
39 + "New Richmond", "Carleton-sur-Mer", "Gaspé",
40 + "Pointe-au-Père"}),
41 + ("Saguenay–Lac-Saint-Jean", {"Saguenay", "Alma", "Chicoutimi", "Jonquière",
42 + "Chambord", "La Baie", "Laterrière"}),
43 + ("Abitibi-Témiscamingue", {"Rouyn-Noranda", "Val-d'Or", "Amos", "Malartic"}),
44 + ("Côte-Nord", {"Sept-Îles", "Port-Cartier", "Baie-Comeau", "Forestville"}),
45 + ("Chaudière-Appalaches", {"Saint-Georges", "Sainte-Marie", "Thetford Mines",
46 + "Montmagny", "Vallée-Jonction", "Scott",
47 + "Saint-Isidore", "La Guadeloupe",
48 + "Saint-Joseph-de-Beauce"}),
49 +]
50 +
51 +
52 +def region_for(city: str) -> str:
53 + for nom, villes in REGIONS:
54 + if city in villes:
55 + return nom
56 + return "Grand Montréal & environs"
57 +
58 +
59 +def _median(v: list) -> float | None:
60 + n = len(v)
61 + if n == 0:
62 + return None
63 + return v[n // 2] if n % 2 else (v[n // 2 - 1] + v[n // 2]) / 2
64 +
65 +
66 +def compute() -> dict:
67 + """Tous les agrégats du marché sur les annonces actives."""
68 + con = db.connect()
69 + rows = con.execute(
70 + """SELECT uid, city, source, price, unit_type, area_sqft, furnished,
71 + pets, availability_date, lat, details
72 + FROM listings WHERE active=1""").fetchall()
73 + now = time.time()
74 +
75 + total = len(rows)
76 + prix = sorted(r["price"] for r in rows
77 + if r["price"] and 300 <= r["price"] <= 10000)
78 +
79 + # -- groupes simples ------------------------------------------------------
80 + def grouper(cle_fn):
81 + g: dict[str, dict] = {}
82 + for r in rows:
83 + k = cle_fn(r)
84 + if not k:
85 + continue
86 + d = g.setdefault(k, {"count": 0, "prix": [], "sources": set()})
87 + d["count"] += 1
88 + d["sources"].add(r["source"])
89 + if r["price"] and 300 <= r["price"] <= 10000:
90 + d["prix"].append(r["price"])
91 + out = []
92 + for k, d in sorted(g.items(), key=lambda kv: -kv[1]["count"]):
93 + p = d["prix"]
94 + out.append({"key": k, "count": d["count"],
95 + "sources": len(d["sources"]),
96 + "avg_price": round(sum(p) / len(p)) if p else None,
97 + "min_price": min(p) if p else None})
98 + return out
99 +
100 + by_type = grouper(lambda r: r["unit_type"])
101 + by_city = grouper(lambda r: r["city"])
102 + by_source = grouper(lambda r: r["source"])
103 + by_region = grouper(lambda r: region_for(r["city"] or ""))
104 +
105 + # -- histogramme des loyers ------------------------------------------------
106 + lo, hi, step = 400, 3200, 200
107 + hist = [{"lo": a, "hi": a + step, "count": 0} for a in range(lo, hi, step)]
108 + under = over = 0
109 + for p in prix:
110 + if p < lo:
111 + under += 1
112 + elif p >= hi:
113 + over += 1
114 + else:
115 + hist[int((p - lo) // step)]["count"] += 1
116 + if under:
117 + hist.insert(0, {"lo": 0, "hi": lo, "count": under})
118 + if over:
119 + hist.append({"lo": hi, "hi": None, "count": over})
120 +
121 + # -- offre : inclusions, animaux, meublé, dispo, superficie ----------------
122 + def pct(n, d):
123 + return round(100 * n / d, 1) if d else None
124 +
125 + inc_counts = {"heating": 0, "electricity": 0, "hot_water": 0, "internet": 0}
126 + ac = parking = balcon = 0
127 + with_details = 0
128 + for r in rows:
129 + try:
130 + det = json.loads(r["details"] or "{}")
131 + except ValueError:
132 + det = {}
133 + if det:
134 + with_details += 1
135 + inc = det.get("inclusions") or {}
136 + for k in inc_counts:
137 + if inc.get(k):
138 + inc_counts[k] += 1
139 + if det.get("ac"):
140 + ac += 1
141 + if (det.get("parking") or {}).get("available"):
142 + parking += 1
143 + if det.get("balcony"):
144 + balcon += 1
145 +
146 + pets_vals = [r["pets"] for r in rows if r["pets"]]
147 + furn = sum(1 for r in rows if r["furnished"])
148 + dispo_now = sum(1 for r in rows if r["availability_date"] == "now")
149 + dispo_date = sum(1 for r in rows
150 + if r["availability_date"] and r["availability_date"] != "now")
151 + aires = [r["area_sqft"] for r in rows if r["area_sqft"]]
152 +
153 + # prix au pi² par taille (annonces ayant les deux)
154 + pi2: dict[str, list] = {}
155 + for r in rows:
156 + if (r["price"] and r["area_sqft"] and r["unit_type"]
157 + and 300 <= r["price"] <= 10000 and r["area_sqft"] >= 200):
158 + pi2.setdefault(r["unit_type"], []).append(r["price"] / r["area_sqft"])
159 + prix_pi2 = [{"key": k, "count": len(v),
160 + "val": round(sum(v) / len(v), 2)}
161 + for k, v in sorted(pi2.items(), key=lambda kv: -len(kv[1]))
162 + if len(v) >= 8][:8]
163 +
164 + offre = {
165 + "furnished_pct": pct(furn, total),
166 + "pets_oui_pct": pct(sum(1 for p in pets_vals if p in ("oui", "conditions")),
167 + len(pets_vals)),
168 + "pets_connu": len(pets_vals),
169 + "chauffage_pct": pct(inc_counts["heating"], total),
170 + "electricite_pct": pct(inc_counts["electricity"], total),
171 + "eau_chaude_pct": pct(inc_counts["hot_water"], total),
172 + "internet_pct": pct(inc_counts["internet"], total),
173 + "clim_pct": pct(ac, total),
174 + "stationnement_pct": pct(parking, total),
175 + "balcon_pct": pct(balcon, total),
176 + "dispo_now": dispo_now,
177 + "dispo_date": dispo_date,
178 + "dispo_inconnue": total - dispo_now - dispo_date,
179 + "superficie_moyenne": round(sum(aires) / len(aires)) if aires else None,
180 + "superficie_connue": len(aires),
181 + "prix_pi2": prix_pi2,
182 + }
183 +
184 + # -- baisses de prix récentes (30 jours) -----------------------------------
185 + baisses = []
186 + for r in con.execute(
187 + """SELECT p1.uid, l.title, l.city, l.price, p1.price nouveau, p1.ts
188 + FROM price_log p1
189 + JOIN listings l ON l.uid = p1.uid AND l.active=1
190 + WHERE p1.ts > ? AND p1.price IS NOT NULL
191 + ORDER BY p1.ts DESC LIMIT 400""", (now - 30 * 86400,)).fetchall():
192 + prev = con.execute(
193 + "SELECT price FROM price_log WHERE uid=? AND ts<? AND price IS NOT NULL"
194 + " ORDER BY ts DESC LIMIT 1", (r["uid"], r["ts"])).fetchone()
195 + if prev and prev["price"] and r["nouveau"] and r["nouveau"] < prev["price"]:
196 + baisses.append({"uid": r["uid"], "title": r["title"],
197 + "city": r["city"], "avant": prev["price"],
198 + "apres": r["nouveau"],
199 + "pct": round(100 * (r["nouveau"] - prev["price"])
200 + / prev["price"], 1)})
201 + baisses.sort(key=lambda b: b["pct"])
202 + baisses = baisses[:12]
203 +
204 + # -- santé des sources ------------------------------------------------------
205 + sync24 = con.execute(
206 + "SELECT COUNT(DISTINCT source) c FROM sync_log WHERE ok=1 AND ts>?",
207 + (now - 86400,)).fetchone()["c"]
208 + alertes = [dict(r) for r in con.execute(
209 + """SELECT source, message, ts FROM sync_log
210 + WHERE ts > ? AND message NOT IN ('ok') ORDER BY ts DESC LIMIT 10""",
211 + (now - 86400,)).fetchall()]
212 + gps_pct = con.execute(
213 + "SELECT ROUND(100.0*SUM(lat IS NOT NULL)/COUNT(*),1) p"
214 + " FROM listings WHERE active=1").fetchone()["p"]
215 +
216 + out = {
217 + "totals": {
218 + "total": total,
219 + "with_price": len(prix),
220 + "avg": round(sum(prix) / len(prix)) if prix else None,
221 + "median": round(_median(prix)) if prix else None,
222 + "min": prix[0] if prix else None,
223 + "max": prix[-1] if prix else None,
224 + "sources": len(by_source),
225 + "cities": len(by_city),
226 + "regions": len([r for r in by_region if r["count"] > 0]),
227 + "gps_pct": gps_pct,
228 + "superficie_moyenne": offre["superficie_moyenne"],
229 + "dispo_now": dispo_now,
230 + },
231 + "histogram": hist,
232 + "by_type": by_type,
233 + "by_city": by_city,
234 + "by_source": by_source,
235 + "by_region": by_region,
236 + "offre": offre,
237 + "baisses": baisses,
238 + "sante": {"sources_sync_24h": sync24, "alertes_24h": alertes},
239 + }
240 + con.close()
241 + return out
modified louka/pdfgen.py +111 −79
@@ -532,41 +532,19 @@ def _region_de(city: str) -> str:
532 532
533 533
534 534 def rapport_pdf() -> bytes:
535 """Rapport global du marché locatif Lou-Ka (multi-pages)."""
536 con = db.connect()
537 rows = con.execute(
538 "SELECT city, source, price, unit_type FROM listings WHERE active=1").fetchall()
535 + """Rapport global du marché locatif Lou-Ka (multi-pages).
536 +
537 + Tous les chiffres viennent de marketstats.compute() — la même source
538 + que la page Statistiques du site.
539 + """
540 + from . import marketstats
541 + st = marketstats.compute()
539 542 reg_file = json.loads((db.DB_PATH.parent / "sources.json").read_text("utf-8"))["sources"]
540 543 noms = {s["id"]: s["name"] for s in reg_file}
541 n_sources = con.execute(
542 "SELECT COUNT(DISTINCT source) c FROM listings WHERE active=1").fetchone()["c"]
543 gps = con.execute(
544 "SELECT ROUND(100.0*SUM(lat IS NOT NULL)/COUNT(*),1) p FROM listings WHERE active=1"
545 ).fetchone()["p"]
546 con.close()
547 544
548 total = len(rows)
549 prix = sorted(r["price"] for r in rows if r["price"] and 300 <= r["price"] <= 10000)
550 moy = round(sum(prix) / len(prix)) if prix else 0
551 med = round(prix[len(prix) // 2]) if prix else 0
552
553 par_region: dict[str, dict] = {}
554 for r in rows:
555 reg = _region_de(r["city"] or "")
556 d = par_region.setdefault(reg, {"n": 0, "prix": [], "sources": set()})
557 d["n"] += 1
558 d["sources"].add(r["source"])
559 if r["price"] and 300 <= r["price"] <= 10000:
560 d["prix"].append(r["price"])
561
562 par_ville: dict[str, int] = {}
563 par_source: dict[str, int] = {}
564 par_type: dict[str, list] = {}
565 for r in rows:
566 par_ville[r["city"] or "?"] = par_ville.get(r["city"] or "?", 0) + 1
567 par_source[r["source"]] = par_source.get(r["source"], 0) + 1
568 if r["unit_type"]:
569 par_type.setdefault(r["unit_type"], []).append(r["price"])
545 + t = st["totals"]
546 + total, moy, med, gps = t["total"], t["avg"] or 0, t["median"] or 0, t["gps_pct"]
547 + n_sources = t["sources"]
570 548
571 549 buf = io.BytesIO()
572 550 c = rl_canvas.Canvas(buf, pagesize=letter)
@@ -579,8 +557,6 @@ def rapport_pdf() -> bytes:
579 557 c.setFont("Helvetica-Bold", 24)
580 558 c.setFillColor(INK)
581 559 c.drawString(M, PAGE_H - 185, "Rapport du marché locatif")
582 c.setFillColor(SURFACE)
583 c.setStrokeColor(INK)
584 560 c.setFont("Courier-Bold", 9)
585 561 c.setFillColor(INK3)
586 562 c.drawString(M, PAGE_H - 205,
@@ -619,24 +595,22 @@ def rapport_pdf() -> bytes:
619 595 c.setLineWidth(1)
620 596 c.line(M, y, PAGE_W - M, y)
621 597 y -= 14
622 ordre = sorted(par_region.items(), key=lambda kv: -kv[1]["n"])
623 max_n = max(d["n"] for _, d in ordre)
624 for nom, d in ordre:
598 + regions = st["by_region"]
599 + max_n = max((r["count"] for r in regions), default=1)
600 + for r in regions:
625 601 c.setFont("Helvetica-Bold", 8.5)
626 602 c.setFillColor(INK)
627 c.drawString(M, y, nom)
628 # barre proportionnelle
603 + c.drawString(M, y, r["key"])
629 604 c.setFillColor(LIME)
630 605 c.setStrokeColor(INK)
631 606 c.setLineWidth(0.7)
632 bw = 90 * d["n"] / max_n
607 + bw = 90 * r["count"] / max_n
633 608 c.roundRect(M + 180, y - 1, max(3, bw), 8, 3, stroke=1, fill=1)
634 609 c.setFont("Helvetica", 8.5)
635 610 c.setFillColor(INK2)
636 c.drawRightString(M + 330, y, f"{d['n']:,}".replace(",", NBSP))
637 c.drawRightString(M + 400, y, str(len(d["sources"])))
638 pm = round(sum(d["prix"]) / len(d["prix"])) if d["prix"] else None
639 c.drawRightString(M + 500, y, _fmt_money(pm) if pm else "—")
611 + c.drawRightString(M + 330, y, f"{r['count']:,}".replace(",", NBSP))
612 + c.drawRightString(M + 400, y, str(r["sources"]))
613 + c.drawRightString(M + 500, y, _fmt_money(r["avg_price"]) if r["avg_price"] else "—")
640 614 y -= 15
641 615 s.pied("Rapport généré automatiquement à partir des annonces publiques "
642 616 "agrégées par Lou-Ka. Loyers : bornes 300–10 000 $.", 1)
@@ -646,38 +620,30 @@ def rapport_pdf() -> bytes:
646 620 s.fond()
647 621 y = s.entete("Rapport du marché · loyers")
648 622 y = s.titre_section(y, "Distribution des loyers")
649 lo, hi, step = 400, 3200, 200
650 classes = [0] * ((hi - lo) // step + 2)
651 for p in prix:
652 if p < lo:
653 classes[0] += 1
654 elif p >= hi:
655 classes[-1] += 1
656 else:
657 classes[1 + int((p - lo) // step)] += 1
658 max_c = max(classes) or 1
623 + hist = st["histogram"]
624 + max_c = max((h["count"] for h in hist), default=1) or 1
659 625 ch_h, ch_y = 150, y - 170
660 bw = (PAGE_W - 2 * M) / len(classes)
661 for i, n in enumerate(classes):
662 bh = ch_h * n / max_c
626 + bw = (PAGE_W - 2 * M) / len(hist)
627 + for i, h in enumerate(hist):
628 + bh = ch_h * h["count"] / max_c
663 629 bx = M + i * bw
664 c.setFillColor(GREEN if i not in (0, len(classes) - 1) else INK3)
630 + borne = h["hi"] is None or h["lo"] == 0
631 + c.setFillColor(INK3 if borne else GREEN)
665 632 c.setStrokeColor(INK)
666 633 c.setLineWidth(0.6)
667 634 c.rect(bx + 2, ch_y, bw - 4, max(1, bh), stroke=1, fill=1)
668 if n and n > max_c * 0.06:
635 + if h["count"] and h["count"] > max_c * 0.06:
669 636 c.setFont("Courier-Bold", 6)
670 637 c.setFillColor(INK)
671 c.drawCentredString(bx + bw / 2, ch_y + bh + 3, str(n))
638 + c.drawCentredString(bx + bw / 2, ch_y + bh + 3, str(h["count"]))
672 639 c.setFont("Helvetica", 5.6)
673 640 c.setFillColor(INK3)
674 lab = "<400" if i == 0 else (f"{hi}+" if i == len(classes) - 1
675 else str(lo + (i - 1) * step))
641 + lab = f"<{h['hi']}" if h["lo"] == 0 else (f"{h['lo']}+" if h["hi"] is None
642 + else str(h["lo"]))
676 643 c.drawCentredString(bx + bw / 2, ch_y - 9, lab)
677 644 y = ch_y - 30
678 645
679 646 y = s.titre_section(y, "Par taille de logement")
680 types = sorted(par_type.items(), key=lambda kv: -len(kv[1]))[:9]
681 647 c.setFont("Courier-Bold", 7)
682 648 c.setFillColor(INK3)
683 649 for lab, xoff in (("TAILLE", 0), ("ANNONCES", 160), ("LOYER MOYEN", 260),
@@ -686,55 +652,121 @@ def rapport_pdf() -> bytes:
686 652 y -= 4
687 653 c.line(M, y, PAGE_W - M, y)
688 654 y -= 13
689 for t, ps in types:
690 pv = [p for p in ps if p and 300 <= p <= 10000]
655 + for r in st["by_type"][:9]:
691 656 c.setFont("Helvetica-Bold", 8.5)
692 657 c.setFillColor(INK)
693 c.drawString(M, y, t)
658 + c.drawString(M, y, r["key"])
694 659 c.setFont("Helvetica", 8.5)
695 660 c.setFillColor(INK2)
696 c.drawRightString(M + 220, y, str(len(ps)))
697 c.drawRightString(M + 330, y, _fmt_money(round(sum(pv) / len(pv))) if pv else "—")
698 c.drawRightString(M + 430, y, _fmt_money(min(pv)) if pv else "—")
661 + c.drawRightString(M + 220, y, str(r["count"]))
662 + c.drawRightString(M + 330, y, _fmt_money(r["avg_price"]) if r["avg_price"] else "—")
663 + c.drawRightString(M + 430, y, _fmt_money(r["min_price"]) if r["min_price"] else "—")
699 664 y -= 13
700 665 s.pied("Lou-Ka — agrégateur indépendant. Chaque annonce renvoie à la "
701 666 "source originale du gestionnaire.", 2)
702 667 c.showPage()
703 668
704 # ---- page 3 : top villes + top gestionnaires
669 + # ---- page 3 : offre, inclusions, prix au pi², baisses de prix
670 + s.fond()
671 + y = s.entete("Rapport du marché · offre")
672 + o = st["offre"]
673 + y = s.titre_section(y, "Inclusions et caractéristiques du parc")
674 + carac = [("Chauffage inclus", o["chauffage_pct"]),
675 + ("Électricité incluse", o["electricite_pct"]),
676 + ("Eau chaude incluse", o["eau_chaude_pct"]),
677 + ("Internet inclus", o["internet_pct"]),
678 + ("Climatisation", o["clim_pct"]),
679 + ("Stationnement", o["stationnement_pct"]),
680 + ("Balcon", o["balcon_pct"]),
681 + ("Meublé", o["furnished_pct"])]
682 + for lab, v in carac:
683 + if v is None:
684 + continue
685 + c.setFont("Helvetica", 8.5)
686 + c.setFillColor(INK2)
687 + c.drawString(M, y, lab)
688 + bx0, bw_ = M + 150, PAGE_W - 2 * M - 200
689 + c.setFillColor(SURFACE)
690 + c.setStrokeColor(INK)
691 + c.setLineWidth(0.8)
692 + c.roundRect(bx0, y - 1, bw_, 8, 4, stroke=1, fill=1)
693 + c.setFillColor(GREEN)
694 + c.roundRect(bx0, y - 1, bw_ * min(1, v / 100), 8, 4, stroke=0, fill=1)
695 + c.setFont("Courier-Bold", 8)
696 + c.setFillColor(INK)
697 + c.drawRightString(PAGE_W - M, y, f"{v}{NBSP}%")
698 + y -= 15
699 + y -= 6
700 + c.setFont("Helvetica", 8)
701 + c.setFillColor(INK3)
702 + c.drawString(M, y, f"Disponibles maintenant : {o['dispo_now']:,} · à date future : "
703 + f"{o['dispo_date']:,} · superficie moyenne : "
704 + f"{o['superficie_moyenne'] or '—'} pi² "
705 + f"({o['superficie_connue']:,} annonces la publient)"
706 + .replace(",", NBSP))
707 + y -= 24
708 +
709 + if o["prix_pi2"]:
710 + y = s.titre_section(y, "Prix au pied carré (loyer / superficie)")
711 + for r in o["prix_pi2"][:6]:
712 + c.setFont("Helvetica-Bold", 8.5)
713 + c.setFillColor(INK)
714 + c.drawString(M, y, r["key"])
715 + c.setFont("Helvetica", 8.5)
716 + c.setFillColor(INK2)
717 + c.drawString(M + 70, y, f"{r['val']:.2f}".replace(".", ",") +
718 + f"{NBSP}$/pi² · {r['count']} annonces")
719 + y -= 13
720 + y -= 10
721 +
722 + if st["baisses"]:
723 + y = s.titre_section(y, "Baisses de prix récentes (30 jours)")
724 + for b in st["baisses"][:8]:
725 + c.setFont("Helvetica", 8.5)
726 + c.setFillColor(INK2)
727 + c.drawString(M, y, f"{(b['title'] or b['uid'])[:46]}{b['city'] or ''}")
728 + c.setFont("Courier-Bold", 8)
729 + c.setFillColor(GREEN)
730 + c.drawRightString(PAGE_W - M, y,
731 + f"{_fmt_money(b['avant'])}{_fmt_money(b['apres'])} "
732 + f"({b['pct']}{NBSP}%)".replace(".", ","))
733 + y -= 13
734 + s.pied("Caractéristiques dérivées des annonces publiées ; les inclusions "
735 + "non mentionnées par une source ne sont pas comptées.", 3)
736 + c.showPage()
737 +
738 + # ---- page 4 : top villes + top gestionnaires
705 739 s.fond()
706 740 y = s.entete("Rapport du marché · détail")
707 741 y = s.titre_section(y, "Top 20 des villes")
708 top_v = sorted(par_ville.items(), key=lambda kv: -kv[1])[:20]
709 742 col_w = (PAGE_W - 2 * M) / 2
710 for i, (v, n) in enumerate(top_v):
743 + for i, r in enumerate(st["by_city"][:20]):
711 744 vx = M if i < 10 else M + col_w
712 745 vy = y - (i % 10) * 13
713 746 c.setFont("Helvetica", 8.5)
714 747 c.setFillColor(INK2)
715 c.drawString(vx, vy, f"{i + 1:>2}. {v}")
748 + c.drawString(vx, vy, f"{i + 1:>2}. {r['key']}")
716 749 c.setFont("Courier-Bold", 8)
717 750 c.setFillColor(INK)
718 c.drawRightString(vx + col_w - 24, vy, f"{n:,}".replace(",", NBSP))
751 + c.drawRightString(vx + col_w - 24, vy, f"{r['count']:,}".replace(",", NBSP))
719 752 y -= 10 * 13 + 16
720 753
721 754 y = s.titre_section(y, "Top 20 des gestionnaires")
722 top_s = sorted(par_source.items(), key=lambda kv: -kv[1])[:20]
723 for i, (sid, n) in enumerate(top_s):
755 + for i, r in enumerate(st["by_source"][:20]):
724 756 vx = M if i < 10 else M + col_w
725 757 vy = y - (i % 10) * 13
726 758 c.setFont("Helvetica", 8.5)
727 759 c.setFillColor(INK2)
728 c.drawString(vx, vy, f"{i + 1:>2}. {noms.get(sid, sid)[:34]}")
760 + c.drawString(vx, vy, f"{i + 1:>2}. {noms.get(r['key'], r['key'])[:34]}")
729 761 c.setFont("Courier-Bold", 8)
730 762 c.setFillColor(INK)
731 c.drawRightString(vx + col_w - 24, vy, f"{n:,}".replace(",", NBSP))
763 + c.drawRightString(vx + col_w - 24, vy, f"{r['count']:,}".replace(",", NBSP))
732 764 y -= 10 * 13 + 20
733 765
734 766 c.setFont("Helvetica", 7.5)
735 767 c.setFillColor(INK3)
736 768 c.drawString(M, y, "Sources de données de quartier : Statistique Canada (Recensement 2021, "
737 769 "licence ouverte), INSPQ (CC-BY 4.0), Ville de Montréal (CC-BY 4.0), OpenStreetMap.")
738 s.pied("© Lou-Ka — www.lou-ka.com · rapport non contractuel, généré automatiquement.", 3)
770 + s.pied("© Lou-Ka — www.lou-ka.com · rapport non contractuel, généré automatiquement.", 4)
739 771 c.save()
740 772 return buf.getvalue()
modified louka/web.py +3 −58
@@ -292,65 +292,10 @@ def stats():
292 292
293 293 @app.get("/api/stats/detailed")
294 294 def stats_detailed():
295 """Agrégations pour la page Statistiques : loyers, types, villes, gestionnaires."""
296 con = db.connect()
297 prices = [r["price"] for r in con.execute(
298 "SELECT price FROM listings WHERE active=1 AND price IS NOT NULL"
299 " AND price BETWEEN 300 AND 10000 ORDER BY price")]
300
301 def median(v):
302 n = len(v)
303 if n == 0:
304 return None
305 return v[n // 2] if n % 2 else (v[n // 2 - 1] + v[n // 2]) / 2
306
307 # Histogramme des loyers : classes de 200 $ de 400 à 3200, + dépassement
308 lo, hi, step = 400, 3200, 200
309 edges = list(range(lo, hi + step, step))
310 hist = [{"lo": a, "hi": a + step, "count": 0} for a in edges[:-1]]
311 under = over = 0
312 for p in prices:
313 if p < lo:
314 under += 1
315 elif p >= hi:
316 over += 1
317 else:
318 hist[int((p - lo) // step)]["count"] += 1
319 if under:
320 hist.insert(0, {"lo": 0, "hi": lo, "count": under})
321 if over:
322 hist.append({"lo": hi, "hi": None, "count": over})
323
324 def group(col):
325 rows = con.execute(
326 f"""SELECT {col} k, COUNT(*) n, AVG(price) avg_price,
327 MIN(price) min_price
328 FROM listings WHERE active=1 AND {col}<>''
329 GROUP BY {col} ORDER BY n DESC""").fetchall()
330 return [{"key": r["k"], "count": r["n"],
331 "avg_price": round(r["avg_price"]) if r["avg_price"] else None,
332 "min_price": r["min_price"]} for r in rows]
295 + """Agrégats du marché (source unique : louka/marketstats.py)."""
296 + from . import marketstats
297 + return marketstats.compute()
333 298
334 out = {
335 "totals": {
336 "total": con.execute("SELECT COUNT(*) c FROM listings WHERE active=1").fetchone()["c"],
337 "with_price": len(prices),
338 "avg": round(sum(prices) / len(prices)) if prices else None,
339 "median": round(median(prices)) if prices else None,
340 "min": prices[0] if prices else None,
341 "max": prices[-1] if prices else None,
342 "sources": con.execute(
343 "SELECT COUNT(DISTINCT source) c FROM listings WHERE active=1").fetchone()["c"],
344 "cities": con.execute(
345 "SELECT COUNT(DISTINCT city) c FROM listings WHERE active=1 AND city<>''").fetchone()["c"],
346 },
347 "histogram": hist,
348 "by_type": group("unit_type"),
349 "by_city": group("city"),
350 "by_source": group("source"),
351 }
352 con.close()
353 return out
354 299
355 300
356 301 @app.post("/api/sync")
357 302