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