# 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
