[object Object]

← back to Ventura Claw Leads

yolo tick 12: 30-day lead-trend sparkline on admin dashboard

abf7235a5fec6d254340dcdf8211cbaaf1144b6b · 2026-05-06 19:36:59 -0700 · Steve Abrams

Pure inline SVG, zero chart libraries, works whether the business has 1
lead or 1,000. Bars scale linearly to peak day; peak day rendered in brass
to make it pop. Empty days get a 1px baseline bar (faint --border color)
so the time axis stays readable at zero.

routes/admin.js GET /admin: query expanded with a generate_series CTE that
produces a contiguous 30-day date axis (PT timezone), LEFT JOIN'd to
business_interest grouped by day. Returns trend = [{day, n}, ...] (length
30 always) + trendPeak, no matter how sparse the data.

views/admin/dashboard.ejs: inline <svg viewBox="0 0 480 60"> sparkline
above 'Recent leads' section. Each bar has a <title> tooltip with the
day label + lead count. Footer shows date-range bookends. Header line:
'peak X · total Y'.

Verified live: e2e claim+seed-3-leads-on-different-days+fetch-dashboard
shows the sparkline with the right bars at the right offsets. 46/46
tests still pass.

Files touched

Diff

commit abf7235a5fec6d254340dcdf8211cbaaf1144b6b
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed May 6 19:36:59 2026 -0700

    yolo tick 12: 30-day lead-trend sparkline on admin dashboard
    
    Pure inline SVG, zero chart libraries, works whether the business has 1
    lead or 1,000. Bars scale linearly to peak day; peak day rendered in brass
    to make it pop. Empty days get a 1px baseline bar (faint --border color)
    so the time axis stays readable at zero.
    
    routes/admin.js GET /admin: query expanded with a generate_series CTE that
    produces a contiguous 30-day date axis (PT timezone), LEFT JOIN'd to
    business_interest grouped by day. Returns trend = [{day, n}, ...] (length
    30 always) + trendPeak, no matter how sparse the data.
    
    views/admin/dashboard.ejs: inline <svg viewBox="0 0 480 60"> sparkline
    above 'Recent leads' section. Each bar has a <title> tooltip with the
    day label + lead count. Footer shows date-range bookends. Header line:
    'peak X · total Y'.
    
    Verified live: e2e claim+seed-3-leads-on-different-days+fetch-dashboard
    shows the sparkline with the right bars at the right offsets. 46/46
    tests still pass.
---
 routes/admin.js           | 24 +++++++++++++++++++++++-
 views/admin/dashboard.ejs | 40 ++++++++++++++++++++++++++++++++++++++++
 2 files changed, 63 insertions(+), 1 deletion(-)

