// ----------------------------------------------------------------------------- // Lou-Ka — Agrégateur de logements à louer (province de Québec) // Auteur : Simon-Pierre Boucher — contact@spboucher.ai // pages/Stats.tsx : observatoire du marché — 5 onglets (Vue d'ensemble, Loyers, // Régions & villes, Offre, Gestionnaires), tuiles héro, histogramme, // barres mono-série, baisses de prix, santé des sources. Rapport PDF global. // ----------------------------------------------------------------------------- import { useEffect, useMemo, useState } from "react"; import { Link, useSearchParams } from "react-router-dom"; import { DetailedStats, GroupStat, fetchDetailedStats, fetchSources, registerSourceNames, sourceName, } from "../api"; const fmt = (n: number | null | undefined) => n == null ? "—" : n.toLocaleString("fr-CA"); const fmt$ = (n: number | null | undefined) => (n == null ? "—" : `${fmt(n)} $`); const ONGLETS = [ { id: "ensemble", label: "Vue d'ensemble", icon: "◎" }, { id: "loyers", label: "Loyers", icon: "$" }, { id: "regions", label: "Régions & villes", icon: "◈" }, { id: "offre", label: "L'offre", icon: "🏠" }, { id: "gestionnaires", label: "Gestionnaires", icon: "🗂" }, ] as const; type OngletId = (typeof ONGLETS)[number]["id"]; // ---- infobulle partagée ------------------------------------------------------ interface Tip { x: number; y: number; title: string; lines: string[]; } function useTooltip() { const [tip, setTip] = useState(null); const show = (e: React.MouseEvent, title: string, lines: string[]) => setTip({ x: e.clientX, y: e.clientY, title, lines }); const hide = () => setTip(null); const node = tip && (
{tip.title}
{tip.lines.map((l) =>
{l}
)}
); return { show, hide, node }; } // ---- barres horizontales (une série) ---------------------------------------- function HBars({ data, unit, tip }: { data: { label: string; count: number; avg: number | null; href?: string }[]; unit: string; tip: ReturnType; }) { const max = Math.max(...data.map((d) => d.count), 1); return (
{data.map((d) => (
tip.show(e, d.label, [ `${fmt(d.count)} ${unit}`, d.avg != null ? `loyer moyen ${fmt$(d.avg)}` : "loyer non affiché"])} onMouseLeave={tip.hide}> {d.href ? {d.label} : d.label} {fmt(d.count)}{d.avg != null && · {fmt$(d.avg)}}
))}
); } // ---- barres de pourcentage (inclusions) --------------------------------------- function PctBars({ data }: { data: { label: string; pct: number | null }[] }) { return (
{data.filter((d) => d.pct != null).map((d) => (
{d.label} {d.pct!.toLocaleString("fr-CA")} %
))}
); } function DataTable({ rows, unit }: { rows: GroupStat[]; unit: string }) { return (
Voir les données {rows.map((r) => ( ))}
Catégorie{unit}Loyer moyenÀ partir de
{r.key}{fmt(r.count)} {fmt$(r.avg_price)}{fmt$(r.min_price)}
); } function Tile({ v, k, hero }: { v: string; k: string; hero?: boolean }) { return (
{v}
{k}
); } // ---- page -------------------------------------------------------------------- export default function StatsPage() { const [d, setD] = useState(null); const [error, setError] = useState(null); const [params, setParams] = useSearchParams(); const onglet = (params.get("onglet") as OngletId) || "ensemble"; const tip = useTooltip(); useEffect(() => { fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {}); fetchDetailedStats().then(setD).catch((e) => setError(String(e))); }, []); const histMax = useMemo( () => Math.max(...(d?.histogram.map((h) => h.count) ?? [1]), 1), [d]); if (error) return (
⚠️

Statistiques indisponibles

