1141 lines
34 KiB
Markdown
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
|