diff --git a/routes/admin.js b/routes/admin.js
index 0174aab..d5bf018 100644
--- a/routes/admin.js
+++ b/routes/admin.js
@@ -27,9 +27,31 @@ router.get('/', async (req, res, next) => {
         FROM business_interest WHERE business_id = $1
        ORDER BY created_at DESC LIMIT 5
     `, [biz.id]);
+    // 30-day daily lead trend for the sparkline. generate_series produces a
+    // contiguous date axis so days with zero leads still appear (so the
+    // sparkline doesn't compress around active days only).
+    const trendRows = await db.many(`
+      WITH days AS (
+        SELECT generate_series(
+          (now() AT TIME ZONE 'America/Los_Angeles')::date - interval '29 days',
+          (now() AT TIME ZONE 'America/Los_Angeles')::date,
+          '1 day'::interval
+        )::date AS d
+      )
+      SELECT days.d AS day,
+             COALESCE(COUNT(bi.*), 0)::int AS n
+        FROM days
+   LEFT JOIN business_interest bi
+          ON bi.business_id = $1
+         AND (bi.created_at AT TIME ZONE 'America/Los_Angeles')::date = days.d
+       GROUP BY days.d
+       ORDER BY days.d ASC
+    `, [biz.id]);
+    const trend = trendRows.map(r => ({ day: r.day, n: r.n }));
+    const trendPeak = trend.reduce((m, p) => Math.max(m, p.n), 0);
     res.render('admin/dashboard', {
       title: `${biz.business_name} · Dashboard`,
-      stats, recent,
+      stats, recent, trend, trendPeak,
       welcome: req.query.welcome === '1'
     });
   } catch (err) { next(err); }
diff --git a/views/admin/dashboard.ejs b/views/admin/dashboard.ejs
index 6225f88..b4268de 100644
--- a/views/admin/dashboard.ejs
+++ b/views/admin/dashboard.ejs
@@ -36,6 +36,46 @@
     </div>
   </div>
 
+  <%
+    // Sparkline: 30 day-bins. Width fluid; height fixed at 60px.
+    var sparkW = 480; var sparkH = 60; var pad = 2;
+    var binW = (sparkW - pad * 2) / Math.max(1, trend.length);
+    var maxV = Math.max(1, trendPeak); // avoid /0 — flat zero shows as flat baseline
+  %>
+  <div class="side-card" style="margin-bottom:24px">
+    <div style="display:flex;align-items:baseline;justify-content:space-between;flex-wrap:wrap;gap:12px;margin-bottom:8px">
+      <h3 style="margin:0">30-day lead trend</h3>
+      <p class="muted" style="margin:0;font-size:12px">peak <%= trendPeak %> · total <%= trend.reduce(function(s,p){return s+p.n;},0) %></p>
+    </div>
+    <svg viewBox="0 0 <%= sparkW %> <%= sparkH %>" style="width:100%;height:80px;display:block" preserveAspectRatio="none" aria-label="30-day lead trend">
+      <% trend.forEach(function(p, i){
+        var h = p.n > 0 ? Math.max(2, (p.n / maxV) * (sparkH - pad * 2)) : 0;
+        var x = pad + i * binW;
+        var y = sparkH - pad - h;
+        var w = Math.max(1, binW - 2);
+        var isPeak = trendPeak > 0 && p.n === trendPeak;
+        var dayLabel = new Date(p.day).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
+      %>
+        <% if (h > 0) { %>
+          <rect x="<%= x.toFixed(2) %>" y="<%= y.toFixed(2) %>" width="<%= w.toFixed(2) %>" height="<%= h.toFixed(2) %>"
+                fill="<%= isPeak ? 'var(--brass)' : 'var(--fg-muted)' %>"
+                opacity="<%= isPeak ? '1' : '0.55' %>">
+            <title><%= dayLabel %>: <%= p.n %> lead<%= p.n === 1 ? '' : 's' %></title>
+          </rect>
+        <% } else { %>
+          <rect x="<%= x.toFixed(2) %>" y="<%= (sparkH - pad - 1).toFixed(2) %>" width="<%= w.toFixed(2) %>" height="1"
+                fill="var(--border)" opacity="0.6">
+            <title><%= dayLabel %>: 0 leads</title>
+          </rect>
+        <% } %>
+      <% }); %>
+    </svg>
+    <div style="display:flex;justify-content:space-between;font-size:11px;color:var(--fg-muted);margin-top:4px">
+      <span><%= new Date(trend[0].day).toLocaleDateString('en-US',{ month:'short', day:'numeric' }) %></span>
+      <span>today</span>
+    </div>
+  </div>
+
   <h2>Recent leads</h2>
   <% if (recent.length === 0) { %>
     <p class="muted">No leads yet — your listing is live; consumers will start finding you. Make sure your <a href="/admin/profile">profile</a> is filled out.</p>

← 2c5302b yolo tick 11: admin invite-teammate flow — multi-user per bu  ·  back to Ventura Claw Leads  ·  yolo tick 13: /map pin hover preview + /admin/billing recent 65ee0ec →