diff --git a/docs/superpowers/plans/2026-03-23-session-archive-multiselect.md b/docs/superpowers/plans/2026-03-23-session-archive-multiselect.md new file mode 100644 index 0000000..f63b7ec --- /dev/null +++ b/docs/superpowers/plans/2026-03-23-session-archive-multiselect.md @@ -0,0 +1,1140 @@ +# Session Archive, Multi-Select, Sunday Badge, and Pagination — 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 session archiving, multi-select bulk actions, a Sunday "Game Night" badge, and configurable pagination to the History page and Session Detail page. + +**Architecture:** Backend-first approach. Add the `archived` column, then modify the list endpoint with filter/limit/total-count support, then add archive and bulk endpoints. Frontend follows: utility helpers, History page rewrite (controls bar + cards + multi-select), then SessionDetail updates. + +**Tech Stack:** Express/better-sqlite3 backend, React frontend with Tailwind CSS. No new dependencies. + +**Spec:** `docs/superpowers/specs/2026-03-23-session-archive-multiselect-design.md` + +--- + +### Task 1: Schema — Add `archived` Column + +**Files:** +- Modify: `backend/database.js` (after existing session table creation, ~line 57) + +- [ ] **Step 1: Add archived column** + +In `backend/database.js`, add after the existing sessions table creation (after the closing `);` of `CREATE TABLE IF NOT EXISTS sessions`): + +```js + // Add archived column if it doesn't exist (for existing databases) + try { + db.exec(`ALTER TABLE sessions ADD COLUMN archived INTEGER DEFAULT 0`); + } catch (err) { + // Column already exists, ignore error + } +``` + +This follows the exact same pattern used for `session_games.status`, `session_games.room_code`, etc. + +- [ ] **Step 2: Verify the column exists** + +Run: `cd backend && node -e "const db = require('./database'); console.log(db.prepare('PRAGMA table_info(sessions)').all().map(c => c.name))"` + +Expected: Array includes `'archived'` + +- [ ] **Step 3: Commit** + +```bash +git add backend/database.js +git commit -m "feat: add archived column to sessions table" +``` + +--- + +### Task 2: Backend — Modify `GET /api/sessions` List Endpoint + +**Files:** +- Modify: `backend/routes/sessions.js:22-47` (the `GET /` handler) +- Test: `tests/api/session-archive.test.js` (new file) + +- [ ] **Step 1: Write the failing tests** + +Create `tests/api/session-archive.test.js`: + +```js +const request = require('supertest'); +const { app } = require('../../backend/server'); +const { cleanDb, getAuthHeader, seedSession } = require('../helpers/test-utils'); + +describe('GET /api/sessions — filter and limit', () => { + beforeEach(() => { + cleanDb(); + }); + + test('default filter excludes archived sessions', async () => { + 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'); + expect(res.status).toBe(200); + expect(res.body).toHaveLength(1); + expect(res.body[0].archived).toBe(0); + }); + + test('filter=archived returns only archived sessions', async () => { + 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=archived'); + expect(res.status).toBe(200); + expect(res.body).toHaveLength(1); + expect(res.body[0].archived).toBe(1); + }); + + test('filter=all returns all sessions', async () => { + 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=all'); + expect(res.status).toBe(200); + expect(res.body).toHaveLength(2); + }); + + test('limit restricts number of sessions returned', 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'); + expect(res.status).toBe(200); + expect(res.body).toHaveLength(3); + }); + + test('limit=all returns all sessions', 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=all'); + expect(res.status).toBe(200); + expect(res.body).toHaveLength(10); + }); + + test('X-Total-Count header reflects total matching sessions before limit', 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'); + expect(res.headers['x-total-count']).toBe('10'); + expect(res.body).toHaveLength(3); + }); + + test('response includes archived field on each session', async () => { + seedSession({ is_active: 0, notes: null }); + + const res = await request(app).get('/api/sessions?filter=all'); + expect(res.status).toBe(200); + expect(res.body[0]).toHaveProperty('archived', 0); + }); + + test('default limit is all when no limit param provided', async () => { + for (let i = 0; i < 8; i++) { + seedSession({ is_active: 0, notes: null }); + } + + const res = await request(app).get('/api/sessions?filter=all'); + expect(res.status).toBe(200); + expect(res.body).toHaveLength(8); + }); +}); +``` + +Note: the default server-side limit is `all` (no limit) for backwards compatibility. The frontend controls the limit via query param. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd /Users/erikfredericks/dev-ai/HSO/jackboxpartypack-gamepicker && npx jest tests/api/session-archive.test.js --verbose --forceExit` + +Expected: Multiple failures — `archived` field missing from response, no filter/limit support. + +- [ ] **Step 3: Implement the modified GET / handler** + +Replace lines 22-47 of `backend/routes/sessions.js` (the entire `router.get('/', ...)` handler) with: + +```js +router.get('/', (req, res) => { + try { + const filter = req.query.filter || 'default'; + const limitParam = req.query.limit || 'all'; + + let whereClause = ''; + if (filter === 'default') { + whereClause = 'WHERE s.archived = 0'; + } else if (filter === 'archived') { + whereClause = 'WHERE s.archived = 1'; + } + + const countRow = db.prepare(` + SELECT COUNT(DISTINCT s.id) as total + FROM sessions s + ${whereClause} + `).get(); + + let limitClause = ''; + if (limitParam !== 'all') { + const limitNum = parseInt(limitParam, 10); + if (!isNaN(limitNum) && limitNum > 0) { + limitClause = `LIMIT ${limitNum}`; + } + } + + const sessions = db.prepare(` + SELECT + s.id, + s.created_at, + s.closed_at, + s.is_active, + s.archived, + s.notes, + COUNT(sg.id) as games_played + FROM sessions s + LEFT JOIN session_games sg ON s.id = sg.session_id + ${whereClause} + GROUP BY s.id + ORDER BY s.created_at DESC + ${limitClause} + `).all(); + + const result = sessions.map(({ notes, ...session }) => { + const { has_notes, notes_preview } = computeNotesPreview(notes); + return { ...session, has_notes, notes_preview }; + }); + + res.set('X-Total-Count', String(countRow.total)); + res.json(result); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd /Users/erikfredericks/dev-ai/HSO/jackboxpartypack-gamepicker && npx jest tests/api/session-archive.test.js --verbose --forceExit` + +Expected: All 8 tests PASS + +- [ ] **Step 5: Run full test suite to verify no regressions** + +Run: `cd /Users/erikfredericks/dev-ai/HSO/jackboxpartypack-gamepicker && npx jest --verbose --forceExit` + +Expected: All tests pass (existing session-notes tests may need a small adjustment if they expect the exact old response shape — check for `archived` field expectations). + +- [ ] **Step 6: Commit** + +```bash +git add backend/routes/sessions.js tests/api/session-archive.test.js +git commit -m "feat: add filter, limit, and X-Total-Count to session list endpoint" +``` + +--- + +### Task 3: Backend — Archive/Unarchive Single Session Endpoints + +**Files:** +- Modify: `backend/routes/sessions.js` (add two new routes after `DELETE /:id/notes` at line 288) +- Test: `tests/api/session-archive.test.js` (append to existing file) + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/api/session-archive.test.js`: + +```js +describe('POST /api/sessions/:id/archive', () => { + beforeEach(() => { + cleanDb(); + }); + + test('archives a closed session', async () => { + const session = seedSession({ is_active: 0, notes: null }); + + const res = await request(app) + .post(`/api/sessions/${session.id}/archive`) + .set('Authorization', getAuthHeader()); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + + const check = await request(app).get(`/api/sessions/${session.id}`); + expect(check.body.archived).toBe(1); + }); + + test('returns 400 for active session', async () => { + const session = seedSession({ is_active: 1, notes: null }); + + const res = await request(app) + .post(`/api/sessions/${session.id}/archive`) + .set('Authorization', getAuthHeader()); + + expect(res.status).toBe(400); + }); + + test('returns 404 for non-existent session', async () => { + const res = await request(app) + .post('/api/sessions/9999/archive') + .set('Authorization', getAuthHeader()); + + expect(res.status).toBe(404); + }); + + test('returns 401 without auth', async () => { + const session = seedSession({ is_active: 0, notes: null }); + + const res = await request(app) + .post(`/api/sessions/${session.id}/archive`); + + expect(res.status).toBe(401); + }); +}); + +describe('POST /api/sessions/:id/unarchive', () => { + beforeEach(() => { + cleanDb(); + }); + + test('unarchives an archived session', async () => { + const session = seedSession({ is_active: 0, notes: null }); + require('../helpers/test-utils').db.prepare( + 'UPDATE sessions SET archived = 1 WHERE id = ?' + ).run(session.id); + + const res = await request(app) + .post(`/api/sessions/${session.id}/unarchive`) + .set('Authorization', getAuthHeader()); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + + const check = await request(app).get(`/api/sessions/${session.id}`); + expect(check.body.archived).toBe(0); + }); + + test('returns 404 for non-existent session', async () => { + const res = await request(app) + .post('/api/sessions/9999/unarchive') + .set('Authorization', getAuthHeader()); + + expect(res.status).toBe(404); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx jest tests/api/session-archive.test.js --verbose --forceExit` + +Expected: New tests fail with 404 (routes don't exist yet) + +- [ ] **Step 3: Implement the archive/unarchive routes** + +Add after the `DELETE /:id/notes` route (after line 288 in `sessions.js`): + +```js +// Archive a session (admin only) +router.post('/:id/archive', authenticateToken, (req, res) => { + try { + const session = db.prepare('SELECT id, is_active FROM sessions WHERE id = ?').get(req.params.id); + + if (!session) { + return res.status(404).json({ error: 'Session not found' }); + } + + if (session.is_active === 1) { + return res.status(400).json({ error: 'Cannot archive an active session. Please close it first.' }); + } + + db.prepare('UPDATE sessions SET archived = 1 WHERE id = ?').run(req.params.id); + res.json({ success: true }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Unarchive a session (admin only) +router.post('/:id/unarchive', authenticateToken, (req, res) => { + try { + const session = db.prepare('SELECT id FROM sessions WHERE id = ?').get(req.params.id); + + if (!session) { + return res.status(404).json({ error: 'Session not found' }); + } + + db.prepare('UPDATE sessions SET archived = 0 WHERE id = ?').run(req.params.id); + res.json({ success: true }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx jest tests/api/session-archive.test.js --verbose --forceExit` + +Expected: All tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add backend/routes/sessions.js tests/api/session-archive.test.js +git commit -m "feat: add POST archive and unarchive endpoints for sessions" +``` + +--- + +### Task 4: Backend — Bulk Endpoint + +**Files:** +- Modify: `backend/routes/sessions.js` (add `POST /bulk` route — MUST be before any `/:id` POST routes) +- Test: `tests/api/session-archive.test.js` (append) + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/api/session-archive.test.js`: + +```js +describe('POST /api/sessions/bulk', () => { + beforeEach(() => { + cleanDb(); + }); + + test('bulk archive multiple sessions', async () => { + const s1 = seedSession({ is_active: 0, notes: null }); + const s2 = seedSession({ is_active: 0, notes: null }); + + const res = await request(app) + .post('/api/sessions/bulk') + .set('Authorization', getAuthHeader()) + .send({ action: 'archive', ids: [s1.id, s2.id] }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.affected).toBe(2); + + const list = await request(app).get('/api/sessions?filter=archived'); + expect(list.body).toHaveLength(2); + }); + + test('bulk unarchive multiple sessions', async () => { + const s1 = seedSession({ is_active: 0, notes: null }); + const s2 = seedSession({ is_active: 0, notes: null }); + const db = require('../helpers/test-utils').db; + db.prepare('UPDATE sessions SET archived = 1 WHERE id IN (?, ?)').run(s1.id, s2.id); + + const res = await request(app) + .post('/api/sessions/bulk') + .set('Authorization', getAuthHeader()) + .send({ action: 'unarchive', ids: [s1.id, s2.id] }); + + expect(res.status).toBe(200); + expect(res.body.affected).toBe(2); + + const list = await request(app).get('/api/sessions?filter=all'); + expect(list.body.every(s => s.archived === 0)).toBe(true); + }); + + test('bulk delete multiple sessions', async () => { + const s1 = seedSession({ is_active: 0, notes: null }); + const s2 = seedSession({ is_active: 0, notes: null }); + + const res = await request(app) + .post('/api/sessions/bulk') + .set('Authorization', getAuthHeader()) + .send({ action: 'delete', ids: [s1.id, s2.id] }); + + expect(res.status).toBe(200); + expect(res.body.affected).toBe(2); + + const list = await request(app).get('/api/sessions?filter=all'); + expect(list.body).toHaveLength(0); + }); + + test('rejects archive of active sessions', async () => { + const active = seedSession({ is_active: 1, notes: null }); + const closed = seedSession({ is_active: 0, notes: null }); + + const res = await request(app) + .post('/api/sessions/bulk') + .set('Authorization', getAuthHeader()) + .send({ action: 'archive', ids: [active.id, closed.id] }); + + expect(res.status).toBe(400); + expect(res.body.activeIds).toContain(active.id); + + const list = await request(app).get('/api/sessions?filter=all'); + expect(list.body).toHaveLength(2); + expect(list.body.every(s => s.archived === 0)).toBe(true); + }); + + test('rejects delete of active sessions', async () => { + const active = seedSession({ is_active: 1, notes: null }); + + const res = await request(app) + .post('/api/sessions/bulk') + .set('Authorization', getAuthHeader()) + .send({ action: 'delete', ids: [active.id] }); + + expect(res.status).toBe(400); + }); + + test('returns 400 for empty ids array', async () => { + const res = await request(app) + .post('/api/sessions/bulk') + .set('Authorization', getAuthHeader()) + .send({ action: 'archive', ids: [] }); + + expect(res.status).toBe(400); + }); + + test('returns 400 for invalid action', async () => { + const res = await request(app) + .post('/api/sessions/bulk') + .set('Authorization', getAuthHeader()) + .send({ action: 'nuke', ids: [1] }); + + expect(res.status).toBe(400); + }); + + test('returns 400 for non-array ids', async () => { + const res = await request(app) + .post('/api/sessions/bulk') + .set('Authorization', getAuthHeader()) + .send({ action: 'archive', ids: 'not-array' }); + + expect(res.status).toBe(400); + }); + + test('returns 404 if any session ID does not exist', async () => { + const s1 = seedSession({ is_active: 0, notes: null }); + + const res = await request(app) + .post('/api/sessions/bulk') + .set('Authorization', getAuthHeader()) + .send({ action: 'archive', ids: [s1.id, 9999] }); + + expect(res.status).toBe(404); + }); + + test('returns 401 without auth', async () => { + const res = await request(app) + .post('/api/sessions/bulk') + .send({ action: 'archive', ids: [1] }); + + expect(res.status).toBe(401); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx jest tests/api/session-archive.test.js --verbose --forceExit` + +Expected: Bulk tests fail (route doesn't exist — likely gets matched as `POST /:id` with `id=bulk`) + +- [ ] **Step 3: Implement the bulk route** + +This route MUST be registered BEFORE any `POST /:id/...` routes. Insert it after the `POST /` route (create session, line ~152) and before `POST /:id/close` (line ~155): + +```js +// Bulk session operations (admin only) +router.post('/bulk', authenticateToken, (req, res) => { + try { + const { action, ids } = req.body; + + if (!Array.isArray(ids) || ids.length === 0) { + return res.status(400).json({ error: 'ids must be a non-empty array' }); + } + + const validActions = ['archive', 'unarchive', 'delete']; + if (!validActions.includes(action)) { + return res.status(400).json({ error: `action must be one of: ${validActions.join(', ')}` }); + } + + const placeholders = ids.map(() => '?').join(','); + const sessions = db.prepare( + `SELECT id, is_active FROM sessions WHERE id IN (${placeholders})` + ).all(...ids); + + if (sessions.length !== ids.length) { + const foundIds = sessions.map(s => s.id); + const missingIds = ids.filter(id => !foundIds.includes(id)); + return res.status(404).json({ error: 'Some sessions not found', missingIds }); + } + + if (action === 'archive' || action === 'delete') { + const activeIds = sessions.filter(s => s.is_active === 1).map(s => s.id); + if (activeIds.length > 0) { + return res.status(400).json({ + error: `Cannot ${action} active sessions. Close them first.`, + activeIds + }); + } + } + + const bulkOperation = db.transaction(() => { + if (action === 'archive') { + db.prepare(`UPDATE sessions SET archived = 1 WHERE id IN (${placeholders})`).run(...ids); + } else if (action === 'unarchive') { + db.prepare(`UPDATE sessions SET archived = 0 WHERE id IN (${placeholders})`).run(...ids); + } else if (action === 'delete') { + db.prepare(`DELETE FROM chat_logs WHERE session_id IN (${placeholders})`).run(...ids); + db.prepare(`DELETE FROM live_votes WHERE session_id IN (${placeholders})`).run(...ids); + db.prepare(`DELETE FROM session_games WHERE session_id IN (${placeholders})`).run(...ids); + db.prepare(`DELETE FROM sessions WHERE id IN (${placeholders})`).run(...ids); + } + }); + + bulkOperation(); + + res.json({ success: true, affected: ids.length }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx jest tests/api/session-archive.test.js --verbose --forceExit` + +Expected: All tests PASS + +- [ ] **Step 5: Run full test suite** + +Run: `npx jest --verbose --forceExit` + +Expected: All tests pass + +- [ ] **Step 6: Commit** + +```bash +git add backend/routes/sessions.js tests/api/session-archive.test.js +git commit -m "feat: add POST /sessions/bulk endpoint for bulk archive, unarchive, and delete" +``` + +--- + +### Task 5: Frontend — Add `isSunday` Helper + +**Files:** +- Modify: `frontend/src/utils/dateUtils.js` + +- [ ] **Step 1: Add isSunday function** + +Append before the final empty line of `frontend/src/utils/dateUtils.js`: + +```js +/** + * Check if a SQLite timestamp falls on a Sunday (in local timezone) + * @param {string} sqliteTimestamp + * @returns {boolean} + */ +export function isSunday(sqliteTimestamp) { + return parseUTCTimestamp(sqliteTimestamp).getDay() === 0; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/src/utils/dateUtils.js +git commit -m "feat: add isSunday helper to dateUtils" +``` + +--- + +### Task 6: Frontend — History Page Rewrite (Controls Bar + Pagination + Filters + Cards) + +**Files:** +- Rewrite: `frontend/src/pages/History.jsx` + +This is the largest frontend task. The History page gets a full rewrite with: +- Controls bar (filter dropdown, show dropdown, session count, select button) +- localStorage persistence for filter and limit +- Session cards with Sunday badge, Archived badge, notes preview +- Updated `loadSessions` to pass filter and limit query params + read X-Total-Count + +- [ ] **Step 1: Rewrite History.jsx** + +Replace the entire contents of `frontend/src/pages/History.jsx` with the new implementation. The key changes: + +**State management:** +```js +const [sessions, setSessions] = useState([]); +const [loading, setLoading] = useState(true); +const [closingSession, setClosingSession] = useState(null); +const [totalCount, setTotalCount] = useState(0); +const [filter, setFilter] = useState(() => localStorage.getItem('history-filter') || 'default'); +const [limit, setLimit] = useState(() => localStorage.getItem('history-show-limit') || '5'); +``` + +**loadSessions with query params:** +```js +const loadSessions = useCallback(async () => { + try { + const response = await api.get('/sessions', { + params: { filter, limit } + }); + setSessions(response.data); + setTotalCount(parseInt(response.headers['x-total-count'] || '0', 10)); + } catch (err) { + console.error('Failed to load sessions', err); + } finally { + setLoading(false); + } +}, [filter, limit]); +``` + +**localStorage persistence:** +```js +const handleFilterChange = (newFilter) => { + setFilter(newFilter); + localStorage.setItem('history-filter', newFilter); +}; + +const handleLimitChange = (newLimit) => { + setLimit(newLimit); + localStorage.setItem('history-show-limit', newLimit); +}; +``` + +**Controls bar JSX:** +- Filter dropdown: `` with 5/10/25/50/All options +- Session count from `totalCount` state +- Select button (admin only, placeholder for Task 7) + +**Session card badges:** +- Import `isSunday` from dateUtils +- Sunday badge: amber "☀ GAME NIGHT" `` when `isSunday(session.created_at)` +- "· Sunday" text appended to date line +- Archived badge: gray "Archived" `` when `session.archived === 1` and filter is `all` or `archived` +- Active badge: green "Active" `` (existing) + +**EndSessionModal:** Keep the existing `EndSessionModal` component at the bottom of the file unchanged. + +- [ ] **Step 2: Verify the build compiles** + +Run: `cd frontend && npm run build` + +Expected: Build succeeds with no errors. + +- [ ] **Step 3: Commit** + +```bash +git add frontend/src/pages/History.jsx +git commit -m "feat: rewrite History page with controls bar, filter, pagination, and session badges" +``` + +--- + +### Task 7: Frontend — Multi-Select Mode + +**Files:** +- Modify: `frontend/src/pages/History.jsx` (add multi-select state, checkboxes, action bar, long-press handler) + +This task adds to the History page from Task 6: + +- [ ] **Step 1: Add multi-select state and handlers** + +Add new state variables: +```js +const [selectMode, setSelectMode] = useState(false); +const [selectedIds, setSelectedIds] = useState(new Set()); +const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false); +``` + +Add long-press refs and handler: +```js +const longPressTimer = useRef(null); + +const handlePointerDown = (sessionId) => { + if (!isAuthenticated || selectMode) return; + longPressTimer.current = setTimeout(() => { + setSelectMode(true); + setSelectedIds(new Set([sessionId])); + }, 500); +}; + +const handlePointerUp = () => { + if (longPressTimer.current) { + clearTimeout(longPressTimer.current); + longPressTimer.current = null; + } +}; +``` + +Add toggle selection handler: +```js +const toggleSelection = (sessionId) => { + setSelectedIds(prev => { + const next = new Set(prev); + if (next.has(sessionId)) { + next.delete(sessionId); + } else { + next.add(sessionId); + } + return next; + }); +}; +``` + +Add bulk action handlers: +```js +const handleBulkAction = async (action) => { + try { + await api.post('/sessions/bulk', { + action, + ids: Array.from(selectedIds) + }); + success(`${selectedIds.size} session${selectedIds.size !== 1 ? 's' : ''} ${action}d`); + setSelectedIds(new Set()); + setShowBulkDeleteConfirm(false); + await loadSessions(); + } catch (err) { + error(err.response?.data?.error || `Failed to ${action} sessions`); + } +}; +``` + +Clear selections when filter or limit changes: +```js +const handleFilterChange = (newFilter) => { + setFilter(newFilter); + localStorage.setItem('history-filter', newFilter); + setSelectedIds(new Set()); +}; + +const handleLimitChange = (newLimit) => { + setLimit(newLimit); + localStorage.setItem('history-show-limit', newLimit); + setSelectedIds(new Set()); +}; +``` + +Exit multi-select handler: +```js +const exitSelectMode = () => { + setSelectMode(false); + setSelectedIds(new Set()); +}; +``` + +- [ ] **Step 2: Update the controls bar with Select/Done button** + +In the controls bar, add the Select/Done button (admin only): + +```jsx +{isAuthenticated && ( + +)} +``` + +- [ ] **Step 3: Update session cards with checkboxes and selection state** + +Wrap each session card's `onClick` to be conditional: + +```jsx +onClick={() => { + if (selectMode) { + if (session.is_active !== 1) { + toggleSelection(session.id); + } + } else { + navigate(`/history/${session.id}`); + } +}} +``` + +Add long-press handlers on the card `
`: +```jsx +onPointerDown={() => { + if (session.is_active !== 1) handlePointerDown(session.id); +}} +onPointerUp={handlePointerUp} +onPointerLeave={handlePointerUp} +``` + +When `selectMode` is true, show a checkbox at the start of each card: +```jsx +{selectMode && ( +
+ {selectedIds.has(session.id) && ( + + )} +
+)} +``` + +Active sessions get `opacity-50` and `cursor-not-allowed` when in selectMode. + +Hide the "End Session" button when in selectMode. + +Selected cards get the indigo highlight: `border-indigo-500 bg-indigo-50 dark:bg-indigo-900/20`. + +- [ ] **Step 4: Add the floating action bar** + +Below the session list, when `selectMode && selectedIds.size > 0`: + +```jsx +{selectMode && selectedIds.size > 0 && ( +
+ + {selectedIds.size} selected + +
+ {filter !== 'archived' && ( + + )} + {filter !== 'default' && ( + + )} + +
+
+)} +``` + +- [ ] **Step 5: Add the bulk delete confirmation modal** + +```jsx +{showBulkDeleteConfirm && ( +
+
+

+ Delete {selectedIds.size} Session{selectedIds.size !== 1 ? 's' : ''}? +

+

+ This will permanently delete {selectedIds.size} session{selectedIds.size !== 1 ? 's' : ''} and all associated games and chat logs. This action cannot be undone. +

+
+ + +
+
+
+)} +``` + +- [ ] **Step 6: Verify build** + +Run: `cd frontend && npm run build` + +Expected: Build succeeds + +- [ ] **Step 7: Commit** + +```bash +git add frontend/src/pages/History.jsx +git commit -m "feat: add multi-select mode with bulk archive, unarchive, and delete" +``` + +--- + +### Task 8: Frontend — Session Detail Page Updates + +**Files:** +- Modify: `frontend/src/pages/SessionDetail.jsx` +- Modify: `frontend/src/utils/dateUtils.js` (already done in Task 5) + +- [ ] **Step 1: Add archive handler and state** + +In `SessionDetail`, add the archive/unarchive handler: + +```js +const handleArchive = async () => { + const action = session.archived === 1 ? 'unarchive' : 'archive'; + try { + await api.post(`/sessions/${id}/${action}`); + await loadSession(); + success(`Session ${action}d`); + } catch (err) { + showError(err.response?.data?.error || `Failed to ${action} session`); + } +}; +``` + +- [ ] **Step 2: Add Sunday badge to the session header** + +Import `isSunday` from dateUtils at the top of the file: + +```js +import { formatLocalDateTime, formatLocalTime, isSunday } from '../utils/dateUtils'; +``` + +In the session header, after the Active badge, add: + +```jsx +{isSunday(session.created_at) && ( + + ☀ Game Night + +)} +``` + +In the date/time line, append " · Sunday" when applicable: + +```jsx +

