Compare commits

...

18 Commits

Author SHA1 Message Date
cottongin
512b36da51 fix: long-press select deselection bug, swap sun emoji for dice, bump version to 0.6.2
Made-with: Cursor
2026-03-23 02:48:56 -04:00
cottongin
d613d4e507 feat: rewrite History page with controls bar, multi-select, Sunday badge, and update SessionDetail with archive support
Made-with: Cursor
2026-03-23 02:21:37 -04:00
cottongin
bbd2e51567 feat: add POST /sessions/bulk endpoint for bulk archive, unarchive, and delete
Made-with: Cursor
2026-03-23 02:19:41 -04:00
cottongin
b40176033f feat: add POST archive and unarchive endpoints for sessions
Made-with: Cursor
2026-03-23 01:45:36 -04:00
cottongin
68045afbbc feat: add filter, limit, and X-Total-Count to session list endpoint
Made-with: Cursor
2026-03-23 01:42:51 -04:00
cottongin
35474e5df4 feat: add archived column to sessions table and isSunday helper
Made-with: Cursor
2026-03-23 01:40:07 -04:00
cottongin
4da2c15d56 docs: add session archive, multi-select, Sunday badge, and pagination implementation plan
Made-with: Cursor
2026-03-23 01:36:45 -04:00
cottongin
bff103e26e docs: add session archive, multi-select, Sunday badge, and pagination design spec
Made-with: Cursor
2026-03-23 01:30:01 -04:00
cottongin
a68a617508 feat: simplify History page to session list with notes preview and navigation
Made-with: Cursor
2026-03-23 00:17:32 -04:00
cottongin
0ee97b35c5 feat: add SessionDetail page with notes view/edit and route
Made-with: Cursor
2026-03-23 00:16:45 -04:00
cottongin
7ce5251543 chore: add react-markdown and @tailwindcss/typography dependencies
Made-with: Cursor
2026-03-23 00:14:31 -04:00
cottongin
b9206b6cfe feat: add PUT and DELETE /api/sessions/:id/notes endpoints
Made-with: Cursor
2026-03-23 00:13:09 -04:00
cottongin
ce3347d0b1 feat: gate full notes behind auth on single session endpoint
Made-with: Cursor
2026-03-23 00:09:39 -04:00
cottongin
e9f1b89d44 feat: add has_notes and notes_preview to session list, omit full notes
Made-with: Cursor
2026-03-23 00:06:09 -04:00
cottongin
656d9c3bf6 feat: add notes preview helper with tests
Made-with: Cursor
2026-03-23 00:01:09 -04:00
cottongin
974d7315b9 feat: add optional auth middleware
Made-with: Cursor
2026-03-22 23:58:27 -04:00
cottongin
341257a04d docs: add session notes implementation plan
Made-with: Cursor
2026-03-22 23:49:13 -04:00
cottongin
8c36b399d0 docs: add session notes read/edit/delete design spec
Made-with: Cursor
2026-03-22 23:33:36 -04:00
19 changed files with 6104 additions and 654 deletions

View File

