SPB Git

spb/lou-ka Public

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

HTML 99.7%

Page Statistiques + extension Grand Montréal (68 connecteurs)

- Page /stats : tuiles héro (total, loyer médian/moyen, villes, gestionnaires),
  histogramme des loyers (classes de 200$), répartitions par taille, ville et
  gestionnaire — barres mono-série encre verte, infobulles, étiquettes directes,
  vue tableau par graphique, catégories cliquables vers les logements filtrés
- 38 connecteurs Grand Montréal (REITs, promoteurs, gestionnaires) + extension
  de 5 connecteurs existants; registre à 74 sources
- Filtres de l'accueil pré-remplis depuis l'URL
- Optimisation smartphone : bottom sheet de filtres, FAB, safe-areas, anti-zoom iOS

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 4 days ago (Aug 6, 2026) parent fb48938

Showing 7 changed files with +479 and −7

modified frontend/src/App.tsx +5 −0
@@ -9,6 +9,7 @@ import { fetchFacets, fetchSources, fetchStats, registerSourceNames, sourceName
9 9 import Home from "./pages/Home";
10 10 import ListingPage from "./pages/Listing";
11 11 import SourcesPage from "./pages/Sources";
12 +import StatsPage from "./pages/Stats";
12 13
13 14 function Ticker() {
14 15 const [items, setItems] = useState<string[]>([]);
@@ -59,6 +60,9 @@ function Header() {
59 60 <NavLink to="/" end className={({ isActive }) => (isActive ? "active" : "")}>
60 61 Logements
61 62 </NavLink>
63 + <NavLink to="/stats" className={({ isActive }) => (isActive ? "active" : "")}>
64 + Stats
65 + </NavLink>
62 66 <NavLink to="/sources" className={({ isActive }) => (isActive ? "active" : "")}>
63 67 Sources
64 68 </NavLink>
@@ -100,6 +104,7 @@ export default function App() {
100 104 <Routes>
101 105 <Route path="/" element={<Home />} />
102 106 <Route path="/logement/:uid" element={<ListingPage />} />
107 + <Route path="/stats" element={<StatsPage />} />
103 108 <Route path="/sources" element={<SourcesPage />} />
104 109 <Route
105 110 path="*"
modified frontend/src/api.ts +26 −0
@@ -89,6 +89,32 @@ export function fetchListings(f: ListingFilters) {
89 89 return get<{ total: number; listings: Listing[] }>(`/api/listings?${params}`);
90 90 }
91 91
92 +export interface GroupStat {
93 + key: string;
94 + count: number;
95 + avg_price: number | null;
96 + min_price: number | null;
97 +}
98 +
99 +export interface DetailedStats {
100 + totals: {
101 + total: number;
102 + with_price: number;
103 + avg: number | null;
104 + median: number | null;
105 + min: number | null;
106 + max: number | null;
107 + sources: number;
108 + cities: number;
109 + };
110 + histogram: { lo: number; hi: number | null; count: number }[];
111 + by_type: GroupStat[];
112 + by_city: GroupStat[];
113 + by_source: GroupStat[];
114 +}
115 +
116 +export const fetchDetailedStats = () => get<DetailedStats>("/api/stats/detailed");
117 +
92 118 export const fetchListing = (uid: string) =>
93 119 get<Listing>(`/api/listings/${encodeURIComponent(uid)}`);
94 120 export const fetchFacets = () => get<Facets>("/api/facets");
modified frontend/src/pages/Home.tsx +8 −6
@@ -4,6 +4,7 @@
4 4 // pages/Home.tsx : accueil — héro, statistiques, filtres, grille d'annonces
5 5 // -----------------------------------------------------------------------------
6 6 import { useEffect, useMemo, useState } from "react";
7 +import { useSearchParams } from "react-router-dom";
7 8 import {
8 9 Facets, Listing, Stats,
9 10 fetchFacets, fetchListings, fetchSources, fetchStats,
@@ -20,12 +21,13 @@ export default function Home() {
20 21 const [stats, setStats] = useState<Stats | null>(null);
21 22 const [error, setError] = useState<string | null>(null);
22 23
23 // filtres
24 const [q, setQ] = useState("");
25 const [city, setCity] = useState("");
26 const [source, setSource] = useState("");
27 const [priceMax, setPriceMax] = useState("");
28 const [unitType, setUnitType] = useState("");
24 + // filtres (pré-remplis depuis l'URL, ex. /?city=Montréal — liens de la page Stats)
25 + const [params] = useSearchParams();
26 + const [q, setQ] = useState(params.get("q") ?? "");
27 + const [city, setCity] = useState(params.get("city") ?? "");
28 + const [source, setSource] = useState(params.get("source") ?? "");
29 + const [priceMax, setPriceMax] = useState(params.get("price_max") ?? "");
30 + const [unitType, setUnitType] = useState(params.get("unit_type") ?? "");
29 31 // feuille de filtres mobile (bottom sheet)
30 32 const [sheetOpen, setSheetOpen] = useState(false);
31 33 const activeFilters = [q, city, source, priceMax, unitType].filter(Boolean).length;
added frontend/src/pages/Stats.tsx +296 −0
@@ -0,0 +1,296 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
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)
7 +// -----------------------------------------------------------------------------
8 +import { useEffect, useMemo, useState } from "react";
9 +import { Link } from "react-router-dom";
10 +import {
11 + DetailedStats, GroupStat,
12 + fetchDetailedStats, fetchSources, registerSourceNames, sourceName,
13 +} from "../api";
14 +
15 +const fmt = (n: number | null | undefined) =>
16 + n == null ? "—" : n.toLocaleString("fr-CA");
17 +const fmt$ = (n: number | null | undefined) => (n == null ? "—" : `${fmt(n)} $`);
18 +
19 +// ---- infobulle partagée ------------------------------------------------------
20 +interface Tip { x: number; y: number; title: string; lines: string[]; }
21 +
22 +function useTooltip() {
23 + const [tip, setTip] = useState<Tip | null>(null);
24 + const show = (e: React.MouseEvent, title: string, lines: string[]) =>
25 + setTip({ x: e.clientX, y: e.clientY, title, lines });
26 + const hide = () => setTip(null);
27 + 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 + >
36 + <div className="viz-tip-title">{tip.title}</div>
37 + {tip.lines.map((l) => (
38 + <div key={l}>{l}</div>
39 + ))}
40 + </div>
41 + );
42 + return { show, hide, node };
43 +}
44 +
45 +// ---- barres horizontales (une série) ----------------------------------------
46 +function HBars({
47 + data, unit, linkPrefix, tip,
48 +}: {
49 + data: { label: string; count: number; avg: number | null; href?: string }[];
50 + unit: string;
51 + linkPrefix?: string;
52 + tip: ReturnType<typeof useTooltip>;
53 +}) {
54 + const max = Math.max(...data.map((d) => d.count), 1);
55 + return (
56 + <div className="hbars">
57 + {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 + >
69 + <span className="hbar-label" title={d.label}>
70 + {d.href ? <Link to={d.href}>{d.label}</Link> : d.label}
71 + </span>
72 + <span className="hbar-track">
73 + <span className="hbar-fill" style={{ width: `${(d.count / max) * 100}%` }} />
74 + </span>
75 + <span className="hbar-value">
76 + {fmt(d.count)}
77 + {d.avg != null && <em> · {fmt$(d.avg)}</em>}
78 + </span>
79 + </div>
80 + ))}
81 + </div>
82 + );
83 +}
84 +
85 +function DataTable({ rows, unit }: { rows: GroupStat[]; unit: string }) {
86 + return (
87 + <details className="viz-table">
88 + <summary>Voir les données</summary>
89 + <table>
90 + <thead>
91 + <tr><th>Catégorie</th><th>{unit}</th><th>Loyer moyen</th><th>À partir de</th></tr>
92 + </thead>
93 + <tbody>
94 + {rows.map((r) => (
95 + <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>
100 + </tr>
101 + ))}
102 + </tbody>
103 + </table>
104 + </details>
105 + );
106 +}
107 +
108 +// ---- page --------------------------------------------------------------------
109 +export default function StatsPage() {
110 + const [d, setD] = useState<DetailedStats | null>(null);
111 + const [error, setError] = useState<string | null>(null);
112 + const tip = useTooltip();
113 +
114 + useEffect(() => {
115 + fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {});
116 + fetchDetailedStats().then(setD).catch((e) => setError(String(e)));
117 + }, []);
118 +
119 + const histMax = useMemo(
120 + () => Math.max(...(d?.histogram.map((h) => h.count) ?? [1]), 1),
121 + [d]
122 + );
123 +
124 + if (error)
125 + return (
126 + <div className="notice container">
127 + <div className="big">⚠️</div>
128 + <h2>Statistiques indisponibles</h2>
129 + <p>{error}</p>
130 + </div>
131 + );
132 +
133 + if (!d)
134 + return (
135 + <div className="container stats-page" aria-busy="true">
136 + <div className="skel" style={{ height: 120, marginTop: 40 }} />
137 + <div className="skel" style={{ height: 300, marginTop: 20 }} />
138 + </div>
139 + );
140 +
141 + 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);
147 + 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 + };
156 +
157 + return (
158 + <div className="container stats-page">
159 + {tip.node}
160 + <span className="kicker">Observatoire — marché locatif</span>
161 + <h1 className="stats-title">Le marché, en chiffres</h1>
162 + <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.
165 + </p>
166 +
167 + <div className="tiles">
168 + <div className="tile hero-tile">
169 + <div className="tile-v">{fmt(t.total)}</div>
170 + <div className="tile-k">logements actifs</div>
171 + </div>
172 + <div className="tile">
173 + <div className="tile-v">{fmt$(t.median)}</div>
174 + <div className="tile-k">loyer médian</div>
175 + </div>
176 + <div className="tile">
177 + <div className="tile-v">{fmt$(t.avg)}</div>
178 + <div className="tile-k">loyer moyen</div>
179 + </div>
180 + <div className="tile">
181 + <div className="tile-v">{fmt(t.sources)}</div>
182 + <div className="tile-k">gestionnaires</div>
183 + </div>
184 + <div className="tile">
185 + <div className="tile-v">{fmt(t.cities)}</div>
186 + <div className="tile-k">villes couvertes</div>
187 + </div>
188 + <div className="tile">
189 + <div className="tile-v">{fmt$(t.min)}</div>
190 + <div className="tile-k">loyer le plus bas</div>
191 + </div>
192 + </div>
193 +
194 + <section className="viz-card">
195 + <h2>Distribution des loyers</h2>
196 + <p className="viz-sub">
197 + {fmt(t.with_price)} annonces avec prix affiché — classes de 200&nbsp;$
198 + </p>
199 + <div className="histo" role="img" aria-label="Histogramme de la distribution des loyers mensuels">
200 + {d.histogram.map((h) => (
201 + <div
202 + className="histo-col"
203 + key={`${h.lo}`}
204 + onMouseMove={(e) =>
205 + tip.show(
206 + e,
207 + h.hi ? `${fmt(h.lo)} – ${fmt(h.hi)} $` : `${fmt(h.lo)} $ et plus`,
208 + [`${fmt(h.count)} logements`,
209 + `${((h.count / Math.max(t.with_price, 1)) * 100).toFixed(1)} % du parc`]
210 + )
211 + }
212 + onMouseLeave={tip.hide}
213 + >
214 + <div className="histo-bar-zone">
215 + <div className="histo-bar" style={{ height: `${(h.count / histMax) * 100}%` }} />
216 + </div>
217 + <div className="histo-x">
218 + {h.lo % 400 === 0 ? (h.lo >= 1000 ? `${h.lo / 1000}k` : h.lo) : ""}
219 + </div>
220 + </div>
221 + ))}
222 + </div>
223 + <details className="viz-table">
224 + <summary>Voir les données</summary>
225 + <table>
226 + <thead><tr><th>Classe</th><th>Logements</th></tr></thead>
227 + <tbody>
228 + {d.histogram.map((h) => (
229 + <tr key={`t${h.lo}`}>
230 + <td>{h.hi ? `${fmt(h.lo)} – ${fmt(h.hi)} $` : `${fmt(h.lo)} $ +`}</td>
231 + <td>{fmt(h.count)}</td>
232 + </tr>
233 + ))}
234 + </tbody>
235 + </table>
236 + </details>
237 + </section>
238 +
239 + <div className="viz-grid">
240 + <section className="viz-card">
241 + <h2>Par taille de logement</h2>
242 + <p className="viz-sub">nombre d'annonces · loyer moyen</p>
243 + <HBars
244 + tip={tip}
245 + unit="logements"
246 + data={types.map((r) => ({
247 + label: r.key, count: r.count, avg: r.avg_price,
248 + href: `/?unit_type=${encodeURIComponent(r.key)}`,
249 + }))}
250 + />
251 + <DataTable rows={d.by_type} unit="Logements" />
252 + </section>
253 +
254 + <section className="viz-card">
255 + <h2>Par ville</h2>
256 + <p className="viz-sub">top {cities.length} — nombre d'annonces · loyer moyen</p>
257 + <HBars
258 + tip={tip}
259 + unit="logements"
260 + data={[...cities.map((r) => ({
261 + label: r.key, count: r.count, avg: r.avg_price,
262 + href: `/?city=${encodeURIComponent(r.key)}`,
263 + })), ...(fold(citiesRest) ? [{
264 + label: fold(citiesRest)!.key, count: fold(citiesRest)!.count, avg: null,
265 + }] : [])]}
266 + />
267 + <DataTable rows={d.by_city} unit="Logements" />
268 + </section>
269 + </div>
270 +
271 + <section className="viz-card">
272 + <h2>Par gestionnaire immobilier</h2>
273 + <p className="viz-sub">top {sources.length} — nombre d'annonces · loyer moyen</p>
274 + <HBars
275 + tip={tip}
276 + unit="logements"
277 + data={[...sources.map((r) => ({
278 + label: sourceName(r.key), count: r.count, avg: r.avg_price,
279 + href: `/?source=${encodeURIComponent(r.key)}`,
280 + })), ...(fold(sourcesRest) ? [{
281 + label: fold(sourcesRest)!.key, count: fold(sourcesRest)!.count, avg: null,
282 + }] : [])]}
283 + />
284 + <DataTable
285 + rows={d.by_source.map((r) => ({ ...r, key: sourceName(r.key) }))}
286 + unit="Logements"
287 + />
288 + </section>
289 +
290 + <p className="stats-foot">
291 + Données recalculées à chaque synchronisation (horaire). Les catégories renvoient
292 + vers les logements filtrés correspondants.
293 + </p>
294 + </div>
295 + );
296 +}
modified frontend/src/styles.css +80 −0
@@ -338,6 +338,86 @@ img { display: block; }
338 338 .pill.todo { background: var(--amber-soft); color: #8a5a12; border: 1px solid var(--amber); }
339 339 .count-pill { font-weight: 700; font-family: var(--font-display); font-size: 16px; }
340 340
341 +/* ================= Page Statistiques ================= */
342 +.stats-page { padding: 44px 0 90px; }
343 +.stats-title { font-size: clamp(30px, 4.6vw, 46px); text-transform: uppercase; margin: 10px 0 6px; }
344 +.stats-page .sub { color: var(--ink-2); max-width: 640px; margin-bottom: 30px; }
345 +
346 +.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 14px; margin-bottom: 30px; }
347 +.tile {
348 + background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card);
349 + padding: 18px 20px; box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.1);
350 +}
351 +.tile-v { font-family: var(--font-display); font-weight: 700; font-size: clamp(24px, 3vw, 34px); letter-spacing: -0.03em; line-height: 1.05; }
352 +.tile-k { font-family: var(--font-mono); font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3); margin-top: 6px; }
353 +.hero-tile { background: var(--ink); color: var(--lime); border-color: var(--ink); }
354 +.hero-tile .tile-k { color: rgba(217, 242, 107, 0.7); }
355 +
356 +.viz-card {
357 + background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card);
358 + box-shadow: var(--shadow-off-soft); padding: 26px 28px 20px; margin-bottom: 22px;
359 +}
360 +.viz-card h2 { font-size: 19px; text-transform: uppercase; letter-spacing: -0.01em; }
361 +.viz-sub { font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-3); margin: 4px 0 20px; }
362 +.viz-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 22px; }
363 +@media (max-width: 900px) { .viz-grid { grid-template-columns: 1fr; } }
364 +
365 +/* Histogramme (mono-série, encre verte) */
366 +.histo { display: flex; align-items: stretch; gap: 2px; height: 240px; }
367 +.histo-col { flex: 1; display: flex; flex-direction: column; min-width: 0; cursor: default; }
368 +.histo-bar-zone { flex: 1; display: flex; align-items: flex-end; border-bottom: 1.5px solid var(--ink); }
369 +.histo-bar {
370 + width: 100%; background: var(--green); border-radius: 4px 4px 0 0;
371 + min-height: 2px; transition: background 0.12s ease;
372 +}
373 +.histo-col:hover .histo-bar { background: var(--ink); box-shadow: inset 0 0 0 2px var(--lime); }
374 +.histo-x { font-family: var(--font-mono); font-size: 9.5px; color: var(--ink-3); text-align: left; height: 18px; padding-top: 5px; overflow: visible; white-space: nowrap; }
375 +
376 +/* Barres horizontales (mono-série) */
377 +.hbars { display: flex; flex-direction: column; gap: 7px; }
378 +.hbar-row {
379 + display: grid; grid-template-columns: minmax(96px, 170px) 1fr auto;
380 + gap: 12px; align-items: center; min-height: 26px; cursor: default;
381 +}
382 +.hbar-label { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
383 +.hbar-label a { border-bottom: 1.5px solid var(--lime); }
384 +.hbar-label a:hover { color: var(--green-deep); }
385 +.hbar-track { background: var(--surface-2); border-radius: 0 4px 4px 0; height: 18px; overflow: hidden; }
386 +.hbar-fill {
387 + display: block; height: 100%; background: var(--green); border-radius: 0 4px 4px 0;
388 + min-width: 2px; transition: background 0.12s ease;
389 +}
390 +.hbar-row:hover .hbar-fill { background: var(--ink); box-shadow: inset 0 0 0 2px var(--lime); }
391 +.hbar-value { font-family: var(--font-mono); font-size: 11.5px; font-weight: 700; white-space: nowrap; }
392 +.hbar-value em { font-style: normal; font-weight: 500; color: var(--ink-3); }
393 +@media (max-width: 640px) {
394 + .hbar-row { grid-template-columns: minmax(80px, 110px) 1fr auto; gap: 8px; }
395 + .hbar-value em { display: none; }
396 + .viz-card { padding: 20px 16px 14px; }
397 + .histo { height: 170px; }
398 +}
399 +
400 +/* Infobulle */
401 +.viz-tip {
402 + position: fixed; z-index: 120; pointer-events: none; max-width: 180px;
403 + background: var(--ink); color: var(--paper); border-radius: 8px;
404 + border: 1px solid var(--lime); padding: 9px 12px; font-size: 12px; line-height: 1.5;
405 + box-shadow: 0 8px 24px rgba(16, 18, 16, 0.35);
406 +}
407 +.viz-tip-title { font-family: var(--font-display); font-weight: 700; color: var(--lime); margin-bottom: 2px; }
408 +
409 +/* Vue tableau repliable (accessibilité / relief) */
410 +.viz-table { margin-top: 16px; border-top: 1.5px dashed var(--line); padding-top: 10px; }
411 +.viz-table summary {
412 + cursor: pointer; font-family: var(--font-mono); font-size: 11px; font-weight: 700;
413 + text-transform: uppercase; letter-spacing: 0.08em; color: var(--green-deep);
414 +}
415 +.viz-table table { width: 100%; border-collapse: collapse; margin-top: 10px; font-size: 13px; }
416 +.viz-table th { text-align: left; font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-3); padding: 6px 10px; border-bottom: 1.5px solid var(--ink); }
417 +.viz-table td { padding: 6px 10px; border-bottom: 1px solid var(--line); }
418 +
419 +.stats-foot { font-family: var(--font-mono); font-size: 11px; color: var(--ink-3); letter-spacing: 0.04em; margin-top: 6px; }
420 +
341 421 /* ================= Mobile : feuille de filtres + FAB ================= */
342 422 .sheet-head { display: none; }
343 423 .sheet-apply { display: none; }
modified frontend/tsconfig.tsbuildinfo +1 −1
@@ -1 +1 @@
1 {"root":["./src/app.tsx","./src/api.ts","./src/main.tsx","./src/components/listingcard.tsx","./src/pages/home.tsx","./src/pages/listing.tsx","./src/pages/sources.tsx"],"version":"5.9.3"}
\ No newline at end of file
1 +{"root":["./src/app.tsx","./src/api.ts","./src/main.tsx","./src/components/listingcard.tsx","./src/pages/home.tsx","./src/pages/listing.tsx","./src/pages/sources.tsx","./src/pages/stats.tsx"],"version":"5.9.3"}
\ No newline at end of file
modified louka/web.py +63 −0
@@ -136,6 +136,69 @@ def stats():
136 136 return {**dict(row), "recent_syncs": log}
137 137
138 138
139 +@app.get("/api/stats/detailed")
140 +def stats_detailed():
141 + """Agrégations pour la page Statistiques : loyers, types, villes, gestionnaires."""
142 + con = db.connect()
143 + prices = [r["price"] for r in con.execute(
144 + "SELECT price FROM listings WHERE active=1 AND price IS NOT NULL"
145 + " AND price BETWEEN 300 AND 10000 ORDER BY price")]
146 +
147 + def median(v):
148 + n = len(v)
149 + if n == 0:
150 + return None
151 + return v[n // 2] if n % 2 else (v[n // 2 - 1] + v[n // 2]) / 2
152 +
153 + # Histogramme des loyers : classes de 200 $ de 400 à 3200, + dépassement
154 + lo, hi, step = 400, 3200, 200
155 + edges = list(range(lo, hi + step, step))
156 + hist = [{"lo": a, "hi": a + step, "count": 0} for a in edges[:-1]]
157 + under = over = 0
158 + for p in prices:
159 + if p < lo:
160 + under += 1
161 + elif p >= hi:
162 + over += 1
163 + else:
164 + hist[int((p - lo) // step)]["count"] += 1
165 + if under:
166 + hist.insert(0, {"lo": 0, "hi": lo, "count": under})
167 + if over:
168 + hist.append({"lo": hi, "hi": None, "count": over})
169 +
170 + def group(col):
171 + rows = con.execute(
172 + f"""SELECT {col} k, COUNT(*) n, AVG(price) avg_price,
173 + MIN(price) min_price
174 + FROM listings WHERE active=1 AND {col}<>''
175 + GROUP BY {col} ORDER BY n DESC""").fetchall()
176 + return [{"key": r["k"], "count": r["n"],
177 + "avg_price": round(r["avg_price"]) if r["avg_price"] else None,
178 + "min_price": r["min_price"]} for r in rows]
179 +
180 + out = {
181 + "totals": {
182 + "total": con.execute("SELECT COUNT(*) c FROM listings WHERE active=1").fetchone()["c"],
183 + "with_price": len(prices),
184 + "avg": round(sum(prices) / len(prices)) if prices else None,
185 + "median": round(median(prices)) if prices else None,
186 + "min": prices[0] if prices else None,
187 + "max": prices[-1] if prices else None,
188 + "sources": con.execute(
189 + "SELECT COUNT(DISTINCT source) c FROM listings WHERE active=1").fetchone()["c"],
190 + "cities": con.execute(
191 + "SELECT COUNT(DISTINCT city) c FROM listings WHERE active=1 AND city<>''").fetchone()["c"],
192 + },
193 + "histogram": hist,
194 + "by_type": group("unit_type"),
195 + "by_city": group("city"),
196 + "by_source": group("source"),
197 + }
198 + con.close()
199 + return out
200 +
201 +
139 202 @app.post("/api/sync")
140 203 def trigger_sync(background: BackgroundTasks, source: str | None = None):
141 204 """Déclenche une synchronisation (équivalent d'un webhook entrant)."""
142 205