Compare commits
19 Commits
171303a6f9
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
512b36da51
|
||
|
|
d613d4e507
|
||
|
|
bbd2e51567
|
||
|
|
b40176033f
|
||
|
|
68045afbbc
|
||
|
|
35474e5df4
|
||
|
|
4da2c15d56
|
||
|
|
bff103e26e
|
||
|
|
a68a617508
|
||
|
|
0ee97b35c5
|
||
|
|
7ce5251543
|
||
|
|
b9206b6cfe
|
||
|
|
ce3347d0b1
|
||
|
|
e9f1b89d44
|
||
|
|
656d9c3bf6
|
||
|
|
974d7315b9
|
||
|
|
341257a04d
|
||
|
|
8c36b399d0
|
||
|
|
c756d45e24
|
@@ -56,6 +56,13 @@ function initializeDatabase() {
|
||||
)
|
||||
`);
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Session games table
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS session_games (
|
||||
|
||||
19
backend/middleware/optional-auth.js
Normal file
19
backend/middleware/optional-auth.js
Normal file
@@ -0,0 +1,19 @@
|
||||
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 };
|
||||
@@ -5,6 +5,8 @@ const db = require('../database');
|
||||
const { triggerWebhook } = require('../utils/webhooks');
|
||||
const { getWebSocketManager } = require('../utils/websocket-manager');
|
||||
const { startMonitor, stopMonitor, getMonitorSnapshot } = require('../utils/ecast-shard-client');
|
||||
const { computeNotesPreview } = require('../utils/notes-preview');
|
||||
const { optionalAuthenticateToken } = require('../middleware/optional-auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -19,17 +21,54 @@ function createMessageHash(username, message, timestamp) {
|
||||
// Get all sessions
|
||||
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.*,
|
||||
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();
|
||||
|
||||
res.json(sessions);
|
||||
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 });
|
||||
}
|
||||
@@ -61,7 +100,7 @@ router.get('/active', (req, res) => {
|
||||
});
|
||||
|
||||
// Get single session by ID
|
||||
router.get('/:id', (req, res) => {
|
||||
router.get('/:id', optionalAuthenticateToken, (req, res) => {
|
||||
try {
|
||||
const session = db.prepare(`
|
||||
SELECT
|
||||
@@ -77,7 +116,14 @@ router.get('/:id', (req, res) => {
|
||||
return res.status(404).json({ error: 'Session not found' });
|
||||
}
|
||||
|
||||
res.json(session);
|
||||
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 });
|
||||
}
|
||||
@@ -133,6 +179,62 @@ router.post('/', authenticateToken, (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 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 });
|
||||
}
|
||||
});
|
||||
|
||||
// Close/finalize session (admin only)
|
||||
router.post('/:id/close', authenticateToken, (req, res) => {
|
||||
try {
|
||||
@@ -227,6 +329,84 @@ router.delete('/:id', authenticateToken, (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Update session notes (admin only)
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
// Clear session notes (admin only)
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
// 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 });
|
||||
}
|
||||
});
|
||||
|
||||
// Get games played in a session
|
||||
router.get('/:id/games', (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -114,7 +114,9 @@ class EcastShardClient {
|
||||
this.stopStatusBroadcast();
|
||||
this.statusInterval = setInterval(() => {
|
||||
this._refreshPlayerCount().finally(() => {
|
||||
this.onEvent('game.status', this.getSnapshot());
|
||||
if (!this.manuallyStopped && !this.gameFinished) {
|
||||
this.onEvent('game.status', this.getSnapshot());
|
||||
}
|
||||
});
|
||||
}, 20000);
|
||||
}
|
||||
@@ -146,7 +148,7 @@ class EcastShardClient {
|
||||
const timeout = setTimeout(() => done(probe), 10000);
|
||||
|
||||
probe.on('message', (data) => {
|
||||
if (welcomed) return;
|
||||
if (welcomed || this.manuallyStopped) { clearTimeout(timeout); done(probe); return; }
|
||||
try {
|
||||
const msg = JSON.parse(data.toString());
|
||||
if (msg.opcode === 'client/welcome') {
|
||||
@@ -155,15 +157,17 @@ class EcastShardClient {
|
||||
if (playerCount > this.playerCount || playerNames.length !== this.playerNames.length) {
|
||||
this.playerCount = playerCount;
|
||||
this.playerNames = playerNames;
|
||||
this.onEvent('lobby.player-joined', {
|
||||
sessionId: this.sessionId,
|
||||
gameId: this.gameId,
|
||||
roomCode: this.roomCode,
|
||||
playerName: playerNames[playerNames.length - 1] || '',
|
||||
playerCount,
|
||||
players: [...playerNames],
|
||||
maxPlayers: this.maxPlayers,
|
||||
});
|
||||
if (!this.manuallyStopped) {
|
||||
this.onEvent('lobby.player-joined', {
|
||||
sessionId: this.sessionId,
|
||||
gameId: this.gameId,
|
||||
roomCode: this.roomCode,
|
||||
playerName: playerNames[playerNames.length - 1] || '',
|
||||
playerCount,
|
||||
players: [...playerNames],
|
||||
maxPlayers: this.maxPlayers,
|
||||
});
|
||||
}
|
||||
} else if (playerCount !== this.playerCount) {
|
||||
this.playerCount = playerCount;
|
||||
this.playerNames = playerNames;
|
||||
@@ -196,6 +200,7 @@ class EcastShardClient {
|
||||
}
|
||||
|
||||
handleMessage(message) {
|
||||
if (this.manuallyStopped || this.gameFinished) return;
|
||||
switch (message.opcode) {
|
||||
case 'client/welcome':
|
||||
this.handleWelcome(message.result);
|
||||
@@ -301,6 +306,15 @@ class EcastShardClient {
|
||||
playerCount: this.playerCount,
|
||||
players: [...this.playerNames],
|
||||
});
|
||||
this.onEvent('room.disconnected', {
|
||||
sessionId: this.sessionId,
|
||||
gameId: this.gameId,
|
||||
roomCode: this.roomCode,
|
||||
reason: 'room_closed',
|
||||
finalPlayerCount: this.playerCount,
|
||||
});
|
||||
activeShards.delete(`${this.sessionId}-${this.gameId}`);
|
||||
this.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -367,6 +381,7 @@ class EcastShardClient {
|
||||
reason: 'room_closed',
|
||||
finalPlayerCount: this.playerCount,
|
||||
});
|
||||
activeShards.delete(`${this.sessionId}-${this.gameId}`);
|
||||
this.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
26
backend/utils/notes-preview.js
Normal file
26
backend/utils/notes-preview.js
Normal file
@@ -0,0 +1,26 @@
|
||||
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 };
|
||||
1652
docs/superpowers/plans/2026-03-22-session-notes-read-edit-delete.md
Normal file
1652
docs/superpowers/plans/2026-03-22-session-notes-read-edit-delete.md
Normal file
File diff suppressed because it is too large
Load Diff
1140
docs/superpowers/plans/2026-03-23-session-archive-multiselect.md
Normal file
1140
docs/superpowers/plans/2026-03-23-session-archive-multiselect.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,170 @@
|
||||
# Session Notes — Read, Edit, Delete
|
||||
|
||||
**Date:** 2026-03-22
|
||||
**Status:** Approved
|
||||
|
||||
## Problem
|
||||
|
||||
When an admin/host ends a session, they can write notes via the EndSessionModal. But after that, those notes are effectively invisible — the History page doesn't display them, and there's no way to read, edit, or delete them from the UI. Notes only surface in raw exports.
|
||||
|
||||
## Solution
|
||||
|
||||
Add the ability to view, edit, and delete session notes through two surfaces:
|
||||
|
||||
1. **Notes preview on History page session cards** — inline teaser showing the first paragraph
|
||||
2. **New Session Detail page** (`/history/:id`) — full rendered markdown notes with inline edit/delete, plus session management actions
|
||||
|
||||
## Approach
|
||||
|
||||
Minimal extension of existing infrastructure (Approach A). No database schema changes. The `sessions.notes` TEXT column stays as-is. Frontend gets a new page and a markdown rendering dependency.
|
||||
|
||||
## Backend API Changes
|
||||
|
||||
### New Endpoints
|
||||
|
||||
#### `PUT /api/sessions/:id/notes`
|
||||
|
||||
- **Auth:** Required (admin token)
|
||||
- **Body:** `{ "notes": "markdown string" }`
|
||||
- **Behavior:** Overwrites `sessions.notes` for the given session (no COALESCE merge — full replacement)
|
||||
- **Response:** Updated session object
|
||||
- **Errors:** 404 if session not found, 401 if no auth header, 403 if token invalid/expired (consistent with existing `authenticateToken` middleware behavior)
|
||||
|
||||
#### `DELETE /api/sessions/:id/notes`
|
||||
|
||||
- **Auth:** Required (admin token)
|
||||
- **Body:** None
|
||||
- **Behavior:** Sets `sessions.notes = NULL`
|
||||
- **Response:** `{ success: true }`
|
||||
- **Errors:** 404 if session not found, 401 if no auth header, 403 if token invalid/expired
|
||||
|
||||
### Modified Endpoints
|
||||
|
||||
#### `GET /api/sessions` (list)
|
||||
|
||||
Add two fields to each session object in the response:
|
||||
|
||||
- `has_notes` (boolean) — `true` if `notes IS NOT NULL AND notes != ''`
|
||||
- `notes_preview` (string | null) — first paragraph of the markdown, truncated to ~150 characters. `null` if no notes.
|
||||
- **Remove `notes` from list response** — the full `notes` field must be omitted from list items. Use explicit column selection instead of `SELECT s.*` to avoid leaking full notes to unauthenticated clients. The list endpoint only returns `has_notes` and `notes_preview`.
|
||||
|
||||
These are computed server-side from the existing `notes` column.
|
||||
|
||||
#### `GET /api/sessions/:id` (single session)
|
||||
|
||||
Conditional notes visibility based on auth:
|
||||
|
||||
- **Authenticated request:** Returns full `notes` field (plus `has_notes` and `notes_preview`)
|
||||
- **Unauthenticated request:** Returns `notes_preview` and `has_notes` only. `notes` field is omitted or null.
|
||||
|
||||
The endpoint currently does not require auth. It will remain publicly accessible but gate the full notes content behind an optional auth check.
|
||||
|
||||
### Unchanged Endpoints
|
||||
|
||||
- `POST /api/sessions` — session creation (unchanged)
|
||||
- `POST /api/sessions/:id/close` — session close with optional notes (unchanged)
|
||||
- `DELETE /api/sessions/:id` — session deletion (unchanged)
|
||||
|
||||
## Frontend Changes
|
||||
|
||||
### History Page (`/history` — `History.jsx`)
|
||||
|
||||
#### Session Cards (Sidebar)
|
||||
|
||||
- Add notes preview teaser below the date/games-count line when `has_notes` is true
|
||||
- Visual treatment: indigo left-border accent, subtle background, truncated text with ellipsis
|
||||
- Clicking a session card navigates to `/history/:id` (instead of expanding the inline detail panel)
|
||||
|
||||
#### Action Buttons
|
||||
|
||||
- **Active sessions:** "End Session" button stays on the card (opens `EndSessionModal` as before)
|
||||
- **Closed sessions:** Delete button removed from the card (moved to detail page only)
|
||||
|
||||
#### Removed from History Page
|
||||
|
||||
- The inline session detail panel (right side, `md:col-span-2`) is replaced by navigation to the detail page
|
||||
- `ChatImportPanel` moves to the detail page
|
||||
- Export buttons move to the detail page
|
||||
|
||||
### New Session Detail Page (`/history/:id` — `SessionDetail.jsx`)
|
||||
|
||||
New route and component.
|
||||
|
||||
#### Layout
|
||||
|
||||
- **Back link** — "← Back to History" navigates to `/history`
|
||||
- **Session header** — Session number, created date/time, games count, active badge if applicable
|
||||
- **Notes section** — Primary content area (see Notes Section below)
|
||||
- **Games list** — Same as current History detail panel (reuse existing game card markup)
|
||||
- **Action buttons:**
|
||||
- Export as TXT / Export as JSON (same as current)
|
||||
- Import Chat Log (active sessions only, admin only)
|
||||
- End Session (active sessions only, admin only — opens `EndSessionModal`)
|
||||
- Delete Session (closed sessions only, admin only — confirmation modal)
|
||||
|
||||
#### Notes Section — View Mode
|
||||
|
||||
- Renders notes as formatted HTML via `react-markdown`
|
||||
- If no notes exist: shows "No notes" placeholder with "Add Notes" button (admin only)
|
||||
- Admin sees an "Edit" button in the section header
|
||||
|
||||
#### Notes Section — Edit Mode (Admin Only)
|
||||
|
||||
- Triggered by clicking "Edit" (or "Add Notes" for empty notes)
|
||||
- Rendered markdown is replaced in-place by a textarea containing the raw markdown
|
||||
- "Supports Markdown formatting" hint below the textarea
|
||||
- Action buttons: **Save** (green), **Cancel** (gray), **Delete Notes** (red, with confirmation)
|
||||
- Save calls `PUT /api/sessions/:id/notes`
|
||||
- Delete Notes calls `DELETE /api/sessions/:id/notes` after a confirmation prompt
|
||||
- Cancel reverts to view mode without saving
|
||||
|
||||
#### Notes Section — Public View (Unauthenticated)
|
||||
|
||||
- Shows `notes_preview` text (first paragraph, plain text — not markdown-rendered)
|
||||
- "Log in to view full notes" hint below the preview
|
||||
- No edit controls
|
||||
|
||||
### Routing
|
||||
|
||||
Add new route in `App.jsx`:
|
||||
|
||||
```
|
||||
/history/:id → <SessionDetail />
|
||||
```
|
||||
|
||||
Existing `/history` route unchanged.
|
||||
|
||||
### New Dependency
|
||||
|
||||
- `react-markdown` — lightweight markdown-to-React renderer. Used only in `SessionDetail.jsx` for rendering notes.
|
||||
|
||||
## What's NOT Changing
|
||||
|
||||
- **Database schema** — no migration, no new tables, no new columns
|
||||
- **`EndSessionModal`** — still works as-is for writing notes at session close time
|
||||
- **`POST /api/sessions/:id/close`** — untouched
|
||||
- **WebSocket events** — no notes-related real-time updates
|
||||
- **Home page** — still shows `activeSession.notes` as plain text for active sessions (no changes)
|
||||
|
||||
## Permission Model
|
||||
|
||||
| Action | Auth Required |
|
||||
|--------|--------------|
|
||||
| View notes preview (list + detail) | No |
|
||||
| View full notes (detail page) | Yes |
|
||||
| Edit notes | Yes |
|
||||
| Delete notes | Yes |
|
||||
| Delete session | Yes |
|
||||
| End session | Yes |
|
||||
|
||||
This is consistent with the existing pattern where read-only session data is public and mutations require admin auth.
|
||||
|
||||
## Notes Preview Computation
|
||||
|
||||
Server-side logic for `notes_preview`:
|
||||
|
||||
1. If `notes` is null or empty, `notes_preview = null`, `has_notes = false`
|
||||
2. Split `notes` on the first double-newline (`\n\n`) to get the first paragraph
|
||||
3. Strip markdown formatting (bold, links, etc.) for a clean plain-text preview
|
||||
4. Truncate to 150 characters, append `...` if truncated
|
||||
5. Return as `notes_preview` string
|
||||
@@ -0,0 +1,185 @@
|
||||
# Design: Session Archive, Sunday Badge, Multi-Select, and Pagination
|
||||
|
||||
## Overview
|
||||
|
||||
Four enhancements to the History page and Session Detail page:
|
||||
|
||||
1. **Archive/Unarchive sessions** — hide sessions from the default history list, with a filter to view archived sessions
|
||||
2. **Sunday "Game Night" badge** — visual indicator on session cards when a session took place on a Sunday
|
||||
3. **Multi-select mode** — bulk archive and delete operations on the History page (admin only)
|
||||
4. **Pagination options** — configurable number of sessions shown (5, 10, 25, 50, All)
|
||||
|
||||
## Backend
|
||||
|
||||
### Schema Change
|
||||
|
||||
Add `archived INTEGER DEFAULT 0` to the `sessions` table using the existing defensive `ALTER TABLE` pattern in `database.js`:
|
||||
|
||||
```js
|
||||
try {
|
||||
db.exec(`ALTER TABLE sessions ADD COLUMN archived INTEGER DEFAULT 0`);
|
||||
} catch (err) {
|
||||
// Column already exists
|
||||
}
|
||||
```
|
||||
|
||||
No new tables. No migration file.
|
||||
|
||||
### New Endpoints
|
||||
|
||||
#### `POST /api/sessions/:id/archive`
|
||||
|
||||
- **Auth:** Required
|
||||
- **Action:** Sets `archived = 1` on the session
|
||||
- **Constraints:** Returns 400 if the session is still active (`is_active = 1`). Returns 404 if session not found.
|
||||
- **Response:** `{ success: true }`
|
||||
|
||||
#### `POST /api/sessions/:id/unarchive`
|
||||
|
||||
- **Auth:** Required
|
||||
- **Action:** Sets `archived = 0` on the session
|
||||
- **Constraints:** Returns 404 if session not found.
|
||||
- **Response:** `{ success: true }`
|
||||
|
||||
#### `POST /api/sessions/bulk`
|
||||
|
||||
- **Auth:** Required
|
||||
- **Action:** Performs a bulk operation on multiple sessions
|
||||
- **Body:** `{ "action": "archive" | "unarchive" | "delete", "ids": [1, 2, 3] }`
|
||||
- **Constraints:**
|
||||
- For `archive` and `delete`: rejects request with 400 if any session ID in the list is still active, returning the offending IDs in the error response
|
||||
- All IDs must exist (404 if any are not found)
|
||||
- Runs inside a database transaction — all-or-nothing
|
||||
- **Validation:** Returns 400 if `ids` is empty, if `action` is not one of the three valid values, or if `ids` is not an array
|
||||
- **Response:** `{ success: true, affected: <count> }`
|
||||
- **Route registration:** Must be registered before `/:id` routes to avoid Express matching `"bulk"` as an `:id` parameter
|
||||
|
||||
### Modified Endpoint
|
||||
|
||||
#### `GET /api/sessions`
|
||||
|
||||
Add two query parameters:
|
||||
|
||||
- **`filter`**: `"default"` | `"archived"` | `"all"`
|
||||
- `"default"` (when omitted): returns sessions where `archived = 0`
|
||||
- `"archived"`: returns sessions where `archived = 1`
|
||||
- `"all"`: returns all sessions regardless of archived status
|
||||
- **`limit`**: `"5"` | `"10"` | `"25"` | `"50"` | `"all"`
|
||||
- `"5"` (when omitted): returns the first 5 sessions (ordered by `created_at DESC`)
|
||||
- `"all"`: no limit applied
|
||||
- Any other value: applied as SQL `LIMIT`
|
||||
|
||||
The response shape stays the same (array of session objects). Each session now includes the `archived` field (0 or 1). The existing `has_notes` and `notes_preview` fields continue as-is.
|
||||
|
||||
**Total count:** The response should also include a way for the frontend to know how many sessions match the current filter (for the "N sessions total" display). Two options: a response header, or wrapping the response. To avoid breaking the existing array response shape, add a custom response header `X-Total-Count` with the total matching count before limit is applied.
|
||||
|
||||
### Unchanged Endpoints
|
||||
|
||||
- `GET /api/sessions/:id` — already returns the full session object; will naturally include `archived` once the column exists
|
||||
- `POST /api/sessions` — unchanged (new sessions default to `archived = 0`)
|
||||
- `POST /api/sessions/:id/close` — unchanged
|
||||
- `DELETE /api/sessions/:id` — unchanged (still works, used by detail page)
|
||||
- `PUT /api/sessions/:id/notes`, `DELETE /api/sessions/:id/notes` — unchanged
|
||||
|
||||
## Frontend
|
||||
|
||||
### History Page — Controls Bar
|
||||
|
||||
Replace the existing "Show All / Show Recent" toggle with a cohesive controls bar containing:
|
||||
|
||||
1. **Filter dropdown:** "Sessions" (default, `filter=default`), "Archived" (`filter=archived`), "All" (`filter=all`)
|
||||
2. **Show dropdown:** 5 (default), 10, 25, 50, All
|
||||
3. **Session count:** "N sessions total" — derived from the `X-Total-Count` response header
|
||||
4. **Select button** (admin only): toggles multi-select mode on/off
|
||||
|
||||
Both the Filter and Show selections are persisted in `localStorage`:
|
||||
- `history-filter` — stores the selected filter value (`"default"`, `"archived"`, `"all"`)
|
||||
- `history-show-limit` — stores the selected limit value (`"5"`, `"10"`, `"25"`, `"50"`, `"all"`)
|
||||
|
||||
Values are read on mount and written on change. The frontend should always pass both `filter` and `limit` query params explicitly when calling `GET /api/sessions`, including in the 3-second polling interval.
|
||||
|
||||
### History Page — Session Cards
|
||||
|
||||
#### Sunday Badge
|
||||
|
||||
Sessions whose `created_at` falls on a Sunday (in the user's local timezone) display:
|
||||
- An amber "GAME NIGHT" badge (with sun icon) next to the session number
|
||||
- "· Sunday" appended to the date line in muted text
|
||||
|
||||
Determination is client-side: `parseUTCTimestamp(session.created_at).getDay() === 0` using the existing `dateUtils.js` helper.
|
||||
|
||||
#### Archived Badge
|
||||
|
||||
When viewing the "All" or "Archived" filter, archived sessions show a gray "Archived" badge next to the session number.
|
||||
|
||||
#### Notes Preview
|
||||
|
||||
Continues as-is — indigo left-border teaser when `has_notes` is true.
|
||||
|
||||
### History Page — Multi-Select Mode
|
||||
|
||||
**Entering multi-select:**
|
||||
- Click the "Select" toggle button in the controls bar (admin only)
|
||||
- Long-press (500ms+) on a closed session card (admin only) — enters multi-select and selects that card
|
||||
|
||||
**While in multi-select:**
|
||||
- Checkboxes appear on each session card
|
||||
- Active sessions (`is_active = 1`) are greyed out with a disabled checkbox — not selectable
|
||||
- Clicking a card toggles its selection (instead of navigating to detail page)
|
||||
- The "End Session" button on active session cards is hidden
|
||||
- A floating action bar appears at the bottom with:
|
||||
- Left: "N selected" count
|
||||
- Right: context-aware action buttons:
|
||||
- **"Sessions" filter:** Archive + Delete
|
||||
- **"Archived" filter:** Unarchive + Delete
|
||||
- **"All" filter:** Archive + Unarchive + Delete
|
||||
- The action bar only appears when at least 1 session is selected
|
||||
- **Delete** always shows a confirmation modal: "Delete N sessions? This cannot be undone."
|
||||
- **Archive/Unarchive** execute immediately (non-destructive, reversible) with a toast confirmation
|
||||
|
||||
**Changing filter or limit while in multi-select:**
|
||||
- Clears all selections (since the visible session list changes)
|
||||
- Stays in multi-select mode
|
||||
|
||||
**Exiting multi-select:**
|
||||
- Click the "Done" / Select toggle button
|
||||
- Clears all selections and hides checkboxes + action bar
|
||||
|
||||
### Session Detail Page
|
||||
|
||||
#### Archive/Unarchive Button
|
||||
|
||||
- Placed alongside the existing Delete Session button at the bottom of the page
|
||||
- Only visible to authenticated admins, only for closed sessions
|
||||
- Shows "Archive" if `session.archived === 0`, "Unarchive" if `session.archived === 1`
|
||||
- Executes immediately with toast confirmation (no modal — it's reversible)
|
||||
|
||||
#### Archived Banner
|
||||
|
||||
- When viewing an archived session, a subtle banner appears at the top of the detail content: "This session is archived"
|
||||
- Includes an inline "Unarchive" button (admin only)
|
||||
|
||||
#### Sunday Badge
|
||||
|
||||
- Same amber "GAME NIGHT" badge shown next to "Session #N" in the page header
|
||||
- "· Sunday" in the date/time display
|
||||
|
||||
### Frontend Utilities
|
||||
|
||||
Add a helper to `dateUtils.js`:
|
||||
|
||||
```js
|
||||
export function isSunday(sqliteTimestamp) {
|
||||
return parseUTCTimestamp(sqliteTimestamp).getDay() === 0;
|
||||
}
|
||||
```
|
||||
|
||||
## What's NOT Changing
|
||||
|
||||
- **Database:** No new tables, just one column addition
|
||||
- **Session notes** (view/edit/delete) — untouched
|
||||
- **EndSessionModal** — untouched
|
||||
- **WebSocket events** — no archive-related real-time updates
|
||||
- **Other routes/pages** (Home, Picker, Manager, Login) — untouched
|
||||
- **`POST /sessions/:id/close`** — untouched
|
||||
- **New dependencies** — none required (no new npm packages)
|
||||
1316
frontend/package-lock.json
generated
1316
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -5,10 +5,12 @@
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"axios": "^1.6.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.20.1",
|
||||
"axios": "^1.6.2"
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^6.20.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.43",
|
||||
@@ -26,4 +28,3 @@
|
||||
"generate-manifest": "node generate-manifest.js"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import Login from './pages/Login';
|
||||
import Picker from './pages/Picker';
|
||||
import Manager from './pages/Manager';
|
||||
import History from './pages/History';
|
||||
import SessionDetail from './pages/SessionDetail';
|
||||
|
||||
function App() {
|
||||
const { isAuthenticated, logout } = useAuth();
|
||||
@@ -161,6 +162,7 @@ function App() {
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/history" element={<History />} />
|
||||
<Route path="/history/:id" element={<SessionDetail />} />
|
||||
<Route path="/picker" element={<Picker />} />
|
||||
<Route path="/manager" element={<Manager />} />
|
||||
</Routes>
|
||||
|
||||
@@ -2,7 +2,7 @@ export const branding = {
|
||||
app: {
|
||||
name: 'HSO Jackbox Game Picker',
|
||||
shortName: 'Jackbox Game Picker',
|
||||
version: '0.6.0 - Fish Tank Edition',
|
||||
version: '0.6.2 - Fish Tank Edition',
|
||||
description: 'Spicing up Hyper Spaceout game nights!',
|
||||
},
|
||||
meta: {
|
||||
@@ -11,7 +11,7 @@ export const branding = {
|
||||
themeColor: '#4F46E5', // Indigo-600
|
||||
},
|
||||
links: {
|
||||
github: '', // Optional: Add your repo URL
|
||||
github: 'https://code.cottongin.xyz/HyperSpaceOut/jackboxpartypack-gamepicker', // Optional: Add your repo URL
|
||||
support: 'cottongin@cottongin.xyz', // Optional: Add support/contact URL
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,119 +1,65 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useToast } from '../components/Toast';
|
||||
import api from '../api/axios';
|
||||
import { formatLocalDateTime, formatLocalDate, formatLocalTime } from '../utils/dateUtils';
|
||||
import PopularityBadge from '../components/PopularityBadge';
|
||||
import { formatLocalDate, isSunday } from '../utils/dateUtils';
|
||||
|
||||
function History() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { error, success } = useToast();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [sessions, setSessions] = useState([]);
|
||||
const [selectedSession, setSelectedSession] = useState(null);
|
||||
const [sessionGames, setSessionGames] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showChatImport, setShowChatImport] = useState(false);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [closingSession, setClosingSession] = useState(null);
|
||||
const [showAllSessions, setShowAllSessions] = useState(false);
|
||||
const [deletingSession, setDeletingSession] = useState(null);
|
||||
|
||||
const [filter, setFilter] = useState(() => localStorage.getItem('history-filter') || 'default');
|
||||
const [limit, setLimit] = useState(() => localStorage.getItem('history-show-limit') || '5');
|
||||
|
||||
const [selectMode, setSelectMode] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState(new Set());
|
||||
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false);
|
||||
|
||||
const longPressTimer = useRef(null);
|
||||
const longPressFired = useRef(false);
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
try {
|
||||
const response = await api.get('/sessions');
|
||||
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);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshSessionGames = useCallback(async (sessionId, silent = false) => {
|
||||
try {
|
||||
const response = await api.get(`/sessions/${sessionId}/games`);
|
||||
// Reverse chronological order (most recent first) - create new array to avoid mutation
|
||||
setSessionGames([...response.data].reverse());
|
||||
} catch (err) {
|
||||
if (!silent) {
|
||||
console.error('Failed to load session games', err);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
}, [filter, limit]);
|
||||
|
||||
useEffect(() => {
|
||||
loadSessions();
|
||||
}, [loadSessions]);
|
||||
|
||||
// Auto-select active session if navigating from picker
|
||||
useEffect(() => {
|
||||
if (sessions.length > 0 && !selectedSession) {
|
||||
const activeSession = sessions.find(s => s.is_active === 1);
|
||||
if (activeSession) {
|
||||
loadSessionGames(activeSession.id);
|
||||
}
|
||||
}
|
||||
}, [sessions, selectedSession]);
|
||||
|
||||
// Poll for session list updates (to detect when sessions end/start)
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
loadSessions();
|
||||
}, 3000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [loadSessions]);
|
||||
|
||||
// Poll for updates on active session games
|
||||
useEffect(() => {
|
||||
if (!selectedSession) return;
|
||||
|
||||
const currentSession = sessions.find(s => s.id === selectedSession);
|
||||
if (!currentSession || currentSession.is_active !== 1) return;
|
||||
|
||||
// Refresh games every 3 seconds for active session
|
||||
const interval = setInterval(() => {
|
||||
refreshSessionGames(selectedSession, true); // silent refresh
|
||||
}, 3000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [selectedSession, sessions, refreshSessionGames]);
|
||||
|
||||
const handleExport = async (sessionId, format) => {
|
||||
try {
|
||||
const response = await api.get(`/sessions/${sessionId}/export?format=${format}`, {
|
||||
responseType: 'blob'
|
||||
});
|
||||
|
||||
// Create download link
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', `session-${sessionId}.${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) {
|
||||
console.error('Failed to export session', err);
|
||||
error('Failed to export session');
|
||||
}
|
||||
const handleFilterChange = (newFilter) => {
|
||||
setFilter(newFilter);
|
||||
localStorage.setItem('history-filter', newFilter);
|
||||
setSelectedIds(new Set());
|
||||
};
|
||||
|
||||
const loadSessionGames = async (sessionId, silent = false) => {
|
||||
try {
|
||||
const response = await api.get(`/sessions/${sessionId}/games`);
|
||||
// Reverse chronological order (most recent first) - create new array to avoid mutation
|
||||
setSessionGames([...response.data].reverse());
|
||||
if (!silent) {
|
||||
setSelectedSession(sessionId);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!silent) {
|
||||
console.error('Failed to load session games', err);
|
||||
}
|
||||
}
|
||||
const handleLimitChange = (newLimit) => {
|
||||
setLimit(newLimit);
|
||||
localStorage.setItem('history-show-limit', newLimit);
|
||||
setSelectedIds(new Set());
|
||||
};
|
||||
|
||||
const handleCloseSession = async (sessionId, notes) => {
|
||||
@@ -121,28 +67,60 @@ function History() {
|
||||
await api.post(`/sessions/${sessionId}/close`, { notes });
|
||||
await loadSessions();
|
||||
setClosingSession(null);
|
||||
if (selectedSession === sessionId) {
|
||||
// Reload the session details to show updated state
|
||||
loadSessionGames(sessionId);
|
||||
}
|
||||
success('Session ended successfully');
|
||||
} catch (err) {
|
||||
error('Failed to close session');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSession = async (sessionId) => {
|
||||
try {
|
||||
await api.delete(`/sessions/${sessionId}`);
|
||||
await loadSessions();
|
||||
setDeletingSession(null);
|
||||
if (selectedSession === sessionId) {
|
||||
setSelectedSession(null);
|
||||
setSessionGames([]);
|
||||
// Multi-select handlers
|
||||
const toggleSelection = (sessionId) => {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(sessionId)) {
|
||||
next.delete(sessionId);
|
||||
} else {
|
||||
next.add(sessionId);
|
||||
}
|
||||
success('Session deleted successfully');
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const exitSelectMode = () => {
|
||||
setSelectMode(false);
|
||||
setSelectedIds(new Set());
|
||||
setShowBulkDeleteConfirm(false);
|
||||
};
|
||||
|
||||
const handlePointerDown = (sessionId) => {
|
||||
if (!isAuthenticated || selectMode) return;
|
||||
longPressFired.current = false;
|
||||
longPressTimer.current = setTimeout(() => {
|
||||
longPressFired.current = true;
|
||||
setSelectMode(true);
|
||||
setSelectedIds(new Set([sessionId]));
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const handlePointerUp = () => {
|
||||
if (longPressTimer.current) {
|
||||
clearTimeout(longPressTimer.current);
|
||||
longPressTimer.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
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('Failed to delete session: ' + (err.response?.data?.error || err.message));
|
||||
error(err.response?.data?.error || `Failed to ${action} sessions`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -155,265 +133,235 @@ function History() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<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="grid md:grid-cols-3 gap-6">
|
||||
{/* Sessions List */}
|
||||
<div className="md:col-span-1">
|
||||
<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 > 3 && (
|
||||
<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 className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
|
||||
{/* Controls Bar */}
|
||||
<div className="flex flex-wrap justify-between items-center gap-3 mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Filter:</span>
|
||||
<select
|
||||
value={filter}
|
||||
onChange={(e) => handleFilterChange(e.target.value)}
|
||||
className="px-2 py-1.5 border border-gray-300 dark:border-gray-600 rounded-md text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 cursor-pointer"
|
||||
>
|
||||
<option value="default">Sessions</option>
|
||||
<option value="archived">Archived</option>
|
||||
<option value="all">All</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{sessions.length === 0 ? (
|
||||
<p className="text-gray-500 dark:text-gray-400">No sessions found</p>
|
||||
) : (
|
||||
<div className="space-y-1 max-h-[600px] overflow-y-auto">
|
||||
{(showAllSessions ? sessions : sessions.slice(0, 3)).map(session => (
|
||||
<div
|
||||
key={session.id}
|
||||
className={`border rounded-lg transition ${
|
||||
selectedSession === session.id
|
||||
? 'border-indigo-500 bg-indigo-50 dark:bg-indigo-900/30'
|
||||
: 'border-gray-300 dark:border-gray-600 hover:border-indigo-300 dark:hover:border-indigo-600'
|
||||
}`}
|
||||
>
|
||||
{/* Main session info - clickable */}
|
||||
<div
|
||||
onClick={() => loadSessionGames(session.id)}
|
||||
className="p-3 cursor-pointer"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-semibold text-sm 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 flex-shrink-0">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>{formatLocalDate(session.created_at)}</span>
|
||||
<span>•</span>
|
||||
<span>{session.games_played} game{session.games_played !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons for authenticated users */}
|
||||
{isAuthenticated && (
|
||||
<div className="px-3 pb-3 pt-0 flex gap-2">
|
||||
{session.is_active === 1 ? (
|
||||
<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>
|
||||
) : (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDeletingSession(session.id);
|
||||
}}
|
||||
className="w-full bg-red-600 dark:bg-red-700 text-white px-4 py-2 rounded text-sm hover:bg-red-700 dark:hover:bg-red-800 transition"
|
||||
>
|
||||
Delete Session
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Show:</span>
|
||||
<select
|
||||
value={limit}
|
||||
onChange={(e) => handleLimitChange(e.target.value)}
|
||||
className="px-2 py-1.5 border border-gray-300 dark:border-gray-600 rounded-md text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 cursor-pointer"
|
||||
>
|
||||
<option value="5">5</option>
|
||||
<option value="10">10</option>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="all">All</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">
|
||||
{totalCount} session{totalCount !== 1 ? 's' : ''} total
|
||||
</span>
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Session Details */}
|
||||
<div className="md:col-span-2">
|
||||
{selectedSession ? (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 sm:p-6">
|
||||
<div className="flex flex-col gap-4 mb-6">
|
||||
<div className="flex-1">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 mb-2">
|
||||
<h2 className="text-xl sm:text-2xl font-semibold text-gray-800 dark:text-gray-100">
|
||||
Session #{selectedSession}
|
||||
</h2>
|
||||
{sessions.find(s => s.id === selectedSession)?.is_active === 1 && (
|
||||
<span className="bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 text-xs sm:text-sm px-2 sm:px-3 py-1 rounded-full font-semibold animate-pulse inline-flex items-center gap-1 w-fit">
|
||||
🟢 Active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm sm:text-base text-gray-600 dark:text-gray-400">
|
||||
{sessions.find(s => s.id === selectedSession)?.created_at &&
|
||||
formatLocalDateTime(sessions.find(s => s.id === selectedSession).created_at)}
|
||||
</p>
|
||||
{sessions.find(s => s.id === selectedSession)?.is_active === 1 && (
|
||||
<p className="text-xs sm:text-sm text-gray-500 dark:text-gray-500 mt-1 italic">
|
||||
Games update automatically
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
{isAuthenticated && sessions.find(s => s.id === selectedSession)?.is_active === 1 && (
|
||||
<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 sm:text-base w-full sm:w-auto"
|
||||
>
|
||||
Import Chat Log
|
||||
</button>
|
||||
)}
|
||||
{isAuthenticated && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleExport(selectedSession, '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 sm:text-base w-full sm:w-auto"
|
||||
>
|
||||
Export as Text
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleExport(selectedSession, '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 sm:text-base w-full sm:w-auto"
|
||||
>
|
||||
Export as JSON
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Session List */}
|
||||
{sessions.length === 0 ? (
|
||||
<p className="text-gray-500 dark:text-gray-400">No sessions found</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{sessions.map(session => {
|
||||
const isActive = session.is_active === 1;
|
||||
const isSelected = selectedIds.has(session.id);
|
||||
const isSundaySession = isSunday(session.created_at);
|
||||
const isArchived = session.archived === 1;
|
||||
const canSelect = selectMode && !isActive;
|
||||
|
||||
{showChatImport && (
|
||||
<ChatImportPanel
|
||||
sessionId={selectedSession}
|
||||
onClose={() => setShowChatImport(false)}
|
||||
onImportComplete={() => {
|
||||
loadSessionGames(selectedSession);
|
||||
setShowChatImport(false);
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
className={`border rounded-lg transition ${
|
||||
selectMode && isActive
|
||||
? 'opacity-50 cursor-not-allowed border-gray-300 dark:border-gray-600'
|
||||
: isSelected
|
||||
? 'border-indigo-500 bg-indigo-50 dark:bg-indigo-900/20 cursor-pointer'
|
||||
: 'border-gray-300 dark:border-gray-600 hover:border-indigo-400 dark:hover:border-indigo-500 cursor-pointer'
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (longPressFired.current) {
|
||||
longPressFired.current = false;
|
||||
return;
|
||||
}
|
||||
if (selectMode) {
|
||||
if (!isActive) toggleSelection(session.id);
|
||||
} else {
|
||||
navigate(`/history/${session.id}`);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{sessionGames.length === 0 ? (
|
||||
<p className="text-gray-500 dark:text-gray-400">No games played in this session</p>
|
||||
) : (
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-4 text-gray-700 dark:text-gray-200">
|
||||
Games Played ({sessionGames.length})
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{sessionGames.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">
|
||||
{sessionGames.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
|
||||
onPointerDown={() => {
|
||||
if (!isActive) handlePointerDown(session.id);
|
||||
}}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerLeave={handlePointerUp}
|
||||
>
|
||||
<div className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
{selectMode && (
|
||||
<div className={`mt-0.5 w-5 h-5 flex-shrink-0 rounded border-2 flex items-center justify-center ${
|
||||
isActive
|
||||
? 'border-gray-300 dark:border-gray-600 bg-gray-100 dark:bg-gray-700'
|
||||
: isSelected
|
||||
? 'border-indigo-600 bg-indigo-600'
|
||||
: 'border-gray-300 dark:border-gray-600'
|
||||
}`}>
|
||||
{isSelected && (
|
||||
<span className="text-white text-xs font-bold">✓</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-gray-800 dark:text-gray-100">
|
||||
Session #{session.id}
|
||||
</span>
|
||||
{isActive && (
|
||||
<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>
|
||||
)}
|
||||
{isSundaySession && (
|
||||
<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>
|
||||
)}
|
||||
{isArchived && (filter === 'all' || filter === 'archived') && (
|
||||
<span className="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 text-xs px-2 py-0.5 rounded">
|
||||
Archived
|
||||
</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="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 className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{formatLocalDate(session.created_at)}
|
||||
{isSundaySession && (
|
||||
<span className="text-gray-400 dark:text-gray-500"> · Sunday</span>
|
||||
)}
|
||||
</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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!selectMode && isAuthenticated && isActive && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Multi-select Action Bar */}
|
||||
{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 className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 flex items-center justify-center h-64">
|
||||
<p className="text-gray-500 dark:text-gray-400 text-lg">Select a session to view details</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* End Session Modal */}
|
||||
{closingSession && (
|
||||
<EndSessionModal
|
||||
sessionId={closingSession}
|
||||
sessionGames={closingSession === selectedSession ? sessionGames : []}
|
||||
onClose={() => setClosingSession(null)}
|
||||
onConfirm={handleCloseSession}
|
||||
onShowChatImport={() => {
|
||||
setShowChatImport(true);
|
||||
if (closingSession !== selectedSession) {
|
||||
loadSessionGames(closingSession);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{deletingSession && (
|
||||
{/* 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 Session?</h2>
|
||||
<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">
|
||||
Are you sure you want to delete Session #{deletingSession}?
|
||||
This will permanently delete all games and chat logs associated with this session. This action cannot be undone.
|
||||
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={() => handleDeleteSession(deletingSession)}
|
||||
onClick={() => handleBulkAction('delete')}
|
||||
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={() => setDeletingSession(null)}
|
||||
onClick={() => setShowBulkDeleteConfirm(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
|
||||
@@ -426,44 +374,13 @@ function History() {
|
||||
);
|
||||
}
|
||||
|
||||
function EndSessionModal({ sessionId, sessionGames, onClose, onConfirm, onShowChatImport }) {
|
||||
function EndSessionModal({ sessionId, onClose, onConfirm }) {
|
||||
const [notes, setNotes] = useState('');
|
||||
|
||||
// Check if any games have been voted on (popularity != 0)
|
||||
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>
|
||||
|
||||
{/* Popularity Warning */}
|
||||
{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)
|
||||
@@ -475,7 +392,6 @@ function EndSessionModal({ sessionId, sessionGames, onClose, onConfirm, onShowCh
|
||||
placeholder="Add any notes about this session..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => onConfirm(sessionId, notes)}
|
||||
@@ -495,183 +411,4 @@ function EndSessionModal({ sessionId, sessionGames, onClose, onConfirm, onShowCh
|
||||
);
|
||||
}
|
||||
|
||||
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-gray-50 dark:bg-gray-700/50 border border-gray-300 dark:border-gray-600 rounded-lg p-6 mb-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.
|
||||
<br />
|
||||
<span className="text-xs italic">
|
||||
Note: Popularity is cumulative - votes are added to each game's all-time totals.
|
||||
</span>
|
||||
</p>
|
||||
|
||||
{/* File Upload */}
|
||||
<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-800 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
|
||||
{result.duplicatesSkipped > 0 && (
|
||||
<span className="block mt-1 text-xs italic opacity-75">
|
||||
({result.duplicatesSkipped} duplicate{result.duplicatesSkipped !== 1 ? 's' : ''} skipped)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{result.votesByGame && Object.keys(result.votesByGame).length > 0 && (
|
||||
<div className="mt-2 text-sm text-green-700 dark:text-green-300">
|
||||
<p className="font-semibold">Votes by game:</p>
|
||||
<ul className="list-disc list-inside">
|
||||
{Object.values(result.votesByGame).map((vote, i) => (
|
||||
<li key={i}>
|
||||
{vote.title}: +{vote.upvotes} / -{vote.downvotes}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-xs mt-2 italic opacity-80">
|
||||
Note: Popularity is cumulative across all sessions. If a game is played multiple times, votes apply to the game itself.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Debug Info */}
|
||||
{result.debug && (
|
||||
<details className="mt-4">
|
||||
<summary className="cursor-pointer font-semibold text-green-800 dark:text-green-200">
|
||||
Debug Info (click to expand)
|
||||
</summary>
|
||||
<div className="mt-2 text-xs text-green-700 dark:text-green-300 space-y-2">
|
||||
{/* Session Timeline */}
|
||||
<div>
|
||||
<p className="font-semibold">Session Timeline:</p>
|
||||
<ul className="list-disc list-inside ml-2">
|
||||
{result.debug.sessionGamesTimeline?.map((game, i) => (
|
||||
<li key={i}>
|
||||
{game.title} - {new Date(game.played_at).toLocaleString()}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Vote Matches */}
|
||||
{result.debug.voteMatches && result.debug.voteMatches.length > 0 && (
|
||||
<div>
|
||||
<p className="font-semibold">Vote Matches ({result.debug.voteMatches.length}):</p>
|
||||
<ul className="list-disc list-inside ml-2 max-h-48 overflow-y-auto">
|
||||
{result.debug.voteMatches.map((match, i) => (
|
||||
<li key={i}>
|
||||
{match.username}: {match.vote} at {new Date(match.timestamp).toLocaleString()} → matched to "{match.matched_game}" (played at {new Date(match.game_played_at).toLocaleString()})
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</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 History;
|
||||
|
||||
|
||||
671
frontend/src/pages/SessionDetail.jsx
Normal file
671
frontend/src/pages/SessionDetail.jsx
Normal file
@@ -0,0 +1,671 @@
|
||||
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, isSunday } 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 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`);
|
||||
}
|
||||
};
|
||||
|
||||
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">
|
||||
<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.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>
|
||||
)}
|
||||
|
||||
<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>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<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={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>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
{showChatImport && (
|
||||
<div className="mb-6">
|
||||
<ChatImportPanel
|
||||
sessionId={id}
|
||||
onClose={() => setShowChatImport(false)}
|
||||
onImportComplete={() => {
|
||||
loadGames();
|
||||
setShowChatImport(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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>
|
||||
|
||||
{closingSession && (
|
||||
<EndSessionModal
|
||||
sessionId={parseInt(id)}
|
||||
sessionGames={games}
|
||||
onClose={() => setClosingSession(false)}
|
||||
onConfirm={handleCloseSession}
|
||||
onShowChatImport={() => {
|
||||
setShowChatImport(true);
|
||||
setClosingSession(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{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;
|
||||
@@ -47,3 +47,12 @@ export function formatLocalDateTime(sqliteTimestamp) {
|
||||
return parseUTCTimestamp(sqliteTimestamp).toLocaleString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,6 @@ export default {
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
plugins: [require('@tailwindcss/typography')],
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ describe('GET /api/sessions (regression)', () => {
|
||||
cleanDb();
|
||||
});
|
||||
|
||||
test('GET /api/sessions/:id returns session object', async () => {
|
||||
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}`);
|
||||
@@ -17,9 +17,11 @@ describe('GET /api/sessions (regression)', () => {
|
||||
expect.objectContaining({
|
||||
id: session.id,
|
||||
is_active: 1,
|
||||
notes: 'Test session',
|
||||
has_notes: true,
|
||||
notes_preview: 'Test session',
|
||||
})
|
||||
);
|
||||
expect(res.body.notes).toBeUndefined();
|
||||
expect(res.body).toHaveProperty('games_played');
|
||||
});
|
||||
|
||||
|
||||
304
tests/api/session-archive.test.js
Normal file
304
tests/api/session-archive.test.js
Normal file
@@ -0,0 +1,304 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
259
tests/api/session-notes.test.js
Normal file
259
tests/api/session-notes.test.js
Normal file
@@ -0,0 +1,259 @@
|
||||
const request = require('supertest');
|
||||
const { app } = require('../../backend/server');
|
||||
const { cleanDb, getAuthHeader, seedSession } = require('../helpers/test-utils');
|
||||
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('...');
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user