diff --git a/docs/superpowers/plans/2026-03-22-session-notes-read-edit-delete.md b/docs/superpowers/plans/2026-03-22-session-notes-read-edit-delete.md new file mode 100644 index 0000000..577be1a --- /dev/null +++ b/docs/superpowers/plans/2026-03-22-session-notes-read-edit-delete.md @@ -0,0 +1,1652 @@ +# Session Notes Read/Edit/Delete 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 the ability to view, edit, and delete session notes — with notes previews on the History page and a new Session Detail page with full markdown rendering and inline editing. + +**Architecture:** Backend adds two new endpoints (PUT/DELETE notes) and modifies two existing ones (list sessions omits full notes, single session gates notes on auth). A new optional-auth middleware enables the conditional response. Frontend adds a new SessionDetail page at `/history/:id` with `react-markdown` for rendering, and simplifies the History page to a session list with navigation. + +**Tech Stack:** Node.js/Express, better-sqlite3, JWT auth, React 18, React Router, Tailwind CSS, react-markdown + +**Spec:** `docs/superpowers/specs/2026-03-22-session-notes-read-edit-delete-design.md` + +--- + +### Task 1: Notes Preview Helper + +Create a utility function to compute `has_notes` and `notes_preview` from a raw notes string. + +**Files:** +- Create: `backend/utils/notes-preview.js` +- Create: `tests/api/session-notes.test.js` + +- [ ] **Step 1: Write failing tests for notesPreview helper** + +In `tests/api/session-notes.test.js`: + +```js +const { computeNotesPreview } = require('../../backend/utils/notes-preview'); + +describe('computeNotesPreview', () => { + test('returns has_notes false and null preview for null input', () => { + const result = computeNotesPreview(null); + expect(result).toEqual({ has_notes: false, notes_preview: null }); + }); + + test('returns has_notes false and null preview for empty string', () => { + const result = computeNotesPreview(''); + expect(result).toEqual({ has_notes: false, notes_preview: null }); + }); + + test('returns first paragraph as preview', () => { + const notes = 'First paragraph here.\n\nSecond paragraph here.'; + const result = computeNotesPreview(notes); + expect(result.has_notes).toBe(true); + expect(result.notes_preview).toBe('First paragraph here.'); + }); + + test('strips markdown bold formatting', () => { + const notes = '**Bold text** and more'; + const result = computeNotesPreview(notes); + expect(result.notes_preview).toBe('Bold text and more'); + }); + + test('strips markdown italic formatting', () => { + const notes = '*Italic text* and _also italic_'; + const result = computeNotesPreview(notes); + expect(result.notes_preview).toBe('Italic text and also italic'); + }); + + test('strips markdown links', () => { + const notes = 'Check [this link](http://example.com) out'; + const result = computeNotesPreview(notes); + expect(result.notes_preview).toBe('Check this link out'); + }); + + test('strips markdown headers', () => { + const notes = '## Header text'; + const result = computeNotesPreview(notes); + expect(result.notes_preview).toBe('Header text'); + }); + + test('strips markdown list markers', () => { + const notes = '- Item one\n- Item two'; + const result = computeNotesPreview(notes); + expect(result.notes_preview).toBe('Item one Item two'); + }); + + test('truncates to 150 characters with ellipsis', () => { + const notes = 'A'.repeat(200); + const result = computeNotesPreview(notes); + expect(result.notes_preview).toHaveLength(153); // 150 + '...' + expect(result.notes_preview.endsWith('...')).toBe(true); + }); + + test('does not truncate text at or under 150 characters', () => { + const notes = 'A'.repeat(150); + const result = computeNotesPreview(notes); + expect(result.notes_preview).toHaveLength(150); + expect(result.notes_preview).not.toContain('...'); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx jest tests/api/session-notes.test.js --verbose` +Expected: FAIL — `Cannot find module '../../backend/utils/notes-preview'` + +- [ ] **Step 3: Implement computeNotesPreview** + +Create `backend/utils/notes-preview.js`: + +```js +function computeNotesPreview(notes) { + if (!notes || notes.trim() === '') { + return { has_notes: false, notes_preview: null }; + } + + const firstParagraph = notes.split(/\n\n/)[0]; + + const stripped = firstParagraph + .replace(/^#{1,6}\s+/gm, '') // headers + .replace(/\*\*(.+?)\*\*/g, '$1') // bold + .replace(/\*(.+?)\*/g, '$1') // italic with * + .replace(/_(.+?)_/g, '$1') // italic with _ + .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // links + .replace(/^[-*+]\s+/gm, '') // list markers + .replace(/\n/g, ' ') // collapse remaining newlines + .replace(/\s+/g, ' ') // collapse whitespace + .trim(); + + const truncated = stripped.length > 150 + ? stripped.slice(0, 150) + '...' + : stripped; + + return { has_notes: true, notes_preview: truncated }; +} + +module.exports = { computeNotesPreview }; +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx jest tests/api/session-notes.test.js --verbose` +Expected: All tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add backend/utils/notes-preview.js tests/api/session-notes.test.js +git commit -m "feat: add notes preview helper with tests" +``` + +--- + +### Task 2: Optional Auth Middleware + +Create a middleware that checks for auth but doesn't reject the request if missing/invalid — just sets `req.user` to null. + +**Files:** +- Create: `backend/middleware/optional-auth.js` +- Test: inline in `tests/api/session-notes.test.js` + +- [ ] **Step 1: Write failing tests for optional auth behavior** + +Add to `tests/api/session-notes.test.js` (new describe block at the bottom — these tests depend on the API endpoint changes in Task 4, so they will be written now but run after Task 4): + +```js +const request = require('supertest'); +const { app } = require('../../backend/server'); +const { cleanDb, getAuthHeader, seedSession } = require('../helpers/test-utils'); + +describe('GET /api/sessions/:id notes visibility', () => { + beforeEach(() => { + cleanDb(); + }); + + test('returns full notes when authenticated', async () => { + const session = seedSession({ notes: '**Full notes** here\n\nSecond paragraph' }); + + const res = await request(app) + .get(`/api/sessions/${session.id}`) + .set('Authorization', getAuthHeader()); + + expect(res.status).toBe(200); + expect(res.body.notes).toBe('**Full notes** here\n\nSecond paragraph'); + expect(res.body.has_notes).toBe(true); + expect(res.body.notes_preview).toBe('Full notes here'); + }); + + test('returns only preview when unauthenticated', async () => { + const session = seedSession({ notes: '**Full notes** here\n\nSecond paragraph' }); + + const res = await request(app) + .get(`/api/sessions/${session.id}`); + + expect(res.status).toBe(200); + expect(res.body.notes).toBeUndefined(); + expect(res.body.has_notes).toBe(true); + expect(res.body.notes_preview).toBe('Full notes here'); + }); + + test('returns has_notes false when no notes', async () => { + const session = seedSession({ notes: null }); + + const res = await request(app) + .get(`/api/sessions/${session.id}`); + + expect(res.status).toBe(200); + expect(res.body.has_notes).toBe(false); + expect(res.body.notes_preview).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Implement optionalAuthenticateToken middleware** + +Create `backend/middleware/optional-auth.js`: + +```js +const jwt = require('jsonwebtoken'); +const { JWT_SECRET } = require('./auth'); + +function optionalAuthenticateToken(req, res, next) { + const authHeader = req.headers['authorization']; + const token = authHeader && authHeader.split(' ')[1]; + + if (!token) { + req.user = null; + return next(); + } + + jwt.verify(token, JWT_SECRET, (err, user) => { + req.user = err ? null : user; + next(); + }); +} + +module.exports = { optionalAuthenticateToken }; +``` + +- [ ] **Step 3: Commit** + +```bash +git add backend/middleware/optional-auth.js +git commit -m "feat: add optional auth middleware" +``` + +--- + +### Task 3: Modify GET /api/sessions (list endpoint) + +Replace `s.*` with explicit columns, add `has_notes` and `notes_preview`, omit full `notes`. + +**Files:** +- Modify: `backend/routes/sessions.js:20-36` (GET `/` handler) +- Test: `tests/api/session-notes.test.js` + +- [ ] **Step 1: Write failing tests for list endpoint changes** + +Add to `tests/api/session-notes.test.js`: + +```js +describe('GET /api/sessions list', () => { + beforeEach(() => { + cleanDb(); + }); + + test('includes has_notes and notes_preview in list response', async () => { + seedSession({ notes: '**Bold** first paragraph\n\nSecond paragraph' }); + seedSession({ notes: null }); + + const res = await request(app).get('/api/sessions'); + + expect(res.status).toBe(200); + expect(res.body).toHaveLength(2); + + const withNotes = res.body.find(s => s.has_notes === true); + const withoutNotes = res.body.find(s => s.has_notes === false); + + expect(withNotes.notes_preview).toBe('Bold first paragraph'); + expect(withNotes).not.toHaveProperty('notes'); + + expect(withoutNotes.notes_preview).toBeNull(); + expect(withoutNotes).not.toHaveProperty('notes'); + }); + + test('list response preserves existing fields', async () => { + seedSession({ is_active: 1, notes: 'Test' }); + + const res = await request(app).get('/api/sessions'); + + expect(res.status).toBe(200); + expect(res.body[0]).toHaveProperty('id'); + expect(res.body[0]).toHaveProperty('created_at'); + expect(res.body[0]).toHaveProperty('closed_at'); + expect(res.body[0]).toHaveProperty('is_active'); + expect(res.body[0]).toHaveProperty('games_played'); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx jest tests/api/session-notes.test.js --verbose --testNamePattern="list"` +Expected: FAIL — `notes` still present in response, `has_notes` and `notes_preview` missing + +- [ ] **Step 3: Modify the GET / handler in sessions.js** + +In `backend/routes/sessions.js`, replace the `GET /` handler (lines 20-36): + +```js +const { computeNotesPreview } = require('../utils/notes-preview'); +``` + +Add this import at the top of the file (after existing imports). + +Replace the handler body: + +```js +router.get('/', (req, res) => { + try { + const sessions = db.prepare(` + SELECT + s.id, + s.created_at, + s.closed_at, + s.is_active, + s.notes, + COUNT(sg.id) as games_played + FROM sessions s + LEFT JOIN session_games sg ON s.id = sg.session_id + GROUP BY s.id + ORDER BY s.created_at DESC + `).all(); + + const result = sessions.map(({ notes, ...session }) => { + const { has_notes, notes_preview } = computeNotesPreview(notes); + return { ...session, has_notes, notes_preview }; + }); + + res.json(result); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); +``` + +Note: We still SELECT `notes` from the DB to compute the preview, but destructure it out of the response object so it's never sent to the client. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx jest tests/api/session-notes.test.js --verbose --testNamePattern="list"` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add backend/routes/sessions.js tests/api/session-notes.test.js +git commit -m "feat: add has_notes and notes_preview to session list, omit full notes" +``` + +--- + +### Task 4: Modify GET /api/sessions/:id (single session) + +Gate full notes behind auth; always return `has_notes` and `notes_preview`. + +**Files:** +- Modify: `backend/routes/sessions.js:64-84` (GET `/:id` handler) +- Modify: `tests/api/regression-sessions.test.js` (update existing test expectations) + +- [ ] **Step 1: Run the existing regression tests to confirm they pass before changes** + +Run: `npx jest tests/api/regression-sessions.test.js --verbose` +Expected: PASS + +- [ ] **Step 2: Modify the GET /:id handler** + +In `backend/routes/sessions.js`, add the import at the top (if not already added in Task 3): + +```js +const { optionalAuthenticateToken } = require('../middleware/optional-auth'); +``` + +Replace the `GET /:id` handler (lines 64-84): + +```js +router.get('/:id', optionalAuthenticateToken, (req, res) => { + try { + const session = db.prepare(` + SELECT + s.*, + COUNT(sg.id) as games_played + FROM sessions s + LEFT JOIN session_games sg ON s.id = sg.session_id + WHERE s.id = ? + GROUP BY s.id + `).get(req.params.id); + + if (!session) { + return res.status(404).json({ error: 'Session not found' }); + } + + const { has_notes, notes_preview } = computeNotesPreview(session.notes); + + if (req.user) { + res.json({ ...session, has_notes, notes_preview }); + } else { + const { notes, ...publicSession } = session; + res.json({ ...publicSession, has_notes, notes_preview }); + } + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); +``` + +- [ ] **Step 3: Update regression test expectations** + +The existing test in `tests/api/regression-sessions.test.js` at line 10-24 expects `notes` in the response without auth. Update it: + +```js + test('GET /api/sessions/:id returns session object with preview for unauthenticated', async () => { + const session = seedSession({ is_active: 1, notes: 'Test session' }); + + const res = await request(app).get(`/api/sessions/${session.id}`); + + expect(res.status).toBe(200); + expect(res.body).toEqual( + expect.objectContaining({ + id: session.id, + is_active: 1, + has_notes: true, + notes_preview: 'Test session', + }) + ); + expect(res.body.notes).toBeUndefined(); + expect(res.body).toHaveProperty('games_played'); + }); +``` + +- [ ] **Step 4: Run all session-related tests** + +Run: `npx jest tests/api/session-notes.test.js tests/api/regression-sessions.test.js --verbose` +Expected: All PASS (including the notes visibility tests written in Task 2 Step 1) + +- [ ] **Step 5: Commit** + +```bash +git add backend/routes/sessions.js tests/api/regression-sessions.test.js tests/api/session-notes.test.js +git commit -m "feat: gate full notes behind auth on single session endpoint" +``` + +--- + +### Task 5: Add PUT /api/sessions/:id/notes + +New endpoint to update notes. + +**Files:** +- Modify: `backend/routes/sessions.js` +- Test: `tests/api/session-notes.test.js` + +- [ ] **Step 1: Write failing tests** + +Add to `tests/api/session-notes.test.js`: + +```js +describe('PUT /api/sessions/:id/notes', () => { + beforeEach(() => { + cleanDb(); + }); + + test('updates notes when authenticated', async () => { + const session = seedSession({ notes: 'Old notes' }); + + const res = await request(app) + .put(`/api/sessions/${session.id}/notes`) + .set('Authorization', getAuthHeader()) + .send({ notes: 'New notes here' }); + + expect(res.status).toBe(200); + expect(res.body.notes).toBe('New notes here'); + }); + + test('overwrites notes completely (no merge)', async () => { + const session = seedSession({ notes: 'Original notes' }); + + const res = await request(app) + .put(`/api/sessions/${session.id}/notes`) + .set('Authorization', getAuthHeader()) + .send({ notes: 'Replacement' }); + + expect(res.status).toBe(200); + expect(res.body.notes).toBe('Replacement'); + }); + + test('returns 404 for nonexistent session', async () => { + const res = await request(app) + .put('/api/sessions/99999/notes') + .set('Authorization', getAuthHeader()) + .send({ notes: 'test' }); + + expect(res.status).toBe(404); + }); + + test('returns 401 without auth header', async () => { + const session = seedSession({}); + + const res = await request(app) + .put(`/api/sessions/${session.id}/notes`) + .send({ notes: 'test' }); + + expect(res.status).toBe(401); + }); + + test('returns 403 with invalid token', async () => { + const session = seedSession({}); + + const res = await request(app) + .put(`/api/sessions/${session.id}/notes`) + .set('Authorization', 'Bearer invalid-token') + .send({ notes: 'test' }); + + expect(res.status).toBe(403); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx jest tests/api/session-notes.test.js --verbose --testNamePattern="PUT"` +Expected: FAIL — 404 (route not found) + +- [ ] **Step 3: Implement PUT /:id/notes** + +Add to `backend/routes/sessions.js` (after the `DELETE /:id` handler, before `GET /:id/games`): + +```js +router.put('/:id/notes', authenticateToken, (req, res) => { + try { + const { notes } = req.body; + + 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 notes = ? WHERE id = ?').run(notes, req.params.id); + + const updated = db.prepare(` + SELECT s.*, COUNT(sg.id) as games_played + FROM sessions s + LEFT JOIN session_games sg ON s.id = sg.session_id + WHERE s.id = ? + GROUP BY s.id + `).get(req.params.id); + + res.json(updated); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); +``` + +**Important route ordering note:** This route must be placed _before_ the `GET /:id/games`, `GET /:id/votes`, and other `/:id/*` routes to avoid conflicts. Since Express matches routes in registration order, `PUT /:id/notes` won't conflict with `GET /:id/games` because they use different HTTP methods. Place it right after `DELETE /:id` (line ~228). + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx jest tests/api/session-notes.test.js --verbose --testNamePattern="PUT"` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add backend/routes/sessions.js tests/api/session-notes.test.js +git commit -m "feat: add PUT /api/sessions/:id/notes endpoint" +``` + +--- + +### Task 6: Add DELETE /api/sessions/:id/notes + +New endpoint to clear notes. + +**Files:** +- Modify: `backend/routes/sessions.js` +- Test: `tests/api/session-notes.test.js` + +- [ ] **Step 1: Write failing tests** + +Add to `tests/api/session-notes.test.js`: + +```js +describe('DELETE /api/sessions/:id/notes', () => { + beforeEach(() => { + cleanDb(); + }); + + test('clears notes when authenticated', async () => { + const session = seedSession({ notes: 'Some notes' }); + + const res = await request(app) + .delete(`/api/sessions/${session.id}/notes`) + .set('Authorization', getAuthHeader()); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + + // Verify notes are actually cleared + const check = await request(app) + .get(`/api/sessions/${session.id}`) + .set('Authorization', getAuthHeader()); + expect(check.body.notes).toBeNull(); + expect(check.body.has_notes).toBe(false); + }); + + test('returns 404 for nonexistent session', async () => { + const res = await request(app) + .delete('/api/sessions/99999/notes') + .set('Authorization', getAuthHeader()); + + expect(res.status).toBe(404); + }); + + test('returns 401 without auth header', async () => { + const session = seedSession({ notes: 'test' }); + + const res = await request(app) + .delete(`/api/sessions/${session.id}/notes`); + + expect(res.status).toBe(401); + }); + + test('returns 403 with invalid token', async () => { + const session = seedSession({ notes: 'test' }); + + const res = await request(app) + .delete(`/api/sessions/${session.id}/notes`) + .set('Authorization', 'Bearer invalid-token'); + + expect(res.status).toBe(403); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx jest tests/api/session-notes.test.js --verbose --testNamePattern="DELETE.*notes"` +Expected: FAIL + +- [ ] **Step 3: Implement DELETE /:id/notes** + +Add to `backend/routes/sessions.js` (right after the PUT `/:id/notes` handler): + +```js +router.delete('/:id/notes', 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 notes = NULL WHERE id = ?').run(req.params.id); + + res.json({ success: true }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); +``` + +Place this right after the `PUT /:id/notes` handler. No route ordering concern here — `DELETE /:id/notes` (two path segments) won't conflict with `DELETE /:id` (one segment) regardless of registration order. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx jest tests/api/session-notes.test.js --verbose --testNamePattern="DELETE.*notes"` +Expected: PASS + +- [ ] **Step 5: Run all backend tests to ensure no regressions** + +Run: `npx jest --verbose` +Expected: All PASS + +- [ ] **Step 6: Commit** + +```bash +git add backend/routes/sessions.js tests/api/session-notes.test.js +git commit -m "feat: add DELETE /api/sessions/:id/notes endpoint" +``` + +--- + +### Task 7: Add react-markdown Frontend Dependency + +**Files:** +- Modify: `frontend/package.json` + +- [ ] **Step 1: Install react-markdown and @tailwindcss/typography** + +The `@tailwindcss/typography` plugin provides `prose` classes used to style the rendered markdown output. + +Run from the `frontend/` directory: + +```bash +cd frontend && npm install react-markdown @tailwindcss/typography +``` + +- [ ] **Step 2: Add typography plugin to Tailwind config** + +In `frontend/tailwind.config.js`, change the `plugins` line: + +```js + plugins: [require('@tailwindcss/typography')], +``` + +- [ ] **Step 3: Verify installation** + +Run: `cd frontend && npx vite build 2>&1 | tail -3` +Expected: Build succeeds. + +- [ ] **Step 4: Commit** + +```bash +git add frontend/package.json frontend/package-lock.json frontend/tailwind.config.js +git commit -m "chore: add react-markdown and @tailwindcss/typography dependencies" +``` + +--- + +### Task 8: Create SessionDetail Page + +The largest frontend task. New page component with notes view/edit, games list, and session actions. + +**Files:** +- Create: `frontend/src/pages/SessionDetail.jsx` +- Modify: `frontend/src/App.jsx` + +- [ ] **Step 1: Add route in App.jsx** + +Add import at the top of `frontend/src/App.jsx` (after the History import): + +```js +import SessionDetail from './pages/SessionDetail'; +``` + +Add route inside `` (after the `/history` route): + +```jsx +} /> +``` + +- [ ] **Step 2: Create SessionDetail.jsx — shell with data loading** + +Create `frontend/src/pages/SessionDetail.jsx`: + +```jsx +import React, { useState, useEffect, useCallback } from 'react'; +import { useParams, useNavigate, Link } from 'react-router-dom'; +import Markdown from 'react-markdown'; +import { useAuth } from '../context/AuthContext'; +import { useToast } from '../components/Toast'; +import api from '../api/axios'; +import { formatLocalDateTime, formatLocalTime } from '../utils/dateUtils'; +import PopularityBadge from '../components/PopularityBadge'; + +function SessionDetail() { + const { id } = useParams(); + const navigate = useNavigate(); + const { isAuthenticated } = useAuth(); + const { error: showError, success } = useToast(); + + const [session, setSession] = useState(null); + const [games, setGames] = useState([]); + const [loading, setLoading] = useState(true); + const [editing, setEditing] = useState(false); + const [editedNotes, setEditedNotes] = useState(''); + const [saving, setSaving] = useState(false); + const [showDeleteNotesConfirm, setShowDeleteNotesConfirm] = useState(false); + const [showDeleteSessionConfirm, setShowDeleteSessionConfirm] = useState(false); + const [showChatImport, setShowChatImport] = useState(false); + const [closingSession, setClosingSession] = useState(false); + + const loadSession = useCallback(async () => { + try { + const res = await api.get(`/sessions/${id}`); + setSession(res.data); + } catch (err) { + if (err.response?.status === 404) { + navigate('/history', { replace: true }); + } + console.error('Failed to load session', err); + } + }, [id, navigate]); + + const loadGames = useCallback(async () => { + try { + const res = await api.get(`/sessions/${id}/games`); + setGames([...res.data].reverse()); + } catch (err) { + console.error('Failed to load session games', err); + } + }, [id]); + + useEffect(() => { + Promise.all([loadSession(), loadGames()]).finally(() => setLoading(false)); + }, [loadSession, loadGames]); + + useEffect(() => { + if (!session || session.is_active !== 1) return; + const interval = setInterval(() => { + loadSession(); + loadGames(); + }, 3000); + return () => clearInterval(interval); + }, [session, loadSession, loadGames]); + + const handleSaveNotes = async () => { + setSaving(true); + try { + await api.put(`/sessions/${id}/notes`, { notes: editedNotes }); + await loadSession(); + setEditing(false); + success('Notes saved'); + } catch (err) { + showError('Failed to save notes'); + } finally { + setSaving(false); + } + }; + + const handleDeleteNotes = async () => { + try { + await api.delete(`/sessions/${id}/notes`); + await loadSession(); + setEditing(false); + setShowDeleteNotesConfirm(false); + success('Notes deleted'); + } catch (err) { + showError('Failed to delete notes'); + } + }; + + const handleDeleteSession = async () => { + try { + await api.delete(`/sessions/${id}`); + success('Session deleted'); + navigate('/history', { replace: true }); + } catch (err) { + showError('Failed to delete session: ' + (err.response?.data?.error || err.message)); + } + }; + + const handleCloseSession = async (sessionId, notes) => { + try { + await api.post(`/sessions/${sessionId}/close`, { notes }); + await loadSession(); + await loadGames(); + setClosingSession(false); + success('Session ended successfully'); + } catch (err) { + showError('Failed to close session'); + } + }; + + const handleExport = async (format) => { + try { + const response = await api.get(`/sessions/${id}/export?format=${format}`, { + responseType: 'blob' + }); + const url = window.URL.createObjectURL(new Blob([response.data])); + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', `session-${id}.${format === 'json' ? 'json' : 'txt'}`); + document.body.appendChild(link); + link.click(); + link.parentNode.removeChild(link); + window.URL.revokeObjectURL(url); + success(`Session exported as ${format.toUpperCase()}`); + } catch (err) { + showError('Failed to export session'); + } + }; + + const startEditing = () => { + setEditedNotes(session.notes || ''); + setEditing(true); + }; + + if (loading) { + return ( +
+
Loading...
+
+ ); + } + + if (!session) { + return ( +
+
Session not found
+
+ ); + } + + return ( +
+ {/* Back link */} + + ← Back to History + + + {/* Session header */} +
+
+
+
+

+ Session #{session.id} +

+ {session.is_active === 1 && ( + + 🟢 Active + + )} +
+

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

+
+ + {/* Action buttons */} +
+ {isAuthenticated && session.is_active === 1 && ( + <> + + + + )} + {isAuthenticated && ( + <> + + + + )} + {isAuthenticated && session.is_active === 0 && ( + + )} +
+
+
+ + {/* Notes section */} +
+ setEditing(false)} + onDeleteNotes={handleDeleteNotes} + onShowDeleteConfirm={() => setShowDeleteNotesConfirm(true)} + onHideDeleteConfirm={() => setShowDeleteNotesConfirm(false)} + /> +
+ + {/* Chat import panel */} + {showChatImport && ( +
+ setShowChatImport(false)} + onImportComplete={() => { + loadGames(); + setShowChatImport(false); + }} + /> +
+ )} + + {/* Games list */} +
+ {games.length === 0 ? ( +

No games played in this session

+ ) : ( + <> +

+ Games Played ({games.length}) +

+
+ {games.map((game, index) => ( +
+
+
+
+ {games.length - index}. {game.title} +
+
{game.pack_name}
+
+
+
+ {formatLocalTime(game.played_at)} +
+ {game.manually_added === 1 && ( + + Manual + + )} +
+
+
+
+ Players: {game.min_players}-{game.max_players} +
+
+ Type: {game.game_type || 'N/A'} +
+
+ + Popularity: + + +
+
+
+ ))} +
+ + )} +
+ + {/* End Session Modal */} + {closingSession && ( + setClosingSession(false)} + onConfirm={handleCloseSession} + onShowChatImport={() => { + setShowChatImport(true); + setClosingSession(false); + }} + /> + )} + + {/* Delete Session Confirmation Modal */} + {showDeleteSessionConfirm && ( +
+
+

Delete Session?

+

+ Are you sure you want to delete Session #{session.id}? + This will permanently delete all games and chat logs associated with this session. This action cannot be undone. +

+
+ + +
+
+
+ )} +
+ ); +} + +function NotesSection({ + session, + isAuthenticated, + editing, + editedNotes, + saving, + showDeleteNotesConfirm, + onStartEditing, + onSetEditedNotes, + onSave, + onCancel, + onDeleteNotes, + onShowDeleteConfirm, + onHideDeleteConfirm, +}) { + if (editing) { + return ( +
+
+

Session Notes

+
+ + + +
+
+