@@ -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 // Session games table
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS session_games ( CREATE TABLE IF NOT EXISTS session_games (

View 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 };

View File

@@ -5,6 +5,8 @@ const db = require('../database');
const { triggerWebhook } = require('../utils/webhooks'); const { triggerWebhook } = require('../utils/webhooks');
const { getWebSocketManager } = require('../utils/websocket-manager'); const { getWebSocketManager } = require('../utils/websocket-manager');
const { startMonitor, stopMonitor, getMonitorSnapshot } = require('../utils/ecast-shard-client'); 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(); const router = express.Router();
@@ -19,17 +21,54 @@ function createMessageHash(username, message, timestamp) {
// Get all sessions // Get all sessions
router.get('/', (req, res) => { router.get('/', (req, res) => {
try { 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(` const sessions = db.prepare(`
SELECT SELECT
s.*, s.id,
s.created_at,
s.closed_at,
s.is_active,
s.archived,
s.notes,
COUNT(sg.id) as games_played COUNT(sg.id) as games_played
FROM sessions s FROM sessions s
LEFT JOIN session_games sg ON s.id = sg.session_id LEFT JOIN session_games sg ON s.id = sg.session_id
${whereClause}
GROUP BY s.id GROUP BY s.id
ORDER BY s.created_at DESC ORDER BY s.created_at DESC
${limitClause}
`).all(); `).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) { } catch (error) {
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });
} }
@@ -61,7 +100,7 @@ router.get('/active', (req, res) => {
}); });
// Get single session by ID // Get single session by ID
router.get('/:id', (req, res) => { router.get('/:id', optionalAuthenticateToken, (req, res) => {
try { try {
const session = db.prepare(` const session = db.prepare(`
SELECT SELECT
@@ -77,7 +116,14 @@ router.get('/:id', (req, res) => {
return res.status(404).json({ error: 'Session not found' }); 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) { } catch (error) {
res.status(500).json({ error: error.message }); 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) // Close/finalize session (admin only)
router.post('/:id/close', authenticateToken, (req, res) => { router.post('/:id/close', authenticateToken, (req, res) => {
try { 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 // Get games played in a session
router.get('/:id/games', (req, res) => { router.get('/:id/games', (req, res) => {
try { try {

View 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 };

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -5,10 +5,12 @@
"type": "module", "type": "module",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@tailwindcss/typography": "^0.5.19",
"axios": "^1.6.2",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-router-dom": "^6.20.1", "react-markdown": "^10.1.0",
"axios": "^1.6.2" "react-router-dom": "^6.20.1"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.2.43", "@types/react": "^18.2.43",
@@ -26,4 +28,3 @@
"generate-manifest": "node generate-manifest.js" "generate-manifest": "node generate-manifest.js"
} }
} }

View File

@@ -12,6 +12,7 @@ import Login from './pages/Login';
import Picker from './pages/Picker'; import Picker from './pages/Picker';
import Manager from './pages/Manager'; import Manager from './pages/Manager';
import History from './pages/History'; import History from './pages/History';
import SessionDetail from './pages/SessionDetail';
function App() { function App() {
const { isAuthenticated, logout } = useAuth(); const { isAuthenticated, logout } = useAuth();
@@ -161,6 +162,7 @@ function App() {
<Route path="/" element={<Home />} /> <Route path="/" element={<Home />} />
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route path="/history" element={<History />} /> <Route path="/history" element={<History />} />
<Route path="/history/:id" element={<SessionDetail />} />
<Route path="/picker" element={<Picker />} /> <Route path="/picker" element={<Picker />} />
<Route path="/manager" element={<Manager />} /> <Route path="/manager" element={<Manager />} />
</Routes> </Routes>

View File

@@ -2,7 +2,7 @@ export const branding = {
app: { app: {
name: 'HSO Jackbox Game Picker', name: 'HSO Jackbox Game Picker',
shortName: '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!', description: 'Spicing up Hyper Spaceout game nights!',
}, },
meta: { meta: {
@@ -11,7 +11,7 @@ export const branding = {
themeColor: '#4F46E5', // Indigo-600 themeColor: '#4F46E5', // Indigo-600
}, },
links: { 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 support: 'cottongin@cottongin.xyz', // Optional: Add support/contact URL
} }
}; };

View File

@@ -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 { useAuth } from '../context/AuthContext';
import { useToast } from '../components/Toast'; import { useToast } from '../components/Toast';
import api from '../api/axios'; import api from '../api/axios';
import { formatLocalDateTime, formatLocalDate, formatLocalTime } from '../utils/dateUtils'; import { formatLocalDate, isSunday } from '../utils/dateUtils';
import PopularityBadge from '../components/PopularityBadge';
function History() { function History() {
const { isAuthenticated } = useAuth(); const { isAuthenticated } = useAuth();
const { error, success } = useToast(); const { error, success } = useToast();
const navigate = useNavigate();
const [sessions, setSessions] = useState([]); const [sessions, setSessions] = useState([]);
const [selectedSession, setSelectedSession] = useState(null);
const [sessionGames, setSessionGames] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [showChatImport, setShowChatImport] = useState(false); const [totalCount, setTotalCount] = useState(0);
const [closingSession, setClosingSession] = useState(null); 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 () => { const loadSessions = useCallback(async () => {
try { try {
const response = await api.get('/sessions'); const response = await api.get('/sessions', {
params: { filter, limit }
});
setSessions(response.data); setSessions(response.data);
setTotalCount(parseInt(response.headers['x-total-count'] || '0', 10));
} catch (err) { } catch (err) {
console.error('Failed to load sessions', err); console.error('Failed to load sessions', err);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, [filter, limit]);
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);
}
}
}, []);
useEffect(() => { useEffect(() => {
loadSessions(); loadSessions();
}, [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(() => { useEffect(() => {
const interval = setInterval(() => { const interval = setInterval(() => {
loadSessions(); loadSessions();
}, 3000); }, 3000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [loadSessions]); }, [loadSessions]);
// Poll for updates on active session games const handleFilterChange = (newFilter) => {
useEffect(() => { setFilter(newFilter);
if (!selectedSession) return; localStorage.setItem('history-filter', newFilter);
setSelectedIds(new Set());
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 loadSessionGames = async (sessionId, silent = false) => { const handleLimitChange = (newLimit) => {
try { setLimit(newLimit);
const response = await api.get(`/sessions/${sessionId}/games`); localStorage.setItem('history-show-limit', newLimit);
// Reverse chronological order (most recent first) - create new array to avoid mutation setSelectedIds(new Set());
setSessionGames([...response.data].reverse());
if (!silent) {
setSelectedSession(sessionId);
}
} catch (err) {
if (!silent) {
console.error('Failed to load session games', err);
}
}
}; };
const handleCloseSession = async (sessionId, notes) => { const handleCloseSession = async (sessionId, notes) => {
@@ -121,28 +67,60 @@ function History() {
await api.post(`/sessions/${sessionId}/close`, { notes }); await api.post(`/sessions/${sessionId}/close`, { notes });
await loadSessions(); await loadSessions();
setClosingSession(null); setClosingSession(null);
if (selectedSession === sessionId) {
// Reload the session details to show updated state
loadSessionGames(sessionId);
}
success('Session ended successfully'); success('Session ended successfully');
} catch (err) { } catch (err) {
error('Failed to close session'); error('Failed to close session');
} }
}; };
const handleDeleteSession = async (sessionId) => { // Multi-select handlers
try { const toggleSelection = (sessionId) => {
await api.delete(`/sessions/${sessionId}`); setSelectedIds(prev => {
await loadSessions(); const next = new Set(prev);
setDeletingSession(null); if (next.has(sessionId)) {
if (selectedSession === sessionId) { next.delete(sessionId);
setSelectedSession(null); } else {
setSessionGames([]); 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) { } catch (err) {
error('Failed to delete session: ' + (err.response?.data?.error || err.message)); error(err.response?.data?.error || `Failed to ${action} sessions`);
} }
}; };
@@ -155,68 +133,156 @@ function History() {
} }
return ( 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> <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="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
<div className="flex justify-between items-center mb-4"> {/* Controls Bar */}
<h2 className="text-2xl font-semibold text-gray-800 dark:text-gray-100">Sessions</h2> <div className="flex flex-wrap justify-between items-center gap-3 mb-4">
{sessions.length > 3 && ( <div className="flex items-center gap-3">
<button <div className="flex items-center gap-1.5">
onClick={() => setShowAllSessions(!showAllSessions)} <span className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Filter:</span>
className="text-sm text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 transition" <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"
> >
{showAllSessions ? 'Show Recent' : `Show All (${sessions.length})`} <option value="default">Sessions</option>
<option value="archived">Archived</option>
<option value="all">All</option>
</select>
</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> </button>
)} )}
</div> </div>
</div>
{/* Session List */}
{sessions.length === 0 ? ( {sessions.length === 0 ? (
<p className="text-gray-500 dark:text-gray-400">No sessions found</p> <p className="text-gray-500 dark:text-gray-400">No sessions found</p>
) : ( ) : (
<div className="space-y-1 max-h-[600px] overflow-y-auto"> <div className="space-y-2">
{(showAllSessions ? sessions : sessions.slice(0, 3)).map(session => ( {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;
return (
<div <div
key={session.id} key={session.id}
className={`border rounded-lg transition ${ className={`border rounded-lg transition ${
selectedSession === session.id selectMode && isActive
? 'border-indigo-500 bg-indigo-50 dark:bg-indigo-900/30' ? 'opacity-50 cursor-not-allowed border-gray-300 dark:border-gray-600'
: 'border-gray-300 dark:border-gray-600 hover:border-indigo-300 dark:hover:border-indigo-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}`);
}
}}
onPointerDown={() => {
if (!isActive) handlePointerDown(session.id);
}}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
> >
{/* Main session info - clickable */} <div className="p-4">
<div <div className="flex items-start gap-3">
onClick={() => loadSessionGames(session.id)} {selectMode && (
className="p-3 cursor-pointer" <div className={`mt-0.5 w-5 h-5 flex-shrink-0 rounded border-2 flex items-center justify-center ${
> isActive
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-2"> ? '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-1 min-w-0">
<div className="flex items-center gap-2 mb-1"> <div className="flex justify-between items-center mb-1">
<span className="font-semibold text-sm text-gray-800 dark:text-gray-100"> <div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-gray-800 dark:text-gray-100">
Session #{session.id} Session #{session.id}
</span> </span>
{session.is_active === 1 && ( {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 flex-shrink-0"> <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 Active
</span> </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> </div>
<div className="flex flex-wrap gap-x-2 text-xs text-gray-500 dark:text-gray-400"> <span className="text-sm text-gray-500 dark:text-gray-400">
<span>{formatLocalDate(session.created_at)}</span> {session.games_played} game{session.games_played !== 1 ? 's' : ''}
<span></span> </span>
<span>{session.games_played} game{session.games_played !== 1 ? 's' : ''}</span>
</div> </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> </div>
</div> </div>
{/* Action buttons for authenticated users */} {!selectMode && isAuthenticated && isActive && (
{isAuthenticated && ( <div className="px-4 pb-4 pt-0">
<div className="px-3 pb-3 pt-0 flex gap-2">
{session.is_active === 1 ? (
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
@@ -226,194 +292,76 @@ function History() {
> >
End Session End Session
</button> </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>
))} );
})}
</div> </div>
)} )}
</div>
</div>
{/* Session Details */} {/* Multi-select Action Bar */}
<div className="md:col-span-2"> {selectMode && selectedIds.size > 0 && (
{selectedSession ? ( <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">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 sm:p-6"> <span className="text-sm font-semibold text-gray-700 dark:text-gray-300">
<div className="flex flex-col gap-4 mb-6"> {selectedIds.size} selected
<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> </span>
)} <div className="flex gap-2">
</div> {filter !== 'archived' && (
<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 <button
onClick={() => setShowChatImport(true)} onClick={() => handleBulkAction('archive')}
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" className="px-4 py-2 bg-indigo-600 text-white rounded-lg text-sm hover:bg-indigo-700 transition"
> >
Import Chat Log Archive
</button> </button>
)} )}
{isAuthenticated && ( {filter !== 'default' && (
<>
<button <button
onClick={() => handleExport(selectedSession, 'txt')} onClick={() => handleBulkAction('unarchive')}
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" className="px-4 py-2 bg-green-600 text-white rounded-lg text-sm hover:bg-green-700 transition"
> >
Export as Text Unarchive
</button> </button>
)}
<button <button
onClick={() => handleExport(selectedSession, 'json')} onClick={() => setShowBulkDeleteConfirm(true)}
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" className="px-4 py-2 bg-red-600 text-white rounded-lg text-sm hover:bg-red-700 transition"
> >
Export as JSON Delete
</button> </button>
</>
)}
</div>
</div>
{showChatImport && (
<ChatImportPanel
sessionId={selectedSession}
onClose={() => setShowChatImport(false)}
onImportComplete={() => {
loadSessionGames(selectedSession);
setShowChatImport(false);
}}
/>
)}
{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
</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>
</div> </div>
)} )}
</div> </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>
{/* End Session Modal */} {/* End Session Modal */}
{closingSession && ( {closingSession && (
<EndSessionModal <EndSessionModal
sessionId={closingSession} sessionId={closingSession}
sessionGames={closingSession === selectedSession ? sessionGames : []}
onClose={() => setClosingSession(null)} onClose={() => setClosingSession(null)}
onConfirm={handleCloseSession} onConfirm={handleCloseSession}
onShowChatImport={() => {
setShowChatImport(true);
if (closingSession !== selectedSession) {
loadSessionGames(closingSession);
}
}}
/> />
)} )}
{/* Delete Confirmation Modal */} {/* Bulk Delete Confirmation Modal */}
{deletingSession && ( {showBulkDeleteConfirm && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"> <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"> <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"> <p className="text-gray-700 dark:text-gray-300 mb-6">
Are you sure you want to delete Session #{deletingSession}? This will permanently delete {selectedIds.size} session{selectedIds.size !== 1 ? 's' : ''} and all associated games and chat logs. This action cannot be undone.
This will permanently delete all games and chat logs associated with this session. This action cannot be undone.
</p> </p>
<div className="flex gap-4"> <div className="flex gap-4">
<button <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" 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 Delete Permanently
</button> </button>
<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" 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 Cancel
@@ -426,44 +374,13 @@ function History() {
); );
} }
function EndSessionModal({ sessionId, sessionGames, onClose, onConfirm, onShowChatImport }) { function EndSessionModal({ sessionId, onClose, onConfirm }) {
const [notes, setNotes] = useState(''); 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 ( return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"> <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"> <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> <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"> <div className="mb-4">
<label className="block text-gray-700 dark:text-gray-300 font-semibold mb-2"> <label className="block text-gray-700 dark:text-gray-300 font-semibold mb-2">
Session Notes (optional) Session Notes (optional)
@@ -475,7 +392,6 @@ function EndSessionModal({ sessionId, sessionGames, onClose, onConfirm, onShowCh
placeholder="Add any notes about this session..." placeholder="Add any notes about this session..."
/> />
</div> </div>
<div className="flex gap-4"> <div className="flex gap-4">
<button <button
onClick={() => onConfirm(sessionId, notes)} 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; export default History;

View 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: [&#123;"username": "...", "message": "...", "timestamp": "..."&#125;]
<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;

View File

@@ -47,3 +47,12 @@ export function formatLocalDateTime(sqliteTimestamp) {
return parseUTCTimestamp(sqliteTimestamp).toLocaleString(); 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;
}

View File

@@ -32,6 +32,6 @@ export default {
} }
}, },
}, },
plugins: [], plugins: [require('@tailwindcss/typography')],
} }

View File

@@ -7,7 +7,7 @@ describe('GET /api/sessions (regression)', () => {
cleanDb(); 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 session = seedSession({ is_active: 1, notes: 'Test session' });
const res = await request(app).get(`/api/sessions/${session.id}`); const res = await request(app).get(`/api/sessions/${session.id}`);
@@ -17,9 +17,11 @@ describe('GET /api/sessions (regression)', () => {
expect.objectContaining({ expect.objectContaining({
id: session.id, id: session.id,
is_active: 1, is_active: 1,
notes: 'Test session', has_notes: true,
notes_preview: 'Test session',
}) })
); );
expect(res.body.notes).toBeUndefined();
expect(res.body).toHaveProperty('games_played'); expect(res.body).toHaveProperty('games_played');
}); });

View 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);
});
});

View 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);
});
});