SPB Git forge

spb/home-ka

Public
10commits 1branches 0releases
793.0 KBsize
maindefault branch
20 days agolast push
Python 49.6% TypeScript 25.5% CSS 24.1%
6.9 KB · 177 lines tsx
Raw Blame History
1// -----------------------------------------------------------------------------2// Home-Ka — US real-estate aggregator (Groupe KA)3// Author: Simon-Pierre Boucher — contact@spboucher.ai4// pages/Stats.tsx : simple live dashboard — totals, by state, by type,5//   recent syncs (/api/stats).6// -----------------------------------------------------------------------------7import { useEffect, useState } from "react";8import { Stats, fetchStats, sourceName } from "../api";910const n = (x: number | null | undefined) =>11  x == null ? "—" : Math.round(x).toLocaleString("en-US");12const usd = (x: number | null | undefined) =>13  x == null ? "—" : "$" + Math.round(x).toLocaleString("en-US");1415function ago(ts: number): string {16  const s = Math.max(0, Math.floor(Date.now() / 1000 - (ts > 1e12 ? ts / 1000 : ts)));17  if (s < 3600) return `${Math.round(s / 60)} min ago`;18  if (s < 172800) return `${Math.round(s / 3600)} h ago`;19  return `${Math.round(s / 86400)} d ago`;20}2122export default function StatsPage() {23  const [stats, setStats] = useState<Stats | null>(null);24  const [error, setError] = useState<string | null>(null);2526  useEffect(() => {27    fetchStats().then(setStats).catch((e) => setError(String(e)));28  }, []);2930  return (31    <div className="container stats-page" style={{ display: "grid", gap: 22, minWidth: 0 }}>32      <header style={{ minWidth: 0 }}>33        <span className="kicker">The aggregated market</span>34        <h1 className="stats-title">Statistics</h1>35        <p className="sub" style={{ marginBottom: 14 }}>36          Live dashboard of the homes for sale aggregated by Home-Ka — volumes37          and prices computed on the platform's real data (source duplicates38          excluded, "price on request" excluded).39        </p>40      </header>4142      {error && (43        <div className="notice">44          <h2>Statistics unavailable</h2>45          <p>{error}</p>46        </div>47      )}48      {!stats && !error && <div className="notice">Loading…</div>}4950      {stats && (51        <>52          {/* --- totals row -------------------------------------------------- */}53          <section aria-label="Key totals" className="tiles">54            <div className="tile hero-tile">55              <div className="tile-v">{n(stats.total)}</div>56              <div className="tile-k">Homes for sale</div>57            </div>58            <div className="tile">59              <div className="tile-v">{n(stats.properties)}</div>60              <div className="tile-k">Physical properties tracked</div>61            </div>62            <div className="tile">63              <div className="tile-v">{n(stats.states)}</div>64              <div className="tile-k">States</div>65            </div>66            <div className="tile">67              <div className="tile-v">{n(stats.cities)}</div>68              <div className="tile-k">Cities</div>69            </div>70            <div className="tile">71              <div className="tile-v">{n(stats.sources)}</div>72              <div className="tile-k">Sources</div>73            </div>74            <div className="tile">75              <div className="tile-v">{usd(stats.avg_price)}</div>76              <div className="tile-k">Average price</div>77            </div>78            <div className="tile">79              <div className="tile-v">{usd(stats.min_price)}</div>80              <div className="tile-k">Lowest price</div>81            </div>82            <div className="tile">83              <div className="tile-v">{usd(stats.max_price)}</div>84              <div className="tile-k">Highest price</div>85            </div>86          </section>8788          {/* --- by state ----------------------------------------------------- */}89          {stats.by_state.length > 0 && (90            <section aria-label="By state">91              <h2>By state</h2>92              <div className="q-table-wrap">93                <table className="q-table">94                  <thead>95                    <tr><th>State</th><th>Homes</th><th>Average price</th></tr>96                  </thead>97                  <tbody>98                    {stats.by_state.map((r) => (99                      <tr key={r.state}>100                        <td>{r.state}</td>101                        <td className="num">{n(r.n)}</td>102                        <td className="num">{usd(r.avg_price)}</td>103                      </tr>104                    ))}105                  </tbody>106                </table>107              </div>108            </section>109          )}110111          {/* --- by property type ---------------------------------------------- */}112          {stats.by_type.length > 0 && (113            <section aria-label="By property type">114              <h2>By property type</h2>115              <div className="q-table-wrap">116                <table className="q-table">117                  <thead>118                    <tr><th>Type</th><th>Homes</th><th>Average price</th></tr>119                  </thead>120                  <tbody>121                    {stats.by_type.map((r) => (122                      <tr key={r.property_type}>123                        <td>{r.property_type}</td>124                        <td className="num">{n(r.n)}</td>125                        <td className="num">{usd(r.avg_price)}</td>126                      </tr>127                    ))}128                  </tbody>129                </table>130              </div>131            </section>132          )}133134          {/* --- recent syncs --------------------------------------------------- */}135          {(stats.recent_syncs?.length ?? 0) > 0 && (136            <section aria-label="Recent syncs">137              <h2>Recent syncs</h2>138              <div className="q-table-wrap">139                <table className="q-table">140                  <thead>141                    <tr>142                      <th>Source</th><th>When</th><th>Found</th>143                      <th>Added</th><th>Removed</th><th>Status</th>144                    </tr>145                  </thead>146                  <tbody>147                    {stats.recent_syncs!.map((s, i) => (148                      <tr key={`${s.source}-${s.ts}-${i}`}>149                        <td>{sourceName(s.source)}</td>150                        <td>{ago(s.ts)}</td>151                        <td className="num">{n(s.found)}</td>152                        <td className="num">{n(s.added)}</td>153                        <td className="num">{n(s.removed)}</td>154                        <td>155                          {s.ok156                            ? <span className="adm-badge adm-ok">OK</span>157                            : <span className="adm-badge adm-err" title={s.message}>Error</span>}158                        </td>159                      </tr>160                    ))}161                  </tbody>162                </table>163              </div>164            </section>165          )}166        </>167      )}168169      <p className="stats-foot">170        Updated automatically — statistics are computed on the aggregated171        listings. Every listing links back to the original announcement at172        the source.173      </p>174    </div>175  );176}177