SPB Git

spb/lou-ka

Public

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

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 (Aug 9, 2026) parent 67f8441 Browse files

6 changed files +706 −311

modified frontend/src/api.ts +26 −0
@@ -175,6 +175,7 @@ export function fetchListings(f: ListingFilters) {
175 export interface GroupStat { 175 export interface GroupStat {
176 key: string; 176 key: string;
177 count: number; 177 count: number;
178+ sources?: number;
178 avg_price: number | null; 179 avg_price: number | null;
179 min_price: number | null; 180 min_price: number | null;
180 } 181 }
@@ -189,11 +190,36 @@ export interface DetailedStats {
189 max: number | null; 190 max: number | null;
190 sources: number; 191 sources: number;
191 cities: number; 192 cities: number;
193+ regions: number;
194+ gps_pct: number | null;
195+ superficie_moyenne: number | null;
196+ dispo_now: number;
192 }; 197 };
193 histogram: { lo: number; hi: number | null; count: number }[]; 198 histogram: { lo: number; hi: number | null; count: number }[];
194 by_type: GroupStat[]; 199 by_type: GroupStat[];
195 by_city: GroupStat[]; 200 by_city: GroupStat[];
196 by_source: GroupStat[]; 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 export const fetchDetailedStats = () => get<DetailedStats>("/api/stats/detailed"); 225 export const fetchDetailedStats = () => get<DetailedStats>("/api/stats/detailed");
modified frontend/src/pages/Stats.tsx +289 −174
@@ -1,12 +1,12 @@
1 // ----------------------------------------------------------------------------- 1 // -----------------------------------------------------------------------------
2 // Lou-Ka — Agrégateur de logements à louer (province de Québec) 2 // Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai 3 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4−// pages/Stats.tsx : page Statistiques — tuiles héro, histogramme des loyers, 4+// pages/Stats.tsx : observatoire du marché — 5 onglets (Vue d'ensemble, Loyers,
5−// répartitions par type / ville / gestionnaire (mono-série, encre verte, 5+// Régions & villes, Offre, Gestionnaires), tuiles héro, histogramme,
6−// étiquettes directes, infobulles au survol, vue tableau par graphique) 6+// barres mono-série, baisses de prix, santé des sources. Rapport PDF global.
7 // ----------------------------------------------------------------------------- 7 // -----------------------------------------------------------------------------
8 import { useEffect, useMemo, useState } from "react"; 8 import { useEffect, useMemo, useState } from "react";
9−import { Link } from "react-router-dom"; 9+import { Link, useSearchParams } from "react-router-dom";
10 import { 10 import {
11 DetailedStats, GroupStat, 11 DetailedStats, GroupStat,
12 fetchDetailedStats, fetchSources, registerSourceNames, sourceName, 12 fetchDetailedStats, fetchSources, registerSourceNames, sourceName,
@@ -16,6 +16,15 @@ const fmt = (n: number | null | undefined) =>
16 n == null ? "—" : n.toLocaleString("fr-CA"); 16 n == null ? "—" : n.toLocaleString("fr-CA");
17 const fmt$ = (n: number | null | undefined) => (n == null ? "—" : `${fmt(n)} $`); 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 // ---- infobulle partagée ------------------------------------------------------ 28 // ---- infobulle partagée ------------------------------------------------------
20 interface Tip { x: number; y: number; title: string; lines: string[]; } 29 interface Tip { x: number; y: number; title: string; lines: string[]; }
21 30
@@ -25,47 +34,30 @@ function useTooltip() {
25 setTip({ x: e.clientX, y: e.clientY, title, lines }); 34 setTip({ x: e.clientX, y: e.clientY, title, lines });
26 const hide = () => setTip(null); 35 const hide = () => setTip(null);
27 const node = tip && ( 36 const node = tip && (
28− <div 37+ <div className="viz-tip" role="status"
29− className="viz-tip" 38+ style={{ left: Math.min(tip.x + 14, window.innerWidth - 190), top: tip.y + 14 }}>
30− style={{
31− left: Math.min(tip.x + 14, window.innerWidth - 190),
32− top: tip.y + 14,
33− }}
34− role="status"
35− >
36 <div className="viz-tip-title">{tip.title}</div> 39 <div className="viz-tip-title">{tip.title}</div>
37− {tip.lines.map((l) => ( 40+ {tip.lines.map((l) => <div key={l}>{l}</div>)}
38− <div key={l}>{l}</div>
39− ))}
40 </div> 41 </div>
41 ); 42 );
42 return { show, hide, node }; 43 return { show, hide, node };
43 } 44 }
44 45
45 // ---- barres horizontales (une série) ---------------------------------------- 46 // ---- barres horizontales (une série) ----------------------------------------
46−function HBars({ 47+function HBars({ data, unit, tip }: {
47− data, unit, linkPrefix, tip,
48−}: {
49 data: { label: string; count: number; avg: number | null; href?: string }[]; 48 data: { label: string; count: number; avg: number | null; href?: string }[];
50 unit: string; 49 unit: string;
51− linkPrefix?: string;
52 tip: ReturnType<typeof useTooltip>; 50 tip: ReturnType<typeof useTooltip>;
53 }) { 51 }) {
54 const max = Math.max(...data.map((d) => d.count), 1); 52 const max = Math.max(...data.map((d) => d.count), 1);
55 return ( 53 return (
56 <div className="hbars"> 54 <div className="hbars">
57 {data.map((d) => ( 55 {data.map((d) => (
58− <div 56+ <div className="hbar-row" key={d.label}
59− className="hbar-row" 57+ onMouseMove={(e) => tip.show(e, d.label, [
60− key={d.label} 58+ `${fmt(d.count)} ${unit}`,
61− onMouseMove={(e) => 59+ d.avg != null ? `loyer moyen ${fmt$(d.avg)}` : "loyer non affiché"])}
62− tip.show(e, d.label, [ 60+ onMouseLeave={tip.hide}>
63− `${fmt(d.count)} ${unit}`,
64− d.avg != null ? `loyer moyen ${fmt$(d.avg)}` : "loyer non affiché",
65− ])
66− }
67− onMouseLeave={tip.hide}
68− >
69 <span className="hbar-label" title={d.label}> 61 <span className="hbar-label" title={d.label}>
70 {d.href ? <Link to={d.href}>{d.label}</Link> : d.label} 62 {d.href ? <Link to={d.href}>{d.label}</Link> : d.label}
71 </span> 63 </span>
@@ -73,9 +65,25 @@ function HBars({
73 <span className="hbar-fill" style={{ width: `${(d.count / max) * 100}%` }} /> 65 <span className="hbar-fill" style={{ width: `${(d.count / max) * 100}%` }} />
74 </span> 66 </span>
75 <span className="hbar-value"> 67 <span className="hbar-value">
76− {fmt(d.count)} 68+ {fmt(d.count)}{d.avg != null && <em> · {fmt$(d.avg)}</em>}
77− {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 </span> 85 </span>
86+ <span className="hbar-value">{d.pct!.toLocaleString("fr-CA")} %</span>
79 </div> 87 </div>
80 ))} 88 ))}
81 </div> 89 </div>
@@ -93,10 +101,8 @@ function DataTable({ rows, unit }: { rows: GroupStat[]; unit: string }) {
93 <tbody> 101 <tbody>
94 {rows.map((r) => ( 102 {rows.map((r) => (
95 <tr key={r.key}> 103 <tr key={r.key}>
96− <td>{r.key}</td> 104+ <td>{r.key}</td><td>{fmt(r.count)}</td>
97− <td>{fmt(r.count)}</td> 105+ <td>{fmt$(r.avg_price)}</td><td>{fmt$(r.min_price)}</td>
98− <td>{fmt$(r.avg_price)}</td>
99− <td>{fmt$(r.min_price)}</td>
100 </tr> 106 </tr>
101 ))} 107 ))}
102 </tbody> 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 // ---- page -------------------------------------------------------------------- 123 // ---- page --------------------------------------------------------------------
109 export default function StatsPage() { 124 export default function StatsPage() {
110 const [d, setD] = useState<DetailedStats | null>(null); 125 const [d, setD] = useState<DetailedStats | null>(null);
111 const [error, setError] = useState<string | null>(null); 126 const [error, setError] = useState<string | null>(null);
127+ const [params, setParams] = useSearchParams();
128+ const onglet = (params.get("onglet") as OngletId) || "ensemble";
112 const tip = useTooltip(); 129 const tip = useTooltip();
113 130
114 useEffect(() => { 131 useEffect(() => {
@@ -117,9 +134,7 @@ export default function StatsPage() {
117 }, []); 134 }, []);
118 135
119 const histMax = useMemo( 136 const histMax = useMemo(
120− () => Math.max(...(d?.histogram.map((h) => h.count) ?? [1]), 1), 137+ () => Math.max(...(d?.histogram.map((h) => h.count) ?? [1]), 1), [d]);
121− [d]
122− );
123 138
124 if (error) 139 if (error)
125 return ( 140 return (
@@ -139,29 +154,46 @@ export default function StatsPage() {
139 ); 154 );
140 155
141 const t = d.totals; 156 const t = d.totals;
142− const types = d.by_type.slice(0, 9); 157+ const o = d.offre;
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);
147 const fold = (rest: GroupStat[]): GroupStat | null => 158 const fold = (rest: GroupStat[]): GroupStat | null =>
148− rest.length === 0 159+ rest.length === 0 ? null : {
149− ? null 160+ key: `Autres (${rest.length})`,
150− : { 161+ count: rest.reduce((s, r) => s + r.count, 0),
151− key: `Autres (${rest.length})`, 162+ avg_price: null, min_price: null,
152− count: rest.reduce((s, r) => s + r.count, 0), 163+ };
153− avg_price: null, 164+
154− min_price: null, 165+ const histogramme = (
155− }; 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 return ( 189 return (
158 <div className="container stats-page"> 190 <div className="container stats-page">
159 {tip.node} 191 {tip.node}
160− <span className="kicker">Observatoire — marché locatif</span> 192+ <span className="kicker">Observatoire — marché locatif québécois</span>
161 <h1 className="stats-title">Le marché, en chiffres</h1> 193 <h1 className="stats-title">Le marché, en chiffres</h1>
162 <p className="sub"> 194 <p className="sub">
163− Calculé en direct sur les {fmt(t.total)} annonces actives agrégées par Lou-Ka. 195+ Calculé en direct sur les {fmt(t.total)} annonces actives de {fmt(t.sources)} gestionnaires,
164− Les loyers « à partir de » des sources sont utilisés tels quels. 196+ dans {fmt(t.cities)} villes et {fmt(t.regions)} régions.
165 </p> 197 </p>
166 <p> 198 <p>
167 <a className="btn btn-primary" href="/api/stats/rapport.pdf" download> 199 <a className="btn btn-primary" href="/api/stats/rapport.pdf" download>
@@ -169,132 +201,215 @@ export default function StatsPage() {
169 </a> 201 </a>
170 </p> 202 </p>
171 203
172− <div className="tiles"> 204+ {/* barre d'onglets */}
173− <div className="tile hero-tile"> 205+ <nav className="onglets" role="tablist" aria-label="Sections des statistiques">
174− <div className="tile-v">{fmt(t.total)}</div> 206+ {ONGLETS.map((g) => (
175− <div className="tile-k">logements actifs</div> 207+ <button key={g.id} role="tab" aria-selected={onglet === g.id}
176− </div> 208+ className={`onglet ${onglet === g.id ? "on" : ""}`}
177− <div className="tile"> 209+ onClick={() => setParams(g.id === "ensemble" ? {} : { onglet: g.id })}>
178− <div className="tile-v">{fmt$(t.median)}</div> 210+ <span aria-hidden="true">{g.icon}</span> {g.label}
179− <div className="tile-k">loyer médian</div> 211+ </button>
180− </div> 212+ ))}
181− <div className="tile"> 213+ </nav>
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>
198 214
199− <section className="viz-card"> 215+ {/* ============ Vue d'ensemble ============ */}
200− <h2>Distribution des loyers</h2> 216+ {onglet === "ensemble" && (
201− <p className="viz-sub"> 217+ <>
202− {fmt(t.with_price)} annonces avec prix affiché — classes de 200&nbsp;$ 218+ <div className="tiles">
203− </p> 219+ <Tile hero v={fmt(t.total)} k="logements actifs" />
204− <div className="histo" role="img" aria-label="Histogramme de la distribution des loyers mensuels"> 220+ <Tile v={fmt$(t.median)} k="loyer médian" />
205− {d.histogram.map((h) => ( 221+ <Tile v={fmt$(t.avg)} k="loyer moyen" />
206− <div 222+ <Tile v={fmt(t.dispo_now)} k="libres maintenant" />
207− className="histo-col" 223+ <Tile v={t.superficie_moyenne ? `${fmt(t.superficie_moyenne)} pi²` : "—"} k="superficie moyenne" />
208− key={`${h.lo}`} 224+ <Tile v={t.gps_pct != null ? `${t.gps_pct} %` : "—"} k="géolocalisées" />
209− onMouseMove={(e) => 225+ </div>
210− tip.show( 226+ <section className="viz-card">
211− e, 227+ <h2>Couverture par région</h2>
212− h.hi ? `${fmt(h.lo)} – ${fmt(h.hi)} $` : `${fmt(h.lo)} $ et plus`, 228+ <p className="viz-sub">annonces actives · loyer moyen régional</p>
213− [`${fmt(h.count)} logements`, 229+ <HBars tip={tip} unit="logements"
214− `${((h.count / Math.max(t.with_price, 1)) * 100).toFixed(1)} % du parc`] 230+ data={d.by_region.map((r) => ({ label: r.key, count: r.count, avg: r.avg_price }))} />
215− ) 231+ <DataTable rows={d.by_region} unit="Logements" />
216− } 232+ </section>
217− onMouseLeave={tip.hide} 233+ {histogramme}
218− > 234+ </>
219− <div className="histo-bar-zone"> 235+ )}
220− <div className="histo-bar" style={{ height: `${(h.count / histMax) * 100}%` }} /> 236+
221− </div> 237+ {/* ============ Loyers ============ */}
222− <div className="histo-x"> 238+ {onglet === "loyers" && (
223− {h.lo % 400 === 0 ? (h.lo >= 1000 ? `${h.lo / 1000}k` : h.lo) : ""} 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 </div> 275 </div>
225− </div> 276+ </section>
226− ))} 277+ </div>
227− </div> 278+ {d.baisses.length > 0 && (
228− <details className="viz-table"> 279+ <section className="viz-card">
229− <summary>Voir les données</summary> 280+ <h2>Baisses de prix récentes 📉</h2>
230− <table> 281+ <p className="viz-sub">30 derniers jours — leviers de négociation</p>
231− <thead><tr><th>Classe</th><th>Logements</th></tr></thead> 282+ <ul className="baisses">
232− <tbody> 283+ {d.baisses.map((b) => (
233− {d.histogram.map((h) => ( 284+ <li key={b.uid}>
234− <tr key={`t${h.lo}`}> 285+ <Link to={`/logement/${encodeURIComponent(b.uid)}`}>
235− <td>{h.hi ? `${fmt(h.lo)} – ${fmt(h.hi)} $` : `${fmt(h.lo)} $ +`}</td> 286+ {b.title || b.uid}
236− <td>{fmt(h.count)}</td> 287+ </Link>
237− </tr> 288+ <span className="baisse-ville">{b.city}</span>
238− ))} 289+ <span className="baisse-prix">
239− </tbody> 290+ <s>{fmt$(b.avant)}</s> → <b>{fmt$(b.apres)}</b>
240− </table> 291+ <em className="baisse-pct">{b.pct.toLocaleString("fr-CA")} %</em>
241− </details> 292+ </span>
242− </section> 293+ </li>
294+ ))}
295+ </ul>
296+ </section>
297+ )}
298+ </>
299+ )}
243 300
244− <div className="viz-grid"> 301+ {/* ============ Régions & villes ============ */}
245− <section className="viz-card"> 302+ {onglet === "regions" && (
246− <h2>Par taille de logement</h2> 303+ <>
247− <p className="viz-sub">nombre d'annonces · loyer moyen</p> 304+ <section className="viz-card">
248− <HBars 305+ <h2>Par région</h2>
249− tip={tip} 306+ <p className="viz-sub">annonces · gestionnaires · loyer moyen</p>
250− unit="logements" 307+ <HBars tip={tip} unit="logements"
251− data={types.map((r) => ({ 308+ data={d.by_region.map((r) => ({ label: r.key, count: r.count, avg: r.avg_price }))} />
252− label: r.key, count: r.count, avg: r.avg_price, 309+ <details className="viz-table" open>
253− href: `/?unit_type=${encodeURIComponent(r.key)}`, 310+ <summary>Voir les données</summary>
254− }))} 311+ <table>
255− /> 312+ <thead><tr><th>Région</th><th>Annonces</th><th>Sources</th><th>Loyer moyen</th></tr></thead>
256− <DataTable rows={d.by_type} unit="Logements" /> 313+ <tbody>
257− </section> 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"> 339+ {/* ============ L'offre ============ */}
260− <h2>Par ville</h2> 340+ {onglet === "offre" && (
261− <p className="viz-sub">top {cities.length} — nombre d'annonces · loyer moyen</p> 341+ <>
262− <HBars 342+ <div className="tiles">
263− tip={tip} 343+ <Tile hero v={fmt(o.dispo_now)} k="libres maintenant" />
264− unit="logements" 344+ <Tile v={fmt(o.dispo_date)} k="libres à date future" />
265− data={[...cities.map((r) => ({ 345+ <Tile v={o.superficie_moyenne ? `${fmt(o.superficie_moyenne)} pi²` : "—"} k="superficie moyenne" />
266− label: r.key, count: r.count, avg: r.avg_price, 346+ <Tile v={o.pets_oui_pct != null ? `${o.pets_oui_pct} %` : "—"}
267− href: `/?city=${encodeURIComponent(r.key)}`, 347+ k={`acceptent les animaux (sur ${fmt(o.pets_connu)} précisées)`} />
268− })), ...(fold(citiesRest) ? [{ 348+ </div>
269− label: fold(citiesRest)!.key, count: fold(citiesRest)!.count, avg: null, 349+ <section className="viz-card">
270− }] : [])]} 350+ <h2>Inclusions et caractéristiques</h2>
271− /> 351+ <p className="viz-sub">part du parc dont la source confirme l'inclusion — le reste est inconnu, pas absent</p>
272− <DataTable rows={d.by_city} unit="Logements" /> 352+ <PctBars data={[
273− </section> 353+ { label: "Balcon", pct: o.balcon_pct },
274− </div> 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"> 373+ {/* ============ Gestionnaires ============ */}
277− <h2>Par gestionnaire immobilier</h2> 374+ {onglet === "gestionnaires" && (
278− <p className="viz-sub">top {sources.length} — nombre d'annonces · loyer moyen</p> 375+ <>
279− <HBars 376+ <div className="tiles">
280− tip={tip} 377+ <Tile hero v={fmt(t.sources)} k="gestionnaires connectés" />
281− unit="logements" 378+ <Tile v={fmt(d.sante.sources_sync_24h)} k="synchronisés (24 h)" />
282− data={[...sources.map((r) => ({ 379+ <Tile v={fmt(d.sante.alertes_24h.length)} k="alertes (24 h)" />
283− label: sourceName(r.key), count: r.count, avg: r.avg_price, 380+ </div>
284− href: `/?source=${encodeURIComponent(r.key)}`, 381+ <section className="viz-card">
285− })), ...(fold(sourcesRest) ? [{ 382+ <h2>Par gestionnaire immobilier</h2>
286− label: fold(sourcesRest)!.key, count: fold(sourcesRest)!.count, avg: null, 383+ <p className="viz-sub">top 20 — nombre d'annonces · loyer moyen</p>
287− }] : [])]} 384+ <HBars tip={tip} unit="logements"
288− /> 385+ data={[...d.by_source.slice(0, 20).map((r) => ({
289− <DataTable 386+ label: sourceName(r.key), count: r.count, avg: r.avg_price,
290− rows={d.by_source.map((r) => ({ ...r, key: sourceName(r.key) }))} 387+ href: `/?source=${encodeURIComponent(r.key)}` })),
291− unit="Logements" 388+ ...(fold(d.by_source.slice(20))
292− /> 389+ ? [{ label: fold(d.by_source.slice(20))!.key,
293− </section> 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 <p className="stats-foot"> 410 <p className="stats-foot">
296− Données recalculées à chaque synchronisation (horaire). Les catégories renvoient 411+ Données recalculées à chaque synchronisation (horaire). Les catégories
297− vers les logements filtrés correspondants. 412+ renvoient vers les logements filtrés correspondants.
298 </p> 413 </p>
299 </div> 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 /* --- boutons PDF -------------------------------------------------------- */ 930 /* --- boutons PDF -------------------------------------------------------- */
931 .btn-pdf { display: block; text-align: center; margin-top: 10px; width: 100%; } 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 def rapport_pdf() -> bytes: 534 def rapport_pdf() -> bytes:
535− """Rapport global du marché locatif Lou-Ka (multi-pages).""" 535+ """Rapport global du marché locatif Lou-Ka (multi-pages).
536− con = db.connect() 536+
537− rows = con.execute( 537+ Tous les chiffres viennent de marketstats.compute() — la même source
538− "SELECT city, source, price, unit_type FROM listings WHERE active=1").fetchall() 538+ que la page Statistiques du site.
539+ """
540+ from . import marketstats
541+ st = marketstats.compute()
539 reg_file = json.loads((db.DB_PATH.parent / "sources.json").read_text("utf-8"))["sources"] 542 reg_file = json.loads((db.DB_PATH.parent / "sources.json").read_text("utf-8"))["sources"]
540 noms = {s["id"]: s["name"] for s in reg_file} 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) 545+ t = st["totals"]
549− prix = sorted(r["price"] for r in rows if r["price"] and 300 <= r["price"] <= 10000) 546+ total, moy, med, gps = t["total"], t["avg"] or 0, t["median"] or 0, t["gps_pct"]
550− moy = round(sum(prix) / len(prix)) if prix else 0 547+ n_sources = t["sources"]
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"])
570 548
571 buf = io.BytesIO() 549 buf = io.BytesIO()
572 c = rl_canvas.Canvas(buf, pagesize=letter) 550 c = rl_canvas.Canvas(buf, pagesize=letter)
@@ -579,8 +557,6 @@ def rapport_pdf() -> bytes:
579 c.setFont("Helvetica-Bold", 24) 557 c.setFont("Helvetica-Bold", 24)
580 c.setFillColor(INK) 558 c.setFillColor(INK)
581 c.drawString(M, PAGE_H - 185, "Rapport du marché locatif") 559 c.drawString(M, PAGE_H - 185, "Rapport du marché locatif")
582− c.setFillColor(SURFACE)
583− c.setStrokeColor(INK)
584 c.setFont("Courier-Bold", 9) 560 c.setFont("Courier-Bold", 9)
585 c.setFillColor(INK3) 561 c.setFillColor(INK3)
586 c.drawString(M, PAGE_H - 205, 562 c.drawString(M, PAGE_H - 205,
@@ -619,24 +595,22 @@ def rapport_pdf() -> bytes:
619 c.setLineWidth(1) 595 c.setLineWidth(1)
620 c.line(M, y, PAGE_W - M, y) 596 c.line(M, y, PAGE_W - M, y)
621 y -= 14 597 y -= 14
622− ordre = sorted(par_region.items(), key=lambda kv: -kv[1]["n"]) 598+ regions = st["by_region"]
623− max_n = max(d["n"] for _, d in ordre) 599+ max_n = max((r["count"] for r in regions), default=1)
624− for nom, d in ordre: 600+ for r in regions:
625 c.setFont("Helvetica-Bold", 8.5) 601 c.setFont("Helvetica-Bold", 8.5)
626 c.setFillColor(INK) 602 c.setFillColor(INK)
627− c.drawString(M, y, nom) 603+ c.drawString(M, y, r["key"])
628− # barre proportionnelle
629 c.setFillColor(LIME) 604 c.setFillColor(LIME)
630 c.setStrokeColor(INK) 605 c.setStrokeColor(INK)
631 c.setLineWidth(0.7) 606 c.setLineWidth(0.7)
632− bw = 90 * d["n"] / max_n 607+ bw = 90 * r["count"] / max_n
633 c.roundRect(M + 180, y - 1, max(3, bw), 8, 3, stroke=1, fill=1) 608 c.roundRect(M + 180, y - 1, max(3, bw), 8, 3, stroke=1, fill=1)
634 c.setFont("Helvetica", 8.5) 609 c.setFont("Helvetica", 8.5)
635 c.setFillColor(INK2) 610 c.setFillColor(INK2)
636− c.drawRightString(M + 330, y, f"{d['n']:,}".replace(",", NBSP)) 611+ c.drawRightString(M + 330, y, f"{r['count']:,}".replace(",", NBSP))
637− c.drawRightString(M + 400, y, str(len(d["sources"]))) 612+ c.drawRightString(M + 400, y, str(r["sources"]))
638− pm = round(sum(d["prix"]) / len(d["prix"])) if d["prix"] else None 613+ c.drawRightString(M + 500, y, _fmt_money(r["avg_price"]) if r["avg_price"] else "—")
639− c.drawRightString(M + 500, y, _fmt_money(pm) if pm else "—")
640 y -= 15 614 y -= 15
641 s.pied("Rapport généré automatiquement à partir des annonces publiques " 615 s.pied("Rapport généré automatiquement à partir des annonces publiques "
642 "agrégées par Lou-Ka. Loyers : bornes 300–10 000 $.", 1) 616 "agrégées par Lou-Ka. Loyers : bornes 300–10 000 $.", 1)
@@ -646,38 +620,30 @@ def rapport_pdf() -> bytes:
646 s.fond() 620 s.fond()
647 y = s.entete("Rapport du marché · loyers") 621 y = s.entete("Rapport du marché · loyers")
648 y = s.titre_section(y, "Distribution des loyers") 622 y = s.titre_section(y, "Distribution des loyers")
649− lo, hi, step = 400, 3200, 200 623+ hist = st["histogram"]
650− classes = [0] * ((hi - lo) // step + 2) 624+ max_c = max((h["count"] for h in hist), default=1) or 1
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
659 ch_h, ch_y = 150, y - 170 625 ch_h, ch_y = 150, y - 170
660− bw = (PAGE_W - 2 * M) / len(classes) 626+ bw = (PAGE_W - 2 * M) / len(hist)
661− for i, n in enumerate(classes): 627+ for i, h in enumerate(hist):
662− bh = ch_h * n / max_c 628+ bh = ch_h * h["count"] / max_c
663 bx = M + i * bw 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 c.setStrokeColor(INK) 632 c.setStrokeColor(INK)
666 c.setLineWidth(0.6) 633 c.setLineWidth(0.6)
667 c.rect(bx + 2, ch_y, bw - 4, max(1, bh), stroke=1, fill=1) 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 c.setFont("Courier-Bold", 6) 636 c.setFont("Courier-Bold", 6)
670 c.setFillColor(INK) 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 c.setFont("Helvetica", 5.6) 639 c.setFont("Helvetica", 5.6)
673 c.setFillColor(INK3) 640 c.setFillColor(INK3)
674− lab = "<400" if i == 0 else (f"{hi}+" if i == len(classes) - 1 641+ lab = f"<{h['hi']}" if h["lo"] == 0 else (f"{h['lo']}+" if h["hi"] is None
675− else str(lo + (i - 1) * step)) 642+ else str(h["lo"]))
676 c.drawCentredString(bx + bw / 2, ch_y - 9, lab) 643 c.drawCentredString(bx + bw / 2, ch_y - 9, lab)
677 y = ch_y - 30 644 y = ch_y - 30
678 645
679 y = s.titre_section(y, "Par taille de logement") 646 y = s.titre_section(y, "Par taille de logement")
680− types = sorted(par_type.items(), key=lambda kv: -len(kv[1]))[:9]
681 c.setFont("Courier-Bold", 7) 647 c.setFont("Courier-Bold", 7)
682 c.setFillColor(INK3) 648 c.setFillColor(INK3)
683 for lab, xoff in (("TAILLE", 0), ("ANNONCES", 160), ("LOYER MOYEN", 260), 649 for lab, xoff in (("TAILLE", 0), ("ANNONCES", 160), ("LOYER MOYEN", 260),
@@ -686,55 +652,121 @@ def rapport_pdf() -> bytes:
686 y -= 4 652 y -= 4
687 c.line(M, y, PAGE_W - M, y) 653 c.line(M, y, PAGE_W - M, y)
688 y -= 13 654 y -= 13
689− for t, ps in types: 655+ for r in st["by_type"][:9]:
690− pv = [p for p in ps if p and 300 <= p <= 10000]
691 c.setFont("Helvetica-Bold", 8.5) 656 c.setFont("Helvetica-Bold", 8.5)
692 c.setFillColor(INK) 657 c.setFillColor(INK)
693− c.drawString(M, y, t) 658+ c.drawString(M, y, r["key"])
694 c.setFont("Helvetica", 8.5) 659 c.setFont("Helvetica", 8.5)
695 c.setFillColor(INK2) 660 c.setFillColor(INK2)
696− c.drawRightString(M + 220, y, str(len(ps))) 661+ c.drawRightString(M + 220, y, str(r["count"]))
697− c.drawRightString(M + 330, y, _fmt_money(round(sum(pv) / len(pv))) if pv else "—") 662+ c.drawRightString(M + 330, y, _fmt_money(r["avg_price"]) if r["avg_price"] else "—")
698− c.drawRightString(M + 430, y, _fmt_money(min(pv)) if pv else "—") 663+ c.drawRightString(M + 430, y, _fmt_money(r["min_price"]) if r["min_price"] else "—")
699 y -= 13 664 y -= 13
700 s.pied("Lou-Ka — agrégateur indépendant. Chaque annonce renvoie à la " 665 s.pied("Lou-Ka — agrégateur indépendant. Chaque annonce renvoie à la "
701 "source originale du gestionnaire.", 2) 666 "source originale du gestionnaire.", 2)
702 c.showPage() 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 s.fond() 739 s.fond()
706 y = s.entete("Rapport du marché · détail") 740 y = s.entete("Rapport du marché · détail")
707 y = s.titre_section(y, "Top 20 des villes") 741 y = s.titre_section(y, "Top 20 des villes")
708− top_v = sorted(par_ville.items(), key=lambda kv: -kv[1])[:20]
709 col_w = (PAGE_W - 2 * M) / 2 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 vx = M if i < 10 else M + col_w 744 vx = M if i < 10 else M + col_w
712 vy = y - (i % 10) * 13 745 vy = y - (i % 10) * 13
713 c.setFont("Helvetica", 8.5) 746 c.setFont("Helvetica", 8.5)
714 c.setFillColor(INK2) 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 c.setFont("Courier-Bold", 8) 749 c.setFont("Courier-Bold", 8)
717 c.setFillColor(INK) 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 y -= 10 * 13 + 16 752 y -= 10 * 13 + 16
720 753
721 y = s.titre_section(y, "Top 20 des gestionnaires") 754 y = s.titre_section(y, "Top 20 des gestionnaires")
722− top_s = sorted(par_source.items(), key=lambda kv: -kv[1])[:20] 755+ for i, r in enumerate(st["by_source"][:20]):
723− for i, (sid, n) in enumerate(top_s):
724 vx = M if i < 10 else M + col_w 756 vx = M if i < 10 else M + col_w
725 vy = y - (i % 10) * 13 757 vy = y - (i % 10) * 13
726 c.setFont("Helvetica", 8.5) 758 c.setFont("Helvetica", 8.5)
727 c.setFillColor(INK2) 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 c.setFont("Courier-Bold", 8) 761 c.setFont("Courier-Bold", 8)
730 c.setFillColor(INK) 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 y -= 10 * 13 + 20 764 y -= 10 * 13 + 20
733 765
734 c.setFont("Helvetica", 7.5) 766 c.setFont("Helvetica", 7.5)
735 c.setFillColor(INK3) 767 c.setFillColor(INK3)
736 c.drawString(M, y, "Sources de données de quartier : Statistique Canada (Recensement 2021, " 768 c.drawString(M, y, "Sources de données de quartier : Statistique Canada (Recensement 2021, "
737 "licence ouverte), INSPQ (CC-BY 4.0), Ville de Montréal (CC-BY 4.0), OpenStreetMap.") 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 c.save() 771 c.save()
740 return buf.getvalue() 772 return buf.getvalue()
modified louka/web.py +3 −58
@@ -292,65 +292,10 @@ def stats():
292 292
293 @app.get("/api/stats/detailed") 293 @app.get("/api/stats/detailed")
294 def stats_detailed(): 294 def stats_detailed():
295− """Agrégations pour la page Statistiques : loyers, types, villes, gestionnaires.""" 295+ """Agrégats du marché (source unique : louka/marketstats.py)."""
296− con = db.connect() 296+ from . import marketstats
297− prices = [r["price"] for r in con.execute( 297+ return marketstats.compute()
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]
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 @app.post("/api/sync") 301 @app.post("/api/sync")
357 302
Menu

Theme