Files
jackboxpartypack-gamepicker/docs/superpowers/plans/2026-03-23-pagination-day-grouping.md
2026-03-23 11:26:37 -04:00

20 KiB

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:

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:

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:

let offsetClause = '';
if (offset > 0) {
  offsetClause = `OFFSET ${offset}`;
}

Update the sessions query (line 62) to include ${offsetClause} after ${limitClause}:

ORDER BY s.created_at DESC
${limitClause}
${offsetClause}

Before res.set('X-Total-Count', ...), add the X-Prev-Last-Date logic:

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
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:

/**
 * 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
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):

const [page, setPage] = useState(1);
const [prevLastDate, setPrevLastDate] = useState(null);

Update loadSessions (the api.get call around line 32) to pass offset:

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:

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:

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:

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:

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
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:

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)):

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 <div className="space-y-2"> with:

{groupedSessions.map((group, groupIdx) => {
  const isSundayGroup = isSunday(group.sessions[0].created_at);
  const isContinued = groupIdx === 0 && page > 1 && prevLastDate &&
    getLocalDateKey(prevLastDate) === group.dateKey;

  return (
    <div key={group.dateKey}>
      {/* Day header bar */}
      <div className="bg-gray-100 dark:bg-[#1e2a3a] rounded-md px-3.5 py-2 mb-2 flex justify-between items-center border-l-[3px] border-indigo-500">
        <div className="flex items-center gap-2">
          <span className="text-sm font-semibold text-indigo-700 dark:text-indigo-300">
            {formatDayHeader(group.sessions[0].created_at)}
          </span>
          {isContinued && (
            <span className="text-xs text-gray-400 dark:text-gray-500 italic">(continued)</span>
          )}
        </div>
        {!isContinued && (
          <div className="flex items-center gap-2">
            <span className="text-xs text-gray-500 dark:text-gray-400">
              {group.sessions.length} session{group.sessions.length !== 1 ? 's' : ''}
            </span>
            {isSundayGroup && (
              <span className="text-xs font-semibold text-amber-700 dark:text-amber-300">🎲 Game Night</span>
            )}
          </div>
        )}
      </div>

      {/* Session cards under this day */}
      <div className="ml-3 space-y-1.5 mb-4">
        {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 (
            <div
              key={session.id}
              className={`border rounded-lg transition ${
                selectMode && isActive
                  ? 'opacity-50 cursor-not-allowed border-gray-300 dark:border-gray-600'
                  : isSelected
                    ? 'border-indigo-500 bg-indigo-50 dark:bg-indigo-900/20 cursor-pointer'
                    : 'border-gray-300 dark:border-gray-600 hover:border-indigo-400 dark:hover:border-indigo-500 cursor-pointer'
              }`}
              onClick={() => {
                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}
            >
              <div className="p-4">
                <div className="flex items-start gap-3">
                  {selectMode && (
                    <div className={`mt-0.5 w-5 h-5 flex-shrink-0 rounded border-2 flex items-center justify-center ${
                      isActive
                        ? 'border-gray-300 dark:border-gray-600 bg-gray-100 dark:bg-gray-700'
                        : isSelected
                          ? 'border-indigo-600 bg-indigo-600'
                          : 'border-gray-300 dark:border-gray-600'
                    }`}>
                      {isSelected && (
                        <span className="text-white text-xs font-bold"></span>
                      )}
                    </div>
                  )}
                  <div className="flex-1 min-w-0">
                    <div className="flex justify-between items-center mb-1">
                      <div className="flex items-center gap-2 flex-wrap">
                        <span className="font-semibold text-gray-800 dark:text-gray-100">
                          Session #{session.id}
                        </span>
                        {isActive && (
                          <span className="bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 text-xs px-2 py-0.5 rounded">
                            Active
                          </span>
                        )}
                        {isArchived && (filter === 'all' || filter === 'archived') && (
                          <span className="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 text-xs px-2 py-0.5 rounded">
                            Archived
                          </span>
                        )}
                      </div>
                      <span className="text-sm text-gray-500 dark:text-gray-400">
                        {session.games_played} game{session.games_played !== 1 ? 's' : ''}
                      </span>
                    </div>
                    <div className="text-sm text-gray-500 dark:text-gray-400">
                      {formatTimeOnly(session.created_at)}
                    </div>
                    {session.has_notes && session.notes_preview && (
                      <div className="mt-2 text-sm text-indigo-400 dark:text-indigo-300 bg-indigo-50 dark:bg-indigo-900/20 px-3 py-2 rounded border-l-2 border-indigo-500">
                        {session.notes_preview}
                      </div>
                    )}
                  </div>
                </div>
              </div>

              {!selectMode && isAuthenticated && isActive && (
                <div className="px-4 pb-4 pt-0">
                  <button
                    onClick={(e) => {
                      e.stopPropagation();
                      setClosingSession(session.id);
                    }}
                    className="w-full bg-orange-600 dark:bg-orange-700 text-white px-4 py-2 rounded text-sm hover:bg-orange-700 dark:hover:bg-orange-800 transition"
                  >
                    End Session
                  </button>
                </div>
              )}
            </div>
          );
        })}
      </div>
    </div>
  );
})}

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
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 </div> of <div className="space-y-2"> (the session list) and before the multi-select action bar {selectMode && selectedIds.size > 0 && (, add:

{/* Pagination bar */}
{limit !== 'all' && (() => {
  const limitNum = parseInt(limit, 10);
  const totalPages = Math.ceil(totalCount / limitNum);
  if (totalPages <= 1) return null;
  return (
    <div className="flex justify-center items-center gap-4 py-3 mt-3 border-t border-gray-200 dark:border-gray-700">
      <button
        onClick={() => { setPage(p => p - 1); setSelectedIds(new Set()); }}
        disabled={page <= 1}
        className={`px-4 py-1.5 rounded-md text-sm font-medium transition ${
          page <= 1
            ? 'bg-gray-600 dark:bg-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed'
            : 'bg-indigo-600 text-white hover:bg-indigo-700'
        }`}
      >
         Prev
      </button>
      <span className="text-sm text-gray-500 dark:text-gray-400">
        Page {page} of {totalPages}
      </span>
      <button
        onClick={() => { setPage(p => p + 1); setSelectedIds(new Set()); }}
        disabled={page >= totalPages}
        className={`px-4 py-1.5 rounded-md text-sm font-medium transition ${
          page >= totalPages
            ? 'bg-gray-600 dark:bg-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed'
            : 'bg-indigo-600 text-white hover:bg-indigo-700'
        }`}
      >
        Next 
      </button>
    </div>
  );
})()}
  • Step 2: Verify frontend builds

Run: cd frontend && npm run build Expected: Build succeeds

  • Step 3: Commit
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