+ {formatLocalDateTime(session.created_at)} + {isSunday(session.created_at) && ( + · Sunday + )} + {' • '} + {session.games_played} game{session.games_played !== 1 ? 's' : ''} played +

+``` + +- [ ] **Step 3: Add archived banner** + +After the back link and before the main session header card, add: + +```jsx +{session.archived === 1 && ( +
+ + This session is archived + + {isAuthenticated && ( + + )} +
+)} +``` + +- [ ] **Step 4: Add archive/unarchive button alongside delete** + +In the action buttons area (where the Delete Session button is), add the Archive/Unarchive button for closed sessions: + +```jsx +{isAuthenticated && session.is_active === 0 && ( + <> + + + +)} +``` + +This replaces the existing Delete Session button block (lines 211-218). + +- [ ] **Step 5: Verify build** + +Run: `cd frontend && npm run build` + +Expected: Build succeeds + +- [ ] **Step 6: Commit** + +```bash +git add frontend/src/pages/SessionDetail.jsx +git commit -m "feat: add archive button, archived banner, and Sunday badge to Session Detail page" +``` + +--- + +### Task 9: Final Verification + +- [ ] **Step 1: Run full backend test suite** + +Run: `cd /Users/erikfredericks/dev-ai/HSO/jackboxpartypack-gamepicker && npx jest --verbose --forceExit` + +Expected: All tests pass + +- [ ] **Step 2: Build frontend** + +Run: `cd frontend && npm run build` + +Expected: Build succeeds + +- [ ] **Step 3: Manual smoke test checklist** + +Spin up the app and verify: +- [ ] History page loads with controls bar (Filter + Show dropdowns) +- [ ] Default filter shows non-archived sessions +- [ ] Changing Show dropdown to 10/25/50/All works and persists on reload +- [ ] Changing Filter dropdown to Archived/All works and persists on reload +- [ ] Sunday sessions show "GAME NIGHT" badge and "· Sunday" text +- [ ] Session Detail page shows Sunday badge and archive button (for closed sessions, as admin) +- [ ] Archiving from detail page works, shows archived banner +- [ ] Select button enters multi-select mode (admin only) +- [ ] Long-press on a closed session enters multi-select +- [ ] Active sessions are greyed out and non-selectable +- [ ] Bulk archive works from action bar +- [ ] Bulk delete shows confirmation modal and works +- [ ] Archived filter shows archived sessions with Unarchive + Delete buttons +- [ ] Changing filter/limit while in multi-select clears selections