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

34 KiB

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

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

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:

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

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

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

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

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

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

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:

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:

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

const [selectMode, setSelectMode] = useState(false);
const [selectedIds, setSelectedIds] = useState(new Set());
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false);

Add long-press refs and handler:

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:

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:

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:

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:

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

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

onClick={() => {
  if (selectMode) {
    if (session.is_active !== 1) {
      toggleSelection(session.id);
    }
  } else {
    navigate(`/history/${session.id}`);
  }
}}

Add long-press handlers on the card <div>:

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:

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

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

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:

import { formatLocalDateTime, formatLocalTime, isSunday } from '../utils/dateUtils';

In the session header, after the Active badge, add:

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

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

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

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