SPB Git

spb/lou-ka

Public

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

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 (Aug 6, 2026) parent fb48938 Browse files

7 changed files +479 −7

modified frontend/src/App.tsx +5 −0
@@ -9,6 +9,7 @@ import { fetchFacets, fetchSources, fetchStats, registerSourceNames, sourceName
9 import Home from "./pages/Home"; 9 import Home from "./pages/Home";
10 import ListingPage from "./pages/Listing"; 10 import ListingPage from "./pages/Listing";
11 import SourcesPage from "./pages/Sources"; 11 import SourcesPage from "./pages/Sources";
12+import StatsPage from "./pages/Stats";
12 13
13 function Ticker() { 14 function Ticker() {
14 const [items, setItems] = useState<string[]>([]); 15 const [items, setItems] = useState<string[]>([]);
@@ -59,6 +60,9 @@ function Header() {
59 <NavLink to="/" end className={({ isActive }) => (isActive ? "active" : "")}> 60 <NavLink to="/" end className={({ isActive }) => (isActive ? "active" : "")}>
60 Logements 61 Logements
61 </NavLink> 62 </NavLink>
63+ <NavLink to="/stats" className={({ isActive }) => (isActive ? "active" : "")}>
64+ Stats
65+ </NavLink>
62 <NavLink to="/sources" className={({ isActive }) => (isActive ? "active" : "")}> 66 <NavLink to="/sources" className={({ isActive }) => (isActive ? "active" : "")}>
63 Sources 67 Sources
64 </NavLink> 68 </NavLink>
@@ -100,6 +104,7 @@ export default function App() {
100 <Routes> 104 <Routes>
101 <Route path="/" element={<Home />} /> 105 <Route path="/" element={<Home />} />
102 <Route path="/logement/:uid" element={<ListingPage />} /> 106 <Route path="/logement/:uid" element={<ListingPage />} />
107+ <Route path="/stats" element={<StatsPage />} />
103 <Route path="/sources" element={<SourcesPage />} /> 108 <Route path="/sources" element={<SourcesPage />} />
104 <Route 109 <Route
105 path="*" 110 path="*"
modified frontend/src/api.ts +26 −0
@@ -89,6 +89,32 @@ export function fetchListings(f: ListingFilters) {
89 return get<{ total: number; listings: Listing[] }>(`/api/listings?${params}`); 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 export const fetchListing = (uid: string) => 118 export const fetchListing = (uid: string) =>
93 get<Listing>(`/api/listings/${encodeURIComponent(uid)}`); 119 get<Listing>(`/api/listings/${encodeURIComponent(uid)}`);
94 export const fetchFacets = () => get<Facets>("/api/facets"); 120 export const fetchFacets = () => get<Facets>("/api/facets");
modified frontend/src/pages/Home.tsx +8 −6
@@ -4,6 +4,7 @@
4 // pages/Home.tsx : accueil — héro, statistiques, filtres, grille d'annonces 4 // pages/Home.tsx : accueil — héro, statistiques, filtres, grille d'annonces
5 // ----------------------------------------------------------------------------- 5 // -----------------------------------------------------------------------------
6 import { useEffect, useMemo, useState } from "react"; 6 import { useEffect, useMemo, useState } from "react";
7+import { useSearchParams } from "react-router-dom";
7 import { 8 import {
8 Facets, Listing, Stats, 9 Facets, Listing, Stats,
9 fetchFacets, fetchListings, fetchSources, fetchStats, 10 fetchFacets, fetchListings, fetchSources, fetchStats,
@@ -20,12 +21,13 @@ export default function Home() {
20 const [stats, setStats] = useState<Stats | null>(null); 21 const [stats, setStats] = useState<Stats | null>(null);
21 const [error, setError] = useState<string | null>(null); 22 const [error, setError] = useState<string | null>(null);
22 23
23− // filtres 24+ // filtres (pré-remplis depuis l'URL, ex. /?city=Montréal — liens de la page Stats)
24− const [q, setQ] = useState(""); 25+ const [params] = useSearchParams();
25− const [city, setCity] = useState(""); 26+ const [q, setQ] = useState(params.get("q") ?? "");
26− const [source, setSource] = useState(""); 27+ const [city, setCity] = useState(params.get("city") ?? "");
27− const [priceMax, setPriceMax] = useState(""); 28+ const [source, setSource] = useState(params.get("source") ?? "");
28− const [unitType, setUnitType] = useState(""); 29+ const [priceMax, setPriceMax] = useState(params.get("price_max") ?? "");
30+ const [unitType, setUnitType] = useState(params.get("unit_type") ?? "");
29 // feuille de filtres mobile (bottom sheet) 31 // feuille de filtres mobile (bottom sheet)
30 const [sheetOpen, setSheetOpen] = useState(false); 32 const [sheetOpen, setSheetOpen] = useState(false);
31 const activeFilters = [q, city, source, priceMax, unitType].filter(Boolean).length; 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 .pill.todo { background: var(--amber-soft); color: #8a5a12; border: 1px solid var(--amber); } 338 .pill.todo { background: var(--amber-soft); color: #8a5a12; border: 1px solid var(--amber); }
339 .count-pill { font-weight: 700; font-family: var(--font-display); font-size: 16px; } 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 /* ================= Mobile : feuille de filtres + FAB ================= */ 421 /* ================= Mobile : feuille de filtres + FAB ================= */
342 .sheet-head { display: none; } 422 .sheet-head { display: none; }
343 .sheet-apply { display: none; } 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 return {**dict(row), "recent_syncs": log} 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 @app.post("/api/sync") 202 @app.post("/api/sync")
140 def trigger_sync(background: BackgroundTasks, source: str | None = None): 203 def trigger_sync(background: BackgroundTasks, source: str | None = None):
141 """Déclenche une synchronisation (équivalent d'un webhook entrant).""" 204 """Déclenche une synchronisation (équivalent d'un webhook entrant)."""
142 205
Menu

Theme