From de1a02b9bb0874968d11950bdf48b88e8573f999 Mon Sep 17 00:00:00 2001 From: cottongin Date: Mon, 23 Mar 2026 11:26:37 -0400 Subject: [PATCH] docs: pagination and day grouping implementation plan Made-with: Cursor --- .../2026-03-23-pagination-day-grouping.md | 611 ++++++++++++++++++ 1 file changed, 611 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-23-pagination-day-grouping.md diff --git a/docs/superpowers/plans/2026-03-23-pagination-day-grouping.md b/docs/superpowers/plans/2026-03-23-pagination-day-grouping.md new file mode 100644 index 0000000..c12f72e --- /dev/null +++ b/docs/superpowers/plans/2026-03-23-pagination-day-grouping.md @@ -0,0 +1,611 @@ +# 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**