SPB Git

spb/lou-ka Public

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

HTML 99.7%
17.1 KB · 417 lines tsx
Raw Blame History
1// -----------------------------------------------------------------------------2// Lou-Ka — Agrégateur de logements à louer (province de Québec)3// Auteur : Simon-Pierre Boucher — contact@spboucher.ai4// 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// -----------------------------------------------------------------------------8import { useEffect, useMemo, useState } from "react";9import { Link, useSearchParams } from "react-router-dom";10import {11  DetailedStats, GroupStat,12  fetchDetailedStats, fetchSources, registerSourceNames, sourceName,13} from "../api";1415const fmt = (n: number | null | undefined) =>16  n == null ? "—" : n.toLocaleString("fr-CA");17const fmt$ = (n: number | null | undefined) => (n == null ? "—" : `${fmt(n)} $`);1819const 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;26type OngletId = (typeof ONGLETS)[number]["id"];2728// ---- infobulle partagée ------------------------------------------------------29interface Tip { x: number; y: number; title: string; lines: string[]; }3031function useTooltip() {32  const [tip, setTip] = useState<Tip | null>(null);33  const show = (e: React.MouseEvent, title: string, lines: string[]) =>34    setTip({ x: e.clientX, y: e.clientY, title, lines });35  const hide = () => setTip(null);36  const node = tip && (37    <div className="viz-tip" role="status"38         style={{ left: Math.min(tip.x + 14, window.innerWidth - 190), top: tip.y + 14 }}>39      <div className="viz-tip-title">{tip.title}</div>40      {tip.lines.map((l) => <div key={l}>{l}</div>)}41    </div>42  );43  return { show, hide, node };44}4546// ---- barres horizontales (une série) ----------------------------------------47function HBars({ data, unit, tip }: {48  data: { label: string; count: number; avg: number | null; href?: string }[];49  unit: string;50  tip: ReturnType<typeof useTooltip>;51}) {52  const max = Math.max(...data.map((d) => d.count), 1);53  return (54    <div className="hbars">55      {data.map((d) => (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}>61          <span className="hbar-label" title={d.label}>62            {d.href ? <Link to={d.href}>{d.label}</Link> : d.label}63          </span>64          <span className="hbar-track">65            <span className="hbar-fill" style={{ width: `${(d.count / max) * 100}%` }} />66          </span>67          <span className="hbar-value">68            {fmt(d.count)}{d.avg != null && <em> · {fmt$(d.avg)}</em>}69          </span>70        </div>71      ))}72    </div>73  );74}7576// ---- barres de pourcentage (inclusions) ---------------------------------------77function 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!)}%` }} />85          </span>86          <span className="hbar-value">{d.pct!.toLocaleString("fr-CA")} %</span>87        </div>88      ))}89    </div>90  );91}9293function DataTable({ rows, unit }: { rows: GroupStat[]; unit: string }) {94  return (95    <details className="viz-table">96      <summary>Voir les données</summary>97      <table>98        <thead>99          <tr><th>Catégorie</th><th>{unit}</th><th>Loyer moyen</th><th>À partir de</th></tr>100        </thead>101        <tbody>102          {rows.map((r) => (103            <tr key={r.key}>104              <td>{r.key}</td><td>{fmt(r.count)}</td>105              <td>{fmt$(r.avg_price)}</td><td>{fmt$(r.min_price)}</td>106            </tr>107          ))}108        </tbody>109      </table>110    </details>111  );112}113114function 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}122123// ---- page --------------------------------------------------------------------124export default function StatsPage() {125  const [d, setD] = useState<DetailedStats | null>(null);126  const [error, setError] = useState<string | null>(null);127  const [params, setParams] = useSearchParams();128  const onglet = (params.get("onglet") as OngletId) || "ensemble";129  const tip = useTooltip();130131  useEffect(() => {132    fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {});133    fetchDetailedStats().then(setD).catch((e) => setError(String(e)));134  }, []);135136  const histMax = useMemo(137    () => Math.max(...(d?.histogram.map((h) => h.count) ?? [1]), 1), [d]);138139  if (error)140    return (141      <div className="notice container">142        <div className="big">⚠️</div>143        <h2>Statistiques indisponibles</h2>144        <p>{error}</p>145      </div>146    );147148  if (!d)149    return (150      <div className="container stats-page" aria-busy="true">151        <div className="skel" style={{ height: 120, marginTop: 40 }} />152        <div className="skel" style={{ height: 300, marginTop: 20 }} />153      </div>154    );155156  const t = d.totals;157  const o = d.offre;158  const fold = (rest: GroupStat[]): GroupStat | null =>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    };164165  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  );188189  return (190    <div className="container stats-page">191      {tip.node}192      <span className="kicker">Observatoire — marché locatif québécois</span>193      <h1 className="stats-title">Le marché, en chiffres</h1>194      <p className="sub">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.197      </p>198      <p>199        <a className="btn btn-primary" href="/api/stats/rapport.pdf" download>200          📊 Télécharger le rapport global (PDF)201        </a>202      </p>203204      {/* 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>214215      {/* ============ 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      )}236237      {/* ============ 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                })}275              </div>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      )}300301      {/* ============ 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      )}338339      {/* ============ 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      )}372373      {/* ============ 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      )}409410      <p className="stats-foot">411        Données recalculées à chaque synchronisation (horaire). Les catégories412        renvoient vers les logements filtrés correspondants.413      </p>414    </div>415  );416}417