1653 lines
55 KiB
Markdown
1653 lines
55 KiB
Markdown
# 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 `<Routes>` (after the `/history` route):
|
||
|
||
```jsx
|
||
<Route path="/history/:id" element={<SessionDetail />} />
|
||
```
|
||
|
||
- [ ] **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 (
|
||
<div className="flex justify-center items-center h-64">
|
||
<div className="text-xl text-gray-600 dark:text-gray-400">Loading...</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!session) {
|
||
return (
|
||
<div className="flex justify-center items-center h-64">
|
||
<div className="text-xl text-gray-600 dark:text-gray-400">Session not found</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="max-w-4xl mx-auto">
|
||
{/* Back link */}
|
||
<Link
|
||
to="/history"
|
||
className="inline-flex items-center text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 mb-4 transition"
|
||
>
|
||
← Back to History
|
||
</Link>
|
||
|
||
{/* Session header */}
|
||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 mb-6">
|
||
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-start gap-4">
|
||
<div>
|
||
<div className="flex items-center gap-3 mb-2">
|
||
<h1 className="text-2xl sm:text-3xl font-bold text-gray-800 dark:text-gray-100">
|
||
Session #{session.id}
|
||
</h1>
|
||
{session.is_active === 1 && (
|
||
<span className="bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 text-sm px-3 py-1 rounded-full font-semibold animate-pulse inline-flex items-center gap-1">
|
||
🟢 Active
|
||
</span>
|
||
)}
|
||
</div>
|
||
<p className="text-gray-600 dark:text-gray-400">
|
||
{formatLocalDateTime(session.created_at)}
|
||
{' • '}
|
||
{session.games_played} game{session.games_played !== 1 ? 's' : ''} played
|
||
</p>
|
||
</div>
|
||
|
||
{/* Action buttons */}
|
||
<div className="flex flex-wrap gap-2">
|
||
{isAuthenticated && session.is_active === 1 && (
|
||
<>
|
||
<button
|
||
onClick={() => setClosingSession(true)}
|
||
className="bg-orange-600 dark:bg-orange-700 text-white px-4 py-2 rounded-lg hover:bg-orange-700 dark:hover:bg-orange-800 transition text-sm"
|
||
>
|
||
End Session
|
||
</button>
|
||
<button
|
||
onClick={() => setShowChatImport(true)}
|
||
className="bg-indigo-600 dark:bg-indigo-700 text-white px-4 py-2 rounded-lg hover:bg-indigo-700 dark:hover:bg-indigo-800 transition text-sm"
|
||
>
|
||
Import Chat Log
|
||
</button>
|
||
</>
|
||
)}
|
||
{isAuthenticated && (
|
||
<>
|
||
<button
|
||
onClick={() => handleExport('txt')}
|
||
className="bg-gray-600 dark:bg-gray-700 text-white px-4 py-2 rounded-lg hover:bg-gray-700 dark:hover:bg-gray-800 transition text-sm"
|
||
>
|
||
Export TXT
|
||
</button>
|
||
<button
|
||
onClick={() => handleExport('json')}
|
||
className="bg-gray-600 dark:bg-gray-700 text-white px-4 py-2 rounded-lg hover:bg-gray-700 dark:hover:bg-gray-800 transition text-sm"
|
||
>
|
||
Export JSON
|
||
</button>
|
||
</>
|
||
)}
|
||
{isAuthenticated && session.is_active === 0 && (
|
||
<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>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Notes section */}
|
||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 mb-6">
|
||
<NotesSection
|
||
session={session}
|
||
isAuthenticated={isAuthenticated}
|
||
editing={editing}
|
||
editedNotes={editedNotes}
|
||
saving={saving}
|
||
showDeleteNotesConfirm={showDeleteNotesConfirm}
|
||
onStartEditing={startEditing}
|
||
onSetEditedNotes={setEditedNotes}
|
||
onSave={handleSaveNotes}
|
||
onCancel={() => setEditing(false)}
|
||
onDeleteNotes={handleDeleteNotes}
|
||
onShowDeleteConfirm={() => setShowDeleteNotesConfirm(true)}
|
||
onHideDeleteConfirm={() => setShowDeleteNotesConfirm(false)}
|
||
/>
|
||
</div>
|
||
|
||
{/* Chat import panel */}
|
||
{showChatImport && (
|
||
<div className="mb-6">
|
||
<ChatImportPanel
|
||
sessionId={id}
|
||
onClose={() => setShowChatImport(false)}
|
||
onImportComplete={() => {
|
||
loadGames();
|
||
setShowChatImport(false);
|
||
}}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* Games list */}
|
||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
|
||
{games.length === 0 ? (
|
||
<p className="text-gray-500 dark:text-gray-400">No games played in this session</p>
|
||
) : (
|
||
<>
|
||
<h2 className="text-xl font-semibold mb-4 text-gray-700 dark:text-gray-200">
|
||
Games Played ({games.length})
|
||
</h2>
|
||
<div className="space-y-3">
|
||
{games.map((game, index) => (
|
||
<div key={game.id} className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 bg-gray-50 dark:bg-gray-700/50">
|
||
<div className="flex justify-between items-start mb-2">
|
||
<div>
|
||
<div className="font-semibold text-lg text-gray-800 dark:text-gray-100">
|
||
{games.length - index}. {game.title}
|
||
</div>
|
||
<div className="text-gray-600 dark:text-gray-400">{game.pack_name}</div>
|
||
</div>
|
||
<div className="text-right">
|
||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||
{formatLocalTime(game.played_at)}
|
||
</div>
|
||
{game.manually_added === 1 && (
|
||
<span className="inline-block mt-1 text-xs bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 px-2 py-1 rounded">
|
||
Manual
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||
<div>
|
||
<span className="font-semibold">Players:</span> {game.min_players}-{game.max_players}
|
||
</div>
|
||
<div>
|
||
<span className="font-semibold">Type:</span> {game.game_type || 'N/A'}
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<span
|
||
className="font-semibold"
|
||
title="Popularity is cumulative across all sessions where this game was played"
|
||
>
|
||
Popularity:
|
||
</span>
|
||
<PopularityBadge
|
||
upvotes={game.upvotes || 0}
|
||
downvotes={game.downvotes || 0}
|
||
popularityScore={game.popularity_score || 0}
|
||
size="sm"
|
||
showCounts={true}
|
||
showNet={true}
|
||
showRatio={true}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* End Session Modal */}
|
||
{closingSession && (
|
||
<EndSessionModal
|
||
sessionId={parseInt(id)}
|
||
sessionGames={games}
|
||
onClose={() => setClosingSession(false)}
|
||
onConfirm={handleCloseSession}
|
||
onShowChatImport={() => {
|
||
setShowChatImport(true);
|
||
setClosingSession(false);
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{/* Delete Session Confirmation Modal */}
|
||
{showDeleteSessionConfirm && (
|
||
<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 Session?</h2>
|
||
<p className="text-gray-700 dark:text-gray-300 mb-6">
|
||
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.
|
||
</p>
|
||
<div className="flex gap-4">
|
||
<button
|
||
onClick={handleDeleteSession}
|
||
className="flex-1 bg-red-600 dark:bg-red-700 text-white py-3 rounded-lg hover:bg-red-700 dark:hover:bg-red-800 transition font-semibold"
|
||
>
|
||
Delete Permanently
|
||
</button>
|
||
<button
|
||
onClick={() => setShowDeleteSessionConfirm(false)}
|
||
className="flex-1 bg-gray-600 dark:bg-gray-700 text-white py-3 rounded-lg hover:bg-gray-700 dark:hover:bg-gray-800 transition"
|
||
>
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function NotesSection({
|
||
session,
|
||
isAuthenticated,
|
||
editing,
|
||
editedNotes,
|
||
saving,
|
||
showDeleteNotesConfirm,
|
||
onStartEditing,
|
||
onSetEditedNotes,
|
||
onSave,
|
||
onCancel,
|
||
onDeleteNotes,
|
||
onShowDeleteConfirm,
|
||
onHideDeleteConfirm,
|
||
}) {
|
||
if (editing) {
|
||
return (
|
||
<div>
|
||
<div className="flex justify-between items-center mb-3">
|
||
<h2 className="text-lg font-semibold text-gray-800 dark:text-gray-100">Session Notes</h2>
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={onShowDeleteConfirm}
|
||
className="bg-red-600 dark:bg-red-700 text-white px-3 py-1.5 rounded text-sm hover:bg-red-700 dark:hover:bg-red-800 transition"
|
||
>
|
||
Delete Notes
|
||
</button>
|
||
<button
|
||
onClick={onSave}
|
||
disabled={saving}
|
||
className="bg-green-600 dark:bg-green-700 text-white px-3 py-1.5 rounded text-sm hover:bg-green-700 dark:hover:bg-green-800 transition disabled:opacity-50"
|
||
>
|
||
{saving ? 'Saving...' : 'Save'}
|
||
</button>
|
||
<button
|
||
onClick={onCancel}
|
||
className="bg-gray-500 dark:bg-gray-600 text-white px-3 py-1.5 rounded text-sm hover:bg-gray-600 dark:hover:bg-gray-700 transition"
|
||
>
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<textarea
|
||
value={editedNotes}
|
||
onChange={(e) => onSetEditedNotes(e.target.value)}
|
||
className="w-full px-4 py-3 border border-indigo-300 dark:border-indigo-600 rounded-lg h-48 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100 font-mono text-sm leading-relaxed resize-y focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||
placeholder="Write your session notes here... Markdown is supported."
|
||
/>
|
||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Supports Markdown formatting</p>
|
||
|
||
{showDeleteNotesConfirm && (
|
||
<div className="mt-3 p-4 bg-red-50 dark:bg-red-900/30 border border-red-300 dark:border-red-700 rounded-lg">
|
||
<p className="text-red-700 dark:text-red-300 mb-3">Are you sure you want to delete these notes?</p>
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={onDeleteNotes}
|
||
className="bg-red-600 text-white px-4 py-2 rounded text-sm hover:bg-red-700 transition"
|
||
>
|
||
Yes, Delete
|
||
</button>
|
||
<button
|
||
onClick={onHideDeleteConfirm}
|
||
className="bg-gray-500 text-white px-4 py-2 rounded text-sm hover:bg-gray-600 transition"
|
||
>
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!isAuthenticated) {
|
||
return (
|
||
<div>
|
||
<h2 className="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-3">Session Notes</h2>
|
||
{session.has_notes ? (
|
||
<>
|
||
<p className="text-gray-700 dark:text-gray-300">{session.notes_preview}</p>
|
||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2 italic">Log in to view full notes</p>
|
||
</>
|
||
) : (
|
||
<p className="text-gray-500 dark:text-gray-400 italic">No notes for this session</p>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
<div className="flex justify-between items-center mb-3">
|
||
<h2 className="text-lg font-semibold text-gray-800 dark:text-gray-100">Session Notes</h2>
|
||
<button
|
||
onClick={onStartEditing}
|
||
className="bg-indigo-600 dark:bg-indigo-700 text-white px-3 py-1.5 rounded text-sm hover:bg-indigo-700 dark:hover:bg-indigo-800 transition"
|
||
>
|
||
{session.notes ? 'Edit' : 'Add Notes'}
|
||
</button>
|
||
</div>
|
||
{session.notes ? (
|
||
<div className="prose prose-sm dark:prose-invert max-w-none text-gray-700 dark:text-gray-300">
|
||
<Markdown>{session.notes}</Markdown>
|
||
</div>
|
||
) : (
|
||
<p className="text-gray-500 dark:text-gray-400 italic">No notes for this session</p>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function EndSessionModal({ sessionId, sessionGames, onClose, onConfirm, onShowChatImport }) {
|
||
const [notes, setNotes] = useState('');
|
||
|
||
const hasPopularityData = sessionGames.some(game => game.popularity_score !== 0);
|
||
const showPopularityWarning = sessionGames.length > 0 && !hasPopularityData;
|
||
|
||
return (
|
||
<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 dark:text-gray-100">End Session #{sessionId}</h2>
|
||
|
||
{showPopularityWarning && (
|
||
<div className="mb-4 p-4 bg-yellow-50 dark:bg-yellow-900/30 border border-yellow-300 dark:border-yellow-700 rounded-lg">
|
||
<div className="flex items-start gap-2">
|
||
<span className="text-yellow-600 dark:text-yellow-400 text-xl">⚠️</span>
|
||
<div className="flex-1">
|
||
<p className="font-semibold text-yellow-800 dark:text-yellow-200 mb-1">No Popularity Data</p>
|
||
<p className="text-sm text-yellow-700 dark:text-yellow-300 mb-3">
|
||
You haven't imported chat reactions yet. Import now to track which games your players loved!
|
||
</p>
|
||
<button
|
||
onClick={() => { onClose(); onShowChatImport(); }}
|
||
className="text-sm bg-yellow-600 dark:bg-yellow-700 text-white px-4 py-2 rounded hover:bg-yellow-700 dark:hover:bg-yellow-800 transition"
|
||
>
|
||
Import Chat Log
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="mb-4">
|
||
<label className="block text-gray-700 dark:text-gray-300 font-semibold mb-2">
|
||
Session Notes (optional)
|
||
</label>
|
||
<textarea
|
||
value={notes}
|
||
onChange={(e) => setNotes(e.target.value)}
|
||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg h-32 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||
placeholder="Add any notes about this session..."
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex gap-4">
|
||
<button
|
||
onClick={() => onConfirm(sessionId, notes)}
|
||
className="flex-1 bg-orange-600 dark:bg-orange-700 text-white py-3 rounded-lg hover:bg-orange-700 dark:hover:bg-orange-800 transition"
|
||
>
|
||
End Session
|
||
</button>
|
||
<button
|
||
onClick={onClose}
|
||
className="flex-1 bg-gray-600 dark:bg-gray-700 text-white py-3 rounded-lg hover:bg-gray-700 dark:hover:bg-gray-800 transition"
|
||
>
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ChatImportPanel({ sessionId, onClose, onImportComplete }) {
|
||
const [chatData, setChatData] = useState('');
|
||
const [importing, setImporting] = useState(false);
|
||
const [result, setResult] = useState(null);
|
||
const { error, success } = useToast();
|
||
|
||
const handleFileUpload = async (event) => {
|
||
const file = event.target.files[0];
|
||
if (!file) return;
|
||
try {
|
||
const text = await file.text();
|
||
setChatData(text);
|
||
success('File loaded successfully');
|
||
} catch (err) {
|
||
error('Failed to read file: ' + err.message);
|
||
}
|
||
};
|
||
|
||
const handleImport = async () => {
|
||
if (!chatData.trim()) {
|
||
error('Please enter chat data or upload a file');
|
||
return;
|
||
}
|
||
setImporting(true);
|
||
setResult(null);
|
||
try {
|
||
const parsedData = JSON.parse(chatData);
|
||
const response = await api.post(`/sessions/${sessionId}/chat-import`, { chatData: parsedData });
|
||
setResult(response.data);
|
||
success('Chat log imported successfully');
|
||
setTimeout(() => onImportComplete(), 2000);
|
||
} catch (err) {
|
||
error('Import failed: ' + (err.response?.data?.error || err.message));
|
||
} finally {
|
||
setImporting(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
|
||
<h3 className="text-xl font-semibold mb-4 dark:text-gray-100">Import Chat Log</h3>
|
||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||
Upload a JSON file or paste JSON array with format: [{"{"}"username": "...", "message": "...", "timestamp": "..."{"}"}]
|
||
<br />
|
||
The system will detect "thisgame++" and "thisgame--" patterns and update game popularity.
|
||
</p>
|
||
<div className="mb-4">
|
||
<label className="block text-gray-700 dark:text-gray-300 font-semibold mb-2">Upload JSON File</label>
|
||
<input
|
||
type="file"
|
||
accept=".json"
|
||
onChange={handleFileUpload}
|
||
disabled={importing}
|
||
className="block w-full text-sm text-gray-900 dark:text-gray-100 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-indigo-50 file:text-indigo-700 dark:file:bg-indigo-900/30 dark:file:text-indigo-300 hover:file:bg-indigo-100 dark:hover:file:bg-indigo-900/50 file:cursor-pointer cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||
/>
|
||
</div>
|
||
<div className="mb-4 text-center text-gray-500 dark:text-gray-400 text-sm">— or —</div>
|
||
<div className="mb-4">
|
||
<label className="block text-gray-700 dark:text-gray-300 font-semibold mb-2">Paste Chat JSON Data</label>
|
||
<textarea
|
||
value={chatData}
|
||
onChange={(e) => setChatData(e.target.value)}
|
||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg h-48 font-mono text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||
placeholder='[{"username":"Alice","message":"thisgame++","timestamp":"2024-01-01T12:00:00Z"}]'
|
||
disabled={importing}
|
||
/>
|
||
</div>
|
||
{result && (
|
||
<div className="mb-4 p-4 bg-green-50 dark:bg-green-900/30 border border-green-300 dark:border-green-700 rounded-lg">
|
||
<p className="font-semibold text-green-800 dark:text-green-200">Import Successful!</p>
|
||
<p className="text-sm text-green-700 dark:text-green-300">
|
||
Imported {result.messagesImported} messages, processed {result.votesProcessed} votes
|
||
</p>
|
||
</div>
|
||
)}
|
||
<div className="flex gap-4">
|
||
<button
|
||
onClick={handleImport}
|
||
disabled={importing}
|
||
className="bg-indigo-600 text-white px-6 py-2 rounded-lg hover:bg-indigo-700 transition disabled:bg-gray-400 dark:disabled:bg-gray-600"
|
||
>
|
||
{importing ? 'Importing...' : 'Import'}
|
||
</button>
|
||
<button
|
||
onClick={onClose}
|
||
className="bg-gray-600 dark:bg-gray-700 text-white px-6 py-2 rounded-lg hover:bg-gray-700 dark:hover:bg-gray-800 transition"
|
||
>
|
||
Close
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default SessionDetail;
|
||
```
|
||
|
||
- [ ] **Step 3: Verify the app compiles**
|
||
|
||
Run from `frontend/`:
|
||
|
||
```bash
|
||
cd frontend && npx vite build 2>&1 | tail -5
|
||
```
|
||
|
||
Expected: Build succeeds with no errors.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/pages/SessionDetail.jsx frontend/src/App.jsx
|
||
git commit -m "feat: add SessionDetail page with notes view/edit and route"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: Modify History Page
|
||
|
||
Simplify the History page: remove the inline detail panel, add notes preview to session cards, change clicks to navigate to `/history/:id`, remove delete button from closed sessions.
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/pages/History.jsx`
|
||
|
||
- [ ] **Step 1: Replace History.jsx**
|
||
|
||
The History page needs significant changes. Replace the entire `History` function component. Key changes:
|
||
|
||
1. Remove state: `selectedSession`, `sessionGames`, `showChatImport`
|
||
2. Remove functions: `refreshSessionGames`, `loadSessionGames`, `handleExport`, all polling for game updates
|
||
3. Remove the entire right-side detail panel (`md:col-span-2`)
|
||
4. Session cards: clicking navigates to `/history/:id` via `useNavigate`
|
||
5. Session cards: show `notes_preview` when `has_notes` is true
|
||
6. Session cards: remove Delete button for closed sessions (only End Session stays for active)
|
||
7. `EndSessionModal` stays (for ending active sessions from the list)
|
||
8. `ChatImportPanel` removed from this file (it's now in `SessionDetail.jsx`)
|
||
9. Delete confirmation modal removed from this file
|
||
|
||
Replace the `History` function in `frontend/src/pages/History.jsx` with:
|
||
|
||
```jsx
|
||
import React, { useState, useEffect, useCallback } from 'react';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import { useAuth } from '../context/AuthContext';
|
||
import { useToast } from '../components/Toast';
|
||
import api from '../api/axios';
|
||
import { formatLocalDate } from '../utils/dateUtils';
|
||
|
||
function History() {
|
||
const { isAuthenticated } = useAuth();
|
||
const { error, success } = useToast();
|
||
const navigate = useNavigate();
|
||
const [sessions, setSessions] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [closingSession, setClosingSession] = useState(null);
|
||
const [showAllSessions, setShowAllSessions] = useState(false);
|
||
|
||
const loadSessions = useCallback(async () => {
|
||
try {
|
||
const response = await api.get('/sessions');
|
||
setSessions(response.data);
|
||
} catch (err) {
|
||
console.error('Failed to load sessions', err);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
loadSessions();
|
||
}, [loadSessions]);
|
||
|
||
useEffect(() => {
|
||
const interval = setInterval(() => {
|
||
loadSessions();
|
||
}, 3000);
|
||
return () => clearInterval(interval);
|
||
}, [loadSessions]);
|
||
|
||
const handleCloseSession = async (sessionId, notes) => {
|
||
try {
|
||
await api.post(`/sessions/${sessionId}/close`, { notes });
|
||
await loadSessions();
|
||
setClosingSession(null);
|
||
success('Session ended successfully');
|
||
} catch (err) {
|
||
error('Failed to close session');
|
||
}
|
||
};
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="flex justify-center items-center h-64">
|
||
<div className="text-xl text-gray-600 dark:text-gray-400">Loading...</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="max-w-2xl mx-auto">
|
||
<h1 className="text-4xl font-bold mb-8 text-gray-800 dark:text-gray-100">Session History</h1>
|
||
|
||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
|
||
<div className="flex justify-between items-center mb-4">
|
||
<h2 className="text-2xl font-semibold text-gray-800 dark:text-gray-100">Sessions</h2>
|
||
{sessions.length > 5 && (
|
||
<button
|
||
onClick={() => setShowAllSessions(!showAllSessions)}
|
||
className="text-sm text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 transition"
|
||
>
|
||
{showAllSessions ? 'Show Recent' : `Show All (${sessions.length})`}
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{sessions.length === 0 ? (
|
||
<p className="text-gray-500 dark:text-gray-400">No sessions found</p>
|
||
) : (
|
||
<div className="space-y-2">
|
||
{(showAllSessions ? sessions : sessions.slice(0, 5)).map(session => (
|
||
<div
|
||
key={session.id}
|
||
className="border border-gray-300 dark:border-gray-600 rounded-lg hover:border-indigo-400 dark:hover:border-indigo-500 transition cursor-pointer"
|
||
>
|
||
<div
|
||
onClick={() => navigate(`/history/${session.id}`)}
|
||
className="p-4"
|
||
>
|
||
<div className="flex justify-between items-center mb-1">
|
||
<div className="flex items-center gap-2">
|
||
<span className="font-semibold text-gray-800 dark:text-gray-100">
|
||
Session #{session.id}
|
||
</span>
|
||
{session.is_active === 1 && (
|
||
<span className="bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 text-xs px-2 py-0.5 rounded">
|
||
Active
|
||
</span>
|
||
)}
|
||
</div>
|
||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||
{session.games_played} game{session.games_played !== 1 ? 's' : ''}
|
||
</span>
|
||
</div>
|
||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||
{formatLocalDate(session.created_at)}
|
||
</div>
|
||
{session.has_notes && session.notes_preview && (
|
||
<div className="mt-2 text-sm text-indigo-400 dark:text-indigo-300 bg-indigo-50 dark:bg-indigo-900/20 px-3 py-2 rounded border-l-2 border-indigo-500">
|
||
{session.notes_preview}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{isAuthenticated && session.is_active === 1 && (
|
||
<div className="px-4 pb-4 pt-0">
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setClosingSession(session.id);
|
||
}}
|
||
className="w-full bg-orange-600 dark:bg-orange-700 text-white px-4 py-2 rounded text-sm hover:bg-orange-700 dark:hover:bg-orange-800 transition"
|
||
>
|
||
End Session
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{closingSession && (
|
||
<EndSessionModal
|
||
sessionId={closingSession}
|
||
onClose={() => setClosingSession(null)}
|
||
onConfirm={handleCloseSession}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function EndSessionModal({ sessionId, onClose, onConfirm }) {
|
||
const [notes, setNotes] = useState('');
|
||
|
||
return (
|
||
<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 dark:text-gray-100">End Session #{sessionId}</h2>
|
||
<div className="mb-4">
|
||
<label className="block text-gray-700 dark:text-gray-300 font-semibold mb-2">
|
||
Session Notes (optional)
|
||
</label>
|
||
<textarea
|
||
value={notes}
|
||
onChange={(e) => setNotes(e.target.value)}
|
||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg h-32 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||
placeholder="Add any notes about this session..."
|
||
/>
|
||
</div>
|
||
<div className="flex gap-4">
|
||
<button
|
||
onClick={() => onConfirm(sessionId, notes)}
|
||
className="flex-1 bg-orange-600 dark:bg-orange-700 text-white py-3 rounded-lg hover:bg-orange-700 dark:hover:bg-orange-800 transition"
|
||
>
|
||
End Session
|
||
</button>
|
||
<button
|
||
onClick={onClose}
|
||
className="flex-1 bg-gray-600 dark:bg-gray-700 text-white py-3 rounded-lg hover:bg-gray-700 dark:hover:bg-gray-800 transition"
|
||
>
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default History;
|
||
```
|
||
|
||
Note: The `EndSessionModal` here is simplified — it no longer receives `sessionGames` or `onShowChatImport` since the detailed chat import flow now lives on the `SessionDetail` page. The popularity warning is only shown on the `SessionDetail` page's `EndSessionModal`. The `ChatImportPanel` component is removed entirely from this file.
|
||
|
||
- [ ] **Step 2: Verify the app compiles**
|
||
|
||
Run from `frontend/`:
|
||
|
||
```bash
|
||
cd frontend && npx vite build 2>&1 | tail -5
|
||
```
|
||
|
||
Expected: Build succeeds.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/pages/History.jsx
|
||
git commit -m "feat: simplify History page to session list with notes preview and navigation"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: Final Verification
|
||
|
||
Run all tests and confirm the build.
|
||
|
||
**Files:** None (verification only)
|
||
|
||
- [ ] **Step 1: Run all backend tests**
|
||
|
||
Run: `npx jest --verbose`
|
||
Expected: All tests pass.
|
||
|
||
- [ ] **Step 2: Run frontend build**
|
||
|
||
Run: `cd frontend && npx vite build`
|
||
Expected: Build succeeds.
|
||
|
||
- [ ] **Step 3: Commit any remaining changes**
|
||
|
||
If any test fixes or adjustments were needed, commit them:
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "chore: final verification and cleanup for session notes feature"
|
||
```
|