← back to Ventura Claw Leads
yolo tick 22: /admin/leads pagination — 50/page with prev/next chips
77c0ec3f0cb9d5cf141b763f6dba5c3dec7b3206 · 2026-05-06 23:54:40 -0700 · Steve Abrams
Replaces the hardcoded LIMIT 200 with cursor-friendly offset pagination.
50 rows/page, capped at 200 pages (10k rows = sane upper bound).
routes/admin.js: ?page= param (1-indexed, clamped 1..200). Fetches PER_PAGE+1
rows so we can detect 'has next' without a separate COUNT query — leads.length
> PER_PAGE means there's another page. Returns hasPrev/hasNext flags +
page + perPage to the view.
views/admin/leads.ejs: pagination footer at the bottom of the table:
'Showing 51-100 of 247' [← Prev] [Next →]
URL helper preserves filter+range+q across pagination clicks (chip state
already preserved across pagination, pagination state already preserved
across chip clicks — the search form drops it intentionally since a new
search resets to page 1).
46/46 tests still pass. Combined freely with All/Unread + 7d/30d/90d/All
+ ?q= search box — orthogonal axes.
Files touched
M routes/admin.jsM views/admin/leads.ejs
Diff
commit 77c0ec3f0cb9d5cf141b763f6dba5c3dec7b3206
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed May 6 23:54:40 2026 -0700
yolo tick 22: /admin/leads pagination — 50/page with prev/next chips
Replaces the hardcoded LIMIT 200 with cursor-friendly offset pagination.
50 rows/page, capped at 200 pages (10k rows = sane upper bound).
routes/admin.js: ?page= param (1-indexed, clamped 1..200). Fetches PER_PAGE+1
rows so we can detect 'has next' without a separate COUNT query — leads.length
> PER_PAGE means there's another page. Returns hasPrev/hasNext flags +
page + perPage to the view.
views/admin/leads.ejs: pagination footer at the bottom of the table:
'Showing 51-100 of 247' [← Prev] [Next →]
URL helper preserves filter+range+q across pagination clicks (chip state
already preserved across pagination, pagination state already preserved
across chip clicks — the search form drops it intentionally since a new
search resets to page 1).
46/46 tests still pass. Combined freely with All/Unread + 7d/30d/90d/All
+ ?q= search box — orthogonal axes.
---
routes/admin.js | 19 ++++++++++++++++---
views/admin/leads.ejs | 22 ++++++++++++++++++++++
2 files changed, 38 insertions(+), 3 deletions(-)
diff --git a/routes/admin.js b/routes/admin.js
index 1ac601d..e0b9055 100644
--- a/routes/admin.js
+++ b/routes/admin.js
@@ -158,6 +158,8 @@ router.get('/leads', async (req, res, next) => {
const rangeKey = String(req.query.range || 'all');
const rangeDays = rangeWhitelist[rangeKey] !== undefined ? rangeWhitelist[rangeKey] : null;
const q = String(req.query.q || '').trim().slice(0, 120);
+ const PER_PAGE = 50;
+ const page = Math.max(1, Math.min(parseInt(req.query.page, 10) || 1, 200)); // cap at 200 pages = 10k rows; sane upper bound
const where = ['business_id = $1'];
const params = [bizId];
@@ -168,15 +170,22 @@ router.get('/leads', async (req, res, next) => {
where.push(`(LOWER(consumer_name) LIKE $${params.length} OR LOWER(consumer_email) LIKE $${params.length} OR LOWER(message) LIKE $${params.length} OR LOWER(zip) LIKE $${params.length})`);
}
- const leads = await db.many(`
+ // Fetch PER_PAGE+1 to detect whether a next page exists without a separate COUNT.
+ params.push(PER_PAGE + 1, (page - 1) * PER_PAGE);
+ const limitIdx = params.length - 1;
+ const offsetIdx = params.length;
+ const fetched = await db.many(`
SELECT id, consumer_name, consumer_email, consumer_phone, message, zip, source,
created_at, delivered_at, delivery_email, delivery_msg_id, bill_amount_cents,
read_at
FROM business_interest
WHERE ${where.join(' AND ')}
ORDER BY (read_at IS NOT NULL), created_at DESC
- LIMIT 200
+ LIMIT $${limitIdx} OFFSET $${offsetIdx}
`, params);
+ const hasNext = fetched.length > PER_PAGE;
+ const leads = fetched.slice(0, PER_PAGE);
+
const counts = await db.one(`
SELECT COUNT(*)::int AS total,
COUNT(*) FILTER (WHERE read_at IS NULL)::int AS unread,
@@ -185,7 +194,11 @@ router.get('/leads', async (req, res, next) => {
COUNT(*) FILTER (WHERE created_at > now() - interval '90 days')::int AS last_90
FROM business_interest WHERE business_id = $1
`, [bizId]);
- res.render('admin/leads', { title: 'Leads · Ventura Claw', leads, filter, counts, range: rangeKey, q });
+ res.render('admin/leads', {
+ title: 'Leads · Ventura Claw',
+ leads, filter, counts, range: rangeKey, q,
+ page, hasNext, hasPrev: page > 1, perPage: PER_PAGE
+ });
} catch (err) { next(err); }
});
diff --git a/views/admin/leads.ejs b/views/admin/leads.ejs
index e8d21f8..b30bb51 100644
--- a/views/admin/leads.ejs
+++ b/views/admin/leads.ejs
@@ -92,6 +92,28 @@
<% }); %>
</tbody>
</table>
+
+ <% if (hasPrev || hasNext) {
+ function pageHref(p) {
+ var pp = new URLSearchParams();
+ if (filter === 'unread') pp.set('filter', 'unread');
+ if (range !== 'all') pp.set('range', range);
+ if (q) pp.set('q', q);
+ if (p > 1) pp.set('page', p);
+ var s = pp.toString();
+ return '/admin/leads' + (s ? '?' + s : '');
+ }
+ var firstNum = (page - 1) * perPage + 1;
+ var lastNum = firstNum + leads.length - 1;
+ %>
+ <div style="display:flex;align-items:center;justify-content:space-between;margin:24px 0;font-size:13px;color:var(--fg-muted)">
+ <span>Showing <%= firstNum %>–<%= lastNum %><% if (counts.total > 0) { %> of <%= counts.total %><% } %></span>
+ <div style="display:flex;gap:8px">
+ <% if (hasPrev) { %><a href="<%= pageHref(page - 1) %>" class="btn btn-ghost btn-sm">← Prev</a><% } %>
+ <% if (hasNext) { %><a href="<%= pageHref(page + 1) %>" class="btn btn-ghost btn-sm">Next →</a><% } %>
+ </div>
+ </div>
+ <% } %>
<% } %>
</section>
← eb723c7 yolo tick 21: per-business profile-view counter (with bot+se
·
back to Ventura Claw Leads
·
yolo tick 23: og:image + Twitter Card image + 'Share this li c73de6a →