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