SPB Git

spb/immo-ka Public

Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)

Python 66.4% TypeScript 19.9% CSS 13.2% HTML 0.5%
6.0 KB · 136 lines tsx
Raw Blame History
1// -----------------------------------------------------------------------------2// Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)3// Auteur : Simon-Pierre Boucher — contact@spboucher.ai4// pages/Stats.tsx : statistiques du marché agrégé (totaux + répartitions)5//   Les répartitions sont calculées à partir de /api/facets et /api/listings6//   (échantillon trié par prix) — le backend n'expose pas d'agrégats détaillés.7// -----------------------------------------------------------------------------8import { useEffect, useMemo, useState } from "react";9import { Link } from "react-router-dom";10import {11  Facets, Listing, Stats,12  fetchFacets, fetchListings, fetchSources, fetchStats,13  registerSourceNames, sourceName,14} from "../api";1516function Bars({ rows, unit }: { rows: { key: string; n: number; href?: string }[]; unit?: string }) {17  const max = Math.max(1, ...rows.map((r) => r.n));18  return (19    <div className="hbars">20      {rows.map((r) => (21        <div className="hbar-row" key={r.key}>22          <div className="hbar-label">{r.href ? <Link to={r.href}>{r.key}</Link> : r.key}</div>23          <div className="hbar-track"><span className="hbar-fill" style={{ width: `${(r.n / max) * 100}%` }} /></div>24          <div className="hbar-value">{r.n.toLocaleString("fr-CA")}{unit ? <em> {unit}</em> : ""}</div>25        </div>26      ))}27    </div>28  );29}3031export default function StatsPage() {32  const [stats, setStats] = useState<Stats | null>(null);33  const [facets, setFacets] = useState<Facets | null>(null);34  const [sample, setSample] = useState<Listing[] | null>(null);3536  useEffect(() => {37    fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {});38    fetchStats().then(setStats).catch(() => {});39    fetchFacets().then(setFacets).catch(() => {});40    fetchListings({ sort: "price_asc" }, 2000, 0).then((r) => setSample(r.listings)).catch(() => {});41  }, []);4243  const byType = useMemo(() => {44    if (!sample) return [];45    const m = new Map<string, number>();46    for (const l of sample) if (l.property_type) m.set(l.property_type, (m.get(l.property_type) ?? 0) + 1);47    return [...m.entries()].map(([key, n]) => ({ key, n, href: `/?property_type=${encodeURIComponent(key)}` }))48      .sort((a, b) => b.n - a.n).slice(0, 12);49  }, [sample]);5051  const byCity = useMemo(() => {52    if (!sample) return [];53    const m = new Map<string, number>();54    for (const l of sample) if (l.city) m.set(l.city, (m.get(l.city) ?? 0) + 1);55    return [...m.entries()].map(([key, n]) => ({ key, n, href: `/?city=${encodeURIComponent(key)}` }))56      .sort((a, b) => b.n - a.n).slice(0, 15);57  }, [sample]);5859  const bySource = useMemo(() => {60    if (!facets) return [];61    return facets.sources.map((s) => ({ key: sourceName(s.source), n: s.n, href: `/?source=${encodeURIComponent(s.source)}` }))62      .sort((a, b) => b.n - a.n).slice(0, 15);63  }, [facets]);6465  const tiles = [66    { v: stats ? stats.total.toLocaleString("fr-CA") : "…", k: "Propriétés à vendre", hero: true },67    { v: stats ? String(stats.sources) : "…", k: "Agences agrégées" },68    { v: stats ? stats.cities.toLocaleString("fr-CA") : "…", k: "Villes couvertes" },69    { v: stats?.avg_price != null ? `${Math.round(stats.avg_price).toLocaleString("fr-CA")} $` : "…", k: "Prix moyen demandé" },70    { v: stats?.min_price != null ? `${Math.round(stats.min_price).toLocaleString("fr-CA")} $` : "…", k: "Prix minimum" },71    { v: stats?.max_price != null ? `${Math.round(stats.max_price).toLocaleString("fr-CA")} $` : "…", k: "Prix maximum" },72  ];7374  return (75    <div className="container stats-page">76      <span className="kicker">Le marché agrégé</span>77      <h1 className="stats-title">Statistiques</h1>78      <p className="sub">79        Portrait en direct des propriétés à vendre agrégées par Immo-Ka à travers toutes les80        agences connectées. Répartitions calculées sur un échantillon des annonces actives.81      </p>8283      <div className="tiles">84        {tiles.map((t) => (85          <div className={`tile ${t.hero ? "hero-tile" : ""}`} key={t.k}>86            <div className="tile-v">{t.v}</div>87            <div className="tile-k">{t.k}</div>88          </div>89        ))}90      </div>9192      <div className="viz-grid">93        <div className="viz-card">94          <h2>Par type de propriété</h2>95          <div className="viz-sub">Échantillon des annonces actives</div>96          {byType.length ? <Bars rows={byType} /> : <p className="fine">Chargement…</p>}97        </div>98        <div className="viz-card">99          <h2>Par agence</h2>100          <div className="viz-sub">Annonces actives (après déduplication)</div>101          {bySource.length ? <Bars rows={bySource} /> : <p className="fine">Chargement…</p>}102        </div>103      </div>104105      <div className="viz-card">106        <h2>Par ville</h2>107        <div className="viz-sub">Top 15 · échantillon des annonces actives</div>108        {byCity.length ? <Bars rows={byCity} /> : <p className="fine">Chargement…</p>}109      </div>110111      {stats?.recent_syncs && stats.recent_syncs.length > 0 && (112        <div className="viz-card">113          <h2>Synchronisations récentes</h2>114          <div className="viz-sub">Journal du moteur d'agrégation</div>115          <div className="viz-table">116            <table>117              <thead><tr><th>Agence</th><th>Trouvées</th><th>Ajoutées</th><th>MàJ</th><th>Retirées</th><th>Quand</th></tr></thead>118              <tbody>119                {stats.recent_syncs.slice(0, 15).map((s, i) => (120                  <tr key={i}>121                    <td>{sourceName(s.source)}</td>122                    <td>{s.found}</td><td>{s.added}</td><td>{s.updated}</td><td>{s.removed}</td>123                    <td style={{ color: "var(--ink-3)" }}>{new Date(s.ts * 1000).toLocaleString("fr-CA")}</td>124                  </tr>125                ))}126              </tbody>127            </table>128          </div>129        </div>130      )}131132      <p className="stats-foot">Mise à jour automatique — chaque fiche renvoie à l'annonce originale de l'agence.</p>133    </div>134  );135}136