[object Object]

← 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

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 →