[object Object]

← back to Costa Rica

yolo tick 3: /stats dashboard with KPIs, by-vertical/category/region tables, filter inputs, theme toggle

72b08a16c8266b476479bb3839283e38d7aa3d6c · 2026-05-08 08:21:06 -0700 · Steve

Files touched

Diff

commit 72b08a16c8266b476479bb3839283e38d7aa3d6c
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri May 8 08:21:06 2026 -0700

    yolo tick 3: /stats dashboard with KPIs, by-vertical/category/region tables, filter inputs, theme toggle
---
 public/stats.html | 107 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 server.js         |   2 +
 2 files changed, 109 insertions(+)

diff --git a/public/stats.html b/public/stats.html
new file mode 100644
index 0000000..5bad144
--- /dev/null
+++ b/public/stats.html
@@ -0,0 +1,107 @@
+<!doctype html>
+<html lang="en" data-theme="light">
+<head>
+  <meta charset="utf-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1" />
+  <title>Stats — Costa Rica Directory</title>
+  <script>(function(){try{var t=localStorage.getItem('cr-theme');if(t)document.documentElement.setAttribute('data-theme',t);}catch(e){}})();</script>
+  <style>
+    :root{--bg:#fafaf7;--fg:#0e1015;--muted:#5a6270;--line:#e3e0d6;--card:#fff;--card-line:#ebe7da;--accent:#0a7a3a;--accent-2:#c2a85a}
+    [data-theme="dark"]{--bg:#0e1015;--fg:#f3f1ea;--muted:#9aa3b2;--line:#23262d;--card:#15181f;--card-line:#262932;--accent:#3ec07a;--accent-2:#d8c074}
+    *{box-sizing:border-box}
+    body{margin:0;background:var(--bg);color:var(--fg);font-family:-apple-system,BlinkMacSystemFont,'Inter','Segoe UI',Roboto,sans-serif;line-height:1.55}
+    a{color:inherit;text-decoration:none} a:hover{color:var(--accent)}
+    .nav{position:sticky;top:0;z-index:30;background:color-mix(in srgb,var(--bg) 88%,transparent);backdrop-filter:saturate(160%) blur(10px);border-bottom:1px solid var(--line)}
+    .nav-in{max-width:1320px;margin:0 auto;padding:14px 22px;display:flex;align-items:center;gap:18px;flex-wrap:wrap}
+    .brand{font-weight:700;font-size:18px} .brand .pin{color:var(--accent)}
+    .nav-links{display:flex;gap:14px;flex-wrap:wrap;color:var(--muted);font-size:14px}
+    .nav-links a.active{color:var(--fg);font-weight:600}
+    .ml-auto{margin-left:auto} .icon-btn{appearance:none;background:transparent;color:inherit;border:1px solid var(--line);border-radius:999px;width:36px;height:36px;display:inline-flex;align-items:center;justify-content:center;cursor:pointer}
+    main{max-width:1320px;margin:0 auto;padding:28px 22px}
+    h1{font-size:38px;letter-spacing:-.02em;margin:6px 0 4px}
+    .sub{color:var(--muted);margin-bottom:22px}
+    .row{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:24px}
+    @media (max-width:760px){.row{grid-template-columns:repeat(2,1fr)}}
+    .stat{background:var(--card);border:1px solid var(--card-line);border-radius:14px;padding:16px}
+    .stat-n{font-size:30px;font-weight:800;letter-spacing:-.02em}
+    .stat-l{color:var(--muted);font-size:13px;text-transform:uppercase;letter-spacing:.06em;margin-top:2px}
+    .grid2{display:grid;grid-template-columns:1.2fr 1fr;gap:18px}
+    @media (max-width:900px){.grid2{grid-template-columns:1fr}}
+    .panel{background:var(--card);border:1px solid var(--card-line);border-radius:14px;padding:18px}
+    .panel h2{margin:0 0 12px;font-size:18px;letter-spacing:-.01em}
+    table{width:100%;border-collapse:collapse;font-size:14px}
+    th,td{padding:8px 6px;border-bottom:1px solid var(--line);text-align:left}
+    th{color:var(--muted);font-weight:600;text-transform:uppercase;font-size:11px;letter-spacing:.05em}
+    .bar{position:relative;background:color-mix(in srgb,var(--accent) 14%,transparent);height:8px;border-radius:999px;overflow:hidden}
+    .bar > span{display:block;height:100%;background:var(--accent);border-radius:999px}
+    .num{text-align:right;font-variant-numeric:tabular-nums}
+    .filter{display:flex;gap:8px;align-items:center;margin-bottom:10px}
+    .filter input{flex:1;background:var(--bg);color:var(--fg);border:1px solid var(--line);border-radius:8px;padding:8px 12px;font-size:13px}
+    .scroll{max-height:520px;overflow:auto}
+  </style>
+</head>
+<body>
+  <header class="nav"><div class="nav-in">
+    <a class="brand" href="/"><span class="pin">●</span> Costa Rica Directory</a>
+    <nav class="nav-links">
+      <a href="/">Browse</a><a href="/regions">Regions</a><a href="/stats" class="active">Stats</a>
+    </nav>
+    <div class="ml-auto"><button class="icon-btn" id="themeBtn" aria-label="Theme">◐</button></div>
+  </div></header>
+
+  <main>
+    <h1>Directory stats</h1>
+    <div class="sub" id="sub">Live counts across the Costa Rica catalog.</div>
+
+    <div class="row" id="kpis"></div>
+
+    <div class="grid2">
+      <section class="panel">
+        <h2>By vertical</h2>
+        <div class="filter"><input id="vfilter" placeholder="filter…" /></div>
+        <div class="scroll"><table id="vtable"><thead><tr><th>Vertical</th><th class="num">Count</th><th>Share</th></tr></thead><tbody></tbody></table></div>
+      </section>
+      <section class="panel">
+        <h2>By category</h2>
+        <table id="ctable"><thead><tr><th>Category</th><th class="num">Count</th><th>Share</th></tr></thead><tbody></tbody></table>
+      </section>
+    </div>
+
+    <section class="panel" style="margin-top:18px">
+      <h2>By region (top 30 by listings)</h2>
+      <div class="filter"><input id="rfilter" placeholder="filter region…" /></div>
+      <div class="scroll"><table id="rtable"><thead><tr><th>Region</th><th class="num">Count</th><th>Share</th></tr></thead><tbody></tbody></table></div>
+    </section>
+  </main>
+
+  <script>
+    const themeBtn=document.getElementById('themeBtn');
+    themeBtn.addEventListener('click',()=>{const cur=document.documentElement.getAttribute('data-theme')||'light';const nx=cur==='light'?'dark':'light';document.documentElement.setAttribute('data-theme',nx);try{localStorage.setItem('cr-theme',nx);}catch{}});
+
+    function bar(pct){return `<div class="bar"><span style="width:${pct.toFixed(1)}%"></span></div>`;}
+    function row(label,n,total){const pct=total?(n*100/total):0;return `<tr><td>${label}</td><td class="num">${n.toLocaleString()}</td><td style="width:38%">${bar(pct)}</td></tr>`;}
+
+    fetch('/api/stats').then(r=>r.json()).then(s=>{
+      const total=s.total||0;
+      document.getElementById('kpis').innerHTML=[
+        ['Total listings',total],
+        ['Verticals',s.by_vertical.length],
+        ['Regions w/ listings',s.by_region.filter(r=>r.n>0).length],
+        ['Categories',s.by_category.length]
+      ].map(([l,n])=>`<div class="stat"><div class="stat-n">${n.toLocaleString()}</div><div class="stat-l">${l}</div></div>`).join('');
+
+      const vbody=document.querySelector('#vtable tbody');
+      const cbody=document.querySelector('#ctable tbody');
+      const rbody=document.querySelector('#rtable tbody');
+
+      function renderV(filter=''){const f=filter.trim().toLowerCase();vbody.innerHTML=s.by_vertical.filter(v=>!f||v.vertical.toLowerCase().includes(f)).map(v=>row(v.vertical,v.n,total)).join('')||`<tr><td colspan="3" style="color:var(--muted);text-align:center;padding:18px">no matches</td></tr>`;}
+      function renderC(){cbody.innerHTML=s.by_category.map(c=>row(c.category,c.n,total)).join('');}
+      function renderR(filter=''){const f=filter.trim().toLowerCase();const items=s.by_region.filter(r=>r.n>0&&(!f||r.name.toLowerCase().includes(f))).slice(0,30);rbody.innerHTML=items.map(r=>row(`<a href="/r/${r.slug}">${r.name}</a>`,r.n,total)).join('')||`<tr><td colspan="3" style="color:var(--muted);text-align:center;padding:18px">no matches</td></tr>`;}
+
+      renderV();renderC();renderR();
+      document.getElementById('vfilter').addEventListener('input',e=>renderV(e.target.value));
+      document.getElementById('rfilter').addEventListener('input',e=>renderR(e.target.value));
+    }).catch(e=>{document.getElementById('sub').textContent='Could not load stats: '+e.message;});
+  </script>
+</body>
+</html>
diff --git a/server.js b/server.js
index 862bbac..ddeca29 100644
--- a/server.js
+++ b/server.js
@@ -387,6 +387,8 @@ app.get('/p/:slug', async (req, res) => {
   } catch (e) { res.status(500).json({ error: e.message }); }
 });
 
+app.get('/stats', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'stats.html')));
+
 app.use(express.static(path.join(__dirname, 'public')));
 
 app.listen(PORT, '0.0.0.0', () => {

← cfab471 yolo tick 2: dedicated region landing page (/r/:slug) with h  ·  back to Costa Rica  ·  track YOLO loop progress + flag prod-deploy + fallback-scrip a3854c7 →