# Pagination & Day Grouping Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add offset-based pagination and day-grouped session rendering to the History page. **Architecture:** Backend adds `offset` param and `X-Prev-Last-Date` header to `GET /sessions`. Frontend adds page state, groups sessions by local date at render time with styled day headers, and renders a Prev/Next pagination bar. **Tech Stack:** Node.js/Express/better-sqlite3 (backend), React/Tailwind CSS (frontend), Jest/supertest (tests) --- ### Task 1: Backend — Add `offset` param and `X-Prev-Last-Date` header **Files:** - Modify: `backend/routes/sessions.js:22-76` (the `GET /` handler) - Test: `tests/api/session-archive.test.js` (add new tests to the existing `GET /api/sessions` describe block) - [ ] **Step 1: Write failing tests for `offset` and `X-Prev-Last-Date`** Add these tests at the end of the `GET /api/sessions — filter and limit` describe block in `tests/api/session-archive.test.js`: ```javascript test('offset skips the first N sessions', async () => { for (let i = 0; i < 5; i++) { seedSession({ is_active: 0, notes: null }); } const allRes = await request(app).get('/api/sessions?filter=all&limit=all'); const offsetRes = await request(app).get('/api/sessions?filter=all&limit=2&offset=2'); expect(offsetRes.status).toBe(200); expect(offsetRes.body).toHaveLength(2); expect(offsetRes.body[0].id).toBe(allRes.body[2].id); expect(offsetRes.body[1].id).toBe(allRes.body[3].id); }); test('offset defaults to 0 when not provided', async () => { for (let i = 0; i < 3; i++) { seedSession({ is_active: 0, notes: null }); } const res = await request(app).get('/api/sessions?filter=all&limit=2'); expect(res.status).toBe(200); expect(res.body).toHaveLength(2); }); test('negative offset is clamped to 0', async () => { seedSession({ is_active: 0, notes: null }); const res = await request(app).get('/api/sessions?filter=all&offset=-5'); expect(res.status).toBe(200); expect(res.body).toHaveLength(1); }); test('non-numeric offset is clamped to 0', async () => { seedSession({ is_active: 0, notes: null }); const res = await request(app).get('/api/sessions?filter=all&offset=abc'); expect(res.status).toBe(200); expect(res.body).toHaveLength(1); }); test('offset past end returns empty array', async () => { seedSession({ is_active: 0, notes: null }); const res = await request(app).get('/api/sessions?filter=all&limit=5&offset=100'); expect(res.status).toBe(200); expect(res.body).toHaveLength(0); expect(res.headers['x-total-count']).toBe('1'); }); test('X-Prev-Last-Date header is set with correct value when offset > 0', async () => { for (let i = 0; i < 5; i++) { seedSession({ is_active: 0, notes: null }); } const allRes = await request(app).get('/api/sessions?filter=all&limit=all'); const res = await request(app).get('/api/sessions?filter=all&limit=2&offset=2'); expect(res.headers['x-prev-last-date']).toBe(allRes.body[1].created_at); }); test('X-Prev-Last-Date header is absent when offset is 0', async () => { seedSession({ is_active: 0, notes: null }); const res = await request(app).get('/api/sessions?filter=all&limit=2'); expect(res.headers['x-prev-last-date']).toBeUndefined(); }); test('X-Total-Count is unaffected by offset', async () => { for (let i = 0; i < 10; i++) { seedSession({ is_active: 0, notes: null }); } const res = await request(app).get('/api/sessions?filter=all&limit=3&offset=6'); expect(res.headers['x-total-count']).toBe('10'); expect(res.body).toHaveLength(3); }); test('offset works with filter=default', async () => { for (let i = 0; i < 5; i++) { seedSession({ is_active: 0, notes: null }); } const archived = seedSession({ is_active: 0, notes: null }); require('../helpers/test-utils').db.prepare( 'UPDATE sessions SET archived = 1 WHERE id = ?' ).run(archived.id); const res = await request(app).get('/api/sessions?filter=default&limit=2&offset=2'); expect(res.status).toBe(200); expect(res.body).toHaveLength(2); expect(res.headers['x-total-count']).toBe('5'); res.body.forEach(s => expect(s.archived).toBe(0)); }); ``` - [ ] **Step 2: Run tests to verify they fail** Run: `npx jest tests/api/session-archive.test.js --no-coverage --forceExit` Expected: 9 new tests FAIL (offset is not yet implemented) - [ ] **Step 3: Implement offset and X-Prev-Last-Date in the GET handler** In `backend/routes/sessions.js`, modify the `router.get('/')` handler (lines 22-76). After parsing `limitParam` (line 25), add offset parsing: ```javascript const offsetParam = req.query.offset || '0'; let offset = parseInt(offsetParam, 10); if (isNaN(offset) || offset < 0) offset = 0; ``` After the `limitClause` block (line 46), build the offset clause: ```javascript let offsetClause = ''; if (offset > 0) { offsetClause = `OFFSET ${offset}`; } ``` Update the sessions query (line 62) to include `${offsetClause}` after `${limitClause}`: ```sql ORDER BY s.created_at DESC ${limitClause} ${offsetClause} ``` Before `res.set('X-Total-Count', ...)`, add the `X-Prev-Last-Date` logic: ```javascript if (offset > 0) { const prevRow = db.prepare(` SELECT s.created_at FROM sessions s ${whereClause} ORDER BY s.created_at DESC LIMIT 1 OFFSET ${offset - 1} `).get(); if (prevRow) { res.set('X-Prev-Last-Date', prevRow.created_at); } } ``` - [ ] **Step 4: Run tests to verify they pass** Run: `npx jest tests/api/session-archive.test.js --no-coverage --forceExit` Expected: ALL tests pass (24 existing + 9 new = 33) - [ ] **Step 5: Commit** ```bash git add backend/routes/sessions.js tests/api/session-archive.test.js git commit -m "feat: add offset pagination and X-Prev-Last-Date header to GET /sessions" ``` --- ### Task 2: Frontend — Date utility helpers **Files:** - Modify: `frontend/src/utils/dateUtils.js` - [ ] **Step 1: Add `getLocalDateKey` and `formatDayHeader` helpers** Append to `frontend/src/utils/dateUtils.js`: ```javascript /** * Get a locale-independent date key for grouping sessions by local calendar day * @param {string} sqliteTimestamp * @returns {string} - e.g., "2026-03-23" */ export function getLocalDateKey(sqliteTimestamp) { const d = parseUTCTimestamp(sqliteTimestamp); const year = d.getFullYear(); const month = String(d.getMonth() + 1).padStart(2, '0'); const day = String(d.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; } /** * Format a SQLite timestamp as a day header string (e.g., "Sunday, Mar 23, 2026") * @param {string} sqliteTimestamp * @returns {string} */ export function formatDayHeader(sqliteTimestamp) { const d = parseUTCTimestamp(sqliteTimestamp); return d.toLocaleDateString(undefined, { weekday: 'long', year: 'numeric', month: 'short', day: 'numeric', }); } /** * Format a SQLite timestamp as a time-only string (e.g., "7:30 PM") * @param {string} sqliteTimestamp * @returns {string} */ export function formatTimeOnly(sqliteTimestamp) { const d = parseUTCTimestamp(sqliteTimestamp); return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit', }); } ``` - [ ] **Step 2: Verify frontend builds** Run: `cd frontend && npm run build` Expected: Build succeeds with no errors - [ ] **Step 3: Commit** ```bash git add frontend/src/utils/dateUtils.js git commit -m "feat: add getLocalDateKey, formatDayHeader, formatTimeOnly date helpers" ``` --- ### Task 3: Frontend — Pagination state and API integration **Files:** - Modify: `frontend/src/pages/History.jsx:14-75` (state declarations and `loadSessions`) - [ ] **Step 1: Add page state and update loadSessions** In `History.jsx`, add state after line 17 (`absoluteTotal`): ```javascript const [page, setPage] = useState(1); const [prevLastDate, setPrevLastDate] = useState(null); ``` Update `loadSessions` (the `api.get` call around line 32) to pass `offset`: ```javascript const limitNum = limit === 'all' ? null : parseInt(limit, 10); const offset = limitNum ? (page - 1) * limitNum : 0; const response = await api.get('/sessions', { params: { filter, limit, offset: offset || undefined } }); ``` After setting `absoluteTotal`, add: ```javascript setPrevLastDate(response.headers['x-prev-last-date'] || null); ``` Add `page` to the `useCallback` dependency array for `loadSessions`. - [ ] **Step 2: Add page reset logic** Update `handleFilterChange` and `handleLimitChange` to reset page: ```javascript const handleFilterChange = (newFilter) => { setFilter(newFilter); localStorage.setItem(prefixKey(adminName, 'history-filter'), newFilter); setSelectedIds(new Set()); setPage(1); }; const handleLimitChange = (newLimit) => { setLimit(newLimit); localStorage.setItem(prefixKey(adminName, 'history-show-limit'), newLimit); setSelectedIds(new Set()); setPage(1); }; ``` Add auto-reset when page becomes empty. Place this check **after** all state updates (`setSessions`, `setTotalCount`, `setAbsoluteTotal`, `setPrevLastDate`) to avoid stale state: ```javascript setSessions(response.data); setTotalCount(parseInt(response.headers['x-total-count'] || '0', 10)); setAbsoluteTotal(parseInt(response.headers['x-absolute-total'] || '0', 10)); setPrevLastDate(response.headers['x-prev-last-date'] || null); if (response.data.length === 0 && offset > 0) { setPage(1); } ``` Also add `setPage(1)` to `exitSelectMode`: ```javascript const exitSelectMode = () => { setSelectMode(false); setSelectedIds(new Set()); setShowBulkDeleteConfirm(false); setPage(1); }; ``` And in the select mode toggle button's `onClick` (where `setSelectMode(true)` is called), add `setPage(1)` after it. Similarly in `handlePointerDown` where `setSelectMode(true)` is called, add `setPage(1)` after it. - [ ] **Step 3: Verify frontend builds** Run: `cd frontend && npm run build` Expected: Build succeeds - [ ] **Step 4: Commit** ```bash git add frontend/src/pages/History.jsx git commit -m "feat: add pagination state and offset to session API calls" ``` --- ### Task 4: Frontend — Day grouping rendering **Files:** - Modify: `frontend/src/pages/History.jsx:1-8` (imports) and `208-316` (session list rendering) - [ ] **Step 1: Update imports** Replace the `dateUtils` import at line 6: ```javascript import { formatDayHeader, formatTimeOnly, getLocalDateKey, isSunday } from '../utils/dateUtils'; ``` (Remove `formatLocalDate` since session cards will now show time-only under day headers.) - [ ] **Step 2: Add grouping logic and render day headers** Replace the session list rendering section (`{sessions.map(session => { ... })}`) with day-grouped rendering. The grouping is computed at render time using `useMemo`: Add before the `return` statement (above `if (loading)`): ```javascript const groupedSessions = useMemo(() => { const groups = []; let currentKey = null; sessions.forEach(session => { const dateKey = getLocalDateKey(session.created_at); if (dateKey !== currentKey) { currentKey = dateKey; groups.push({ dateKey, sessions: [session] }); } else { groups[groups.length - 1].sessions.push(session); } }); return groups; }, [sessions]); ``` Add `useMemo` to the React import at line 1. Replace the `{sessions.map(session => { ... })}` block inside `
` with: ```jsx {groupedSessions.map((group, groupIdx) => { const isSundayGroup = isSunday(group.sessions[0].created_at); const isContinued = groupIdx === 0 && page > 1 && prevLastDate && getLocalDateKey(prevLastDate) === group.dateKey; return (
{/* Day header bar */}
{formatDayHeader(group.sessions[0].created_at)} {isContinued && ( (continued) )}
{!isContinued && (
{group.sessions.length} session{group.sessions.length !== 1 ? 's' : ''} {isSundayGroup && ( 🎲 Game Night )}
)}
{/* Session cards under this day */}
{group.sessions.map(session => { const isActive = session.is_active === 1; const isSelected = selectedIds.has(session.id); const isArchived = session.archived === 1; const canSelect = selectMode && !isActive; return (
{ if (longPressFired.current) { longPressFired.current = false; return; } if (selectMode) { if (!isActive) toggleSelection(session.id); } else { navigate(`/history/${session.id}`); } }} onPointerDown={() => { if (!isActive) handlePointerDown(session.id); }} onPointerUp={handlePointerUp} onPointerLeave={handlePointerUp} >
{selectMode && (
{isSelected && ( )}
)}
Session #{session.id} {isActive && ( Active )} {isArchived && (filter === 'all' || filter === 'archived') && ( Archived )}
{session.games_played} game{session.games_played !== 1 ? 's' : ''}
{formatTimeOnly(session.created_at)}
{session.has_notes && session.notes_preview && (
{session.notes_preview}
)}
{!selectMode && isAuthenticated && isActive && (
)}
); })}
); })} ``` Each session card inside the group keeps its existing structure (selection, badges, notes preview, etc.) but: - The date line changes from `formatLocalDate(session.created_at)` to `formatTimeOnly(session.created_at)` - Remove the per-card `isSundaySession` badge (`🎲 Game Night` span) and the `· Sunday` text — these are now on the day header - Remove the `isSundaySession` const from inside the card map — it's computed per-group instead - [ ] **Step 3: Verify frontend builds** Run: `cd frontend && npm run build` Expected: Build succeeds - [ ] **Step 4: Commit** ```bash git add frontend/src/pages/History.jsx git commit -m "feat: render sessions grouped by day with styled header bars" ``` --- ### Task 5: Frontend — Pagination bar **Files:** - Modify: `frontend/src/pages/History.jsx` (add pagination bar below session list, above multi-select action bar) - [ ] **Step 1: Add pagination bar JSX** After the closing `
` of `
` (the session list) and before the multi-select action bar `{selectMode && selectedIds.size > 0 && (`, add: ```jsx {/* Pagination bar */} {limit !== 'all' && (() => { const limitNum = parseInt(limit, 10); const totalPages = Math.ceil(totalCount / limitNum); if (totalPages <= 1) return null; return (
Page {page} of {totalPages}
); })()} ``` - [ ] **Step 2: Verify frontend builds** Run: `cd frontend && npm run build` Expected: Build succeeds - [ ] **Step 3: Commit** ```bash git add frontend/src/pages/History.jsx git commit -m "feat: add Prev/Next pagination bar to session history" ``` --- ### Task 6: Final verification **Files:** None (verification only) - [ ] **Step 1: Run full backend test suite** Run: `npx jest --no-coverage --forceExit` Expected: All tests pass (147 existing + 9 new = 156) - [ ] **Step 2: Verify frontend build** Run: `cd frontend && npm run build` Expected: Clean build, no warnings - [ ] **Step 3: Final commit if any cleanup needed**