{error}

); if (!d) return (
); const t = d.totals; const o = d.offre; const fold = (rest: GroupStat[]): GroupStat | null => rest.length === 0 ? null : { key: `Autres (${rest.length})`, count: rest.reduce((s, r) => s + r.count, 0), avg_price: null, min_price: null, }; const histogramme = (

Distribution des loyers

{fmt(t.with_price)} annonces avec prix affiché — classes de 200 $

{d.histogram.map((h) => (
tip.show(e, h.hi ? `${fmt(h.lo)} – ${fmt(h.hi)} $` : `${fmt(h.lo)} $ et plus`, [`${fmt(h.count)} logements`, `${((h.count / Math.max(t.with_price, 1)) * 100).toFixed(1)} % du parc`])} onMouseLeave={tip.hide}>
{h.lo % 400 === 0 ? (h.lo >= 1000 ? `${h.lo / 1000}k` : h.lo) : ""}
))}
); return (
{tip.node} Observatoire — marché locatif québécois

Le marché, en chiffres

Calculé en direct sur les {fmt(t.total)} annonces actives de {fmt(t.sources)} gestionnaires, dans {fmt(t.cities)} villes et {fmt(t.regions)} régions.

📊 Télécharger le rapport global (PDF)

{/* barre d'onglets */} {/* ============ Vue d'ensemble ============ */} {onglet === "ensemble" && ( <>

Couverture par région

annonces actives · loyer moyen régional

({ label: r.key, count: r.count, avg: r.avg_price }))} />
{histogramme} )} {/* ============ Loyers ============ */} {onglet === "loyers" && ( <>
{histogramme}

Par taille de logement

nombre d'annonces · loyer moyen

({ label: r.key, count: r.count, avg: r.avg_price, href: `/?unit_type=${encodeURIComponent(r.key)}` }))} />

Prix au pied carré

loyer ÷ superficie, par taille (annonces publiant les deux)

{o.prix_pi2.map((r) => { const max = Math.max(...o.prix_pi2.map((x) => x.val), 0.01); return (
{r.key} {r.val.toLocaleString("fr-CA")} $/pi² · {r.count}
); })}
{d.baisses.length > 0 && (

Baisses de prix récentes 📉

30 derniers jours — leviers de négociation

    {d.baisses.map((b) => (
  • {b.title || b.uid} {b.city} {fmt$(b.avant)}{fmt$(b.apres)} {b.pct.toLocaleString("fr-CA")} %
  • ))}
)} )} {/* ============ Régions & villes ============ */} {onglet === "regions" && ( <>

Par région

annonces · gestionnaires · loyer moyen

({ label: r.key, count: r.count, avg: r.avg_price }))} />
Voir les données {d.by_region.map((r) => ( ))}
RégionAnnoncesSourcesLoyer moyen
{r.key}{fmt(r.count)} {r.sources ?? "—"}{fmt$(r.avg_price)}

Par ville

top 20 — nombre d'annonces · loyer moyen

({ label: r.key, count: r.count, avg: r.avg_price, href: `/?city=${encodeURIComponent(r.key)}` })), ...(fold(d.by_city.slice(20)) ? [{ label: fold(d.by_city.slice(20))!.key, count: fold(d.by_city.slice(20))!.count, avg: null }] : [])]} />
)} {/* ============ L'offre ============ */} {onglet === "offre" && ( <>

Inclusions et caractéristiques

part du parc dont la source confirme l'inclusion — le reste est inconnu, pas absent

(b.pct ?? 0) - (a.pct ?? 0))} />

Par taille de logement

({ label: r.key, count: r.count, avg: r.avg_price, href: `/?unit_type=${encodeURIComponent(r.key)}` }))} />
)} {/* ============ Gestionnaires ============ */} {onglet === "gestionnaires" && ( <>

Par gestionnaire immobilier

top 20 — nombre d'annonces · loyer moyen

({ label: sourceName(r.key), count: r.count, avg: r.avg_price, href: `/?source=${encodeURIComponent(r.key)}` })), ...(fold(d.by_source.slice(20)) ? [{ label: fold(d.by_source.slice(20))!.key, count: fold(d.by_source.slice(20))!.count, avg: null }] : [])]} /> ({ ...r, key: sourceName(r.key) }))} unit="Logements" />
{d.sante.alertes_24h.length > 0 && (

Alertes de synchronisation (24 h)

    {d.sante.alertes_24h.map((a, i) => (
  • {sourceName(a.source)} — {a.message}
  • ))}
)}

Voir le registre complet des sources →

)}

Données recalculées à chaque synchronisation (horaire). Les catégories renvoient vers les logements filtrés correspondants.

); }