Files
jackboxpartypack-gamepicker/docs/superpowers/plans/2026-03-23-session-archive-multiselect.md

1141 lines
34 KiB
Markdown

# 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: `<select>` with Sessions/Archived/All options
- Show dropdown: `<select>` 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" `<span>` when `isSunday(session.created_at)`
- "· Sunday" text appended to date line
- Archived badge: gray "Archived" `<span>` when `session.archived === 1` and filter is `all` or `archived`
- Active badge: green "Active" `<span>` (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 && (
<button
onClick={selectMode ? exitSelectMode : () => setSelectMode(true)}
className={`px-3 py-1.5 rounded text-sm font-medium transition ${
selectMode
? 'bg-indigo-600 dark:bg-indigo-700 text-white hover:bg-indigo-700 dark:hover:bg-indigo-800'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
}`}
>
{selectMode ? '✓ Done' : 'Select'}
</button>
)}
```
- [ ] **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 `<div>`:
```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 && (
<div className={`w-5 h-5 flex-shrink-0 rounded border-2 flex items-center justify-center ${
session.is_active === 1
? 'border-gray-300 dark:border-gray-600 bg-gray-100 dark:bg-gray-700'
: selectedIds.has(session.id)
? 'border-indigo-600 bg-indigo-600'
: 'border-gray-300 dark:border-gray-600'
}`}>
{selectedIds.has(session.id) && (
<span className="text-white text-xs font-bold"></span>
)}
</div>
)}
```
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 && (
<div className="sticky bottom-4 mt-4 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 p-4 flex justify-between items-center">
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">
{selectedIds.size} selected
</span>
<div className="flex gap-2">
{filter !== 'archived' && (
<button
onClick={() => handleBulkAction('archive')}
className="px-4 py-2 bg-indigo-600 text-white rounded-lg text-sm hover:bg-indigo-700 transition"
>
Archive
</button>
)}
{filter !== 'default' && (
<button
onClick={() => handleBulkAction('unarchive')}
className="px-4 py-2 bg-green-600 text-white rounded-lg text-sm hover:bg-green-700 transition"
>
Unarchive
</button>
)}
<button
onClick={() => setShowBulkDeleteConfirm(true)}
className="px-4 py-2 bg-red-600 text-white rounded-lg text-sm hover:bg-red-700 transition"
>
Delete
</button>
</div>
</div>
)}
```
- [ ] **Step 5: Add the bulk delete confirmation modal**
```jsx
{showBulkDeleteConfirm && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg p-8 max-w-md w-full">
<h2 className="text-2xl font-bold mb-4 text-red-600 dark:text-red-400">
Delete {selectedIds.size} Session{selectedIds.size !== 1 ? 's' : ''}?
</h2>
<p className="text-gray-700 dark:text-gray-300 mb-6">
This will permanently delete {selectedIds.size} session{selectedIds.size !== 1 ? 's' : ''} and all associated games and chat logs. This action cannot be undone.
</p>
<div className="flex gap-4">
<button
onClick={() => handleBulkAction('delete')}
className="flex-1 bg-red-600 text-white py-3 rounded-lg hover:bg-red-700 transition font-semibold"
>
Delete Permanently
</button>
<button
onClick={() => setShowBulkDeleteConfirm(false)}
className="flex-1 bg-gray-600 text-white py-3 rounded-lg hover:bg-gray-700 transition"
>
Cancel
</button>
</div>
</div>
</div>
)}
```
- [ ] **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) && (
<span className="bg-amber-100 dark:bg-amber-900 text-amber-800 dark:text-amber-200 text-xs px-2 py-0.5 rounded font-semibold">
Game Night
</span>
)}
```
In the date/time line, append " · Sunday" when applicable:
```jsx
<p className="text-gray-600 dark:text-gray-400">
{formatLocalDateTime(session.created_at)}
{isSunday(session.created_at) && (
<span className="text-gray-400 dark:text-gray-500"> · Sunday</span>
)}
{' • '}
{session.games_played} game{session.games_played !== 1 ? 's' : ''} played
</p>
```
- [ ] **Step 3: Add archived banner**
After the back link and before the main session header card, add:
```jsx
{session.archived === 1 && (
<div className="bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg p-4 mb-4 flex justify-between items-center">
<span className="text-gray-600 dark:text-gray-400 text-sm font-medium">
This session is archived
</span>
{isAuthenticated && (
<button
onClick={handleArchive}
className="text-sm bg-green-600 text-white px-3 py-1.5 rounded hover:bg-green-700 transition"
>
Unarchive
</button>
)}
</div>
)}
```
- [ ] **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 && (
<>
<button
onClick={handleArchive}
className={`${
session.archived === 1
? 'bg-green-600 dark:bg-green-700 hover:bg-green-700 dark:hover:bg-green-800'
: 'bg-gray-500 dark:bg-gray-600 hover:bg-gray-600 dark:hover:bg-gray-700'
} text-white px-4 py-2 rounded-lg transition text-sm`}
>
{session.archived === 1 ? 'Unarchive' : 'Archive'}
</button>
<button
onClick={() => setShowDeleteSessionConfirm(true)}
className="bg-red-600 dark:bg-red-700 text-white px-4 py-2 rounded-lg hover:bg-red-700 dark:hover:bg-red-800 transition text-sm"
>
Delete Session
</button>
</>
)}
```
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