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>
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 $ | ||||
| 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 | ||||