← 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
M routes/admin.jsM views/admin/dashboard.ejs
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 →