Compare commits

..

44 Commits

Author SHA1 Message Date
cottongin
0a59da8ee9 chore: add gitea templates 2026-03-23 12:18:39 -04:00
cottongin
a71ad7ae68 chore: add MIT license 2026-03-23 12:08:21 -04:00
cottongin
850fed5a87 chore: update README.md with new features and changes, version bump. 2026-03-23 12:04:03 -04:00
cottongin
0833cf6167 feat: add Prev/Next pagination bar to session history
Made-with: Cursor
2026-03-23 11:35:42 -04:00
cottongin
bfabf390b4 feat: render sessions grouped by day with styled header bars
Made-with: Cursor
2026-03-23 11:34:41 -04:00
cottongin
ad8efc0fbf feat: add pagination state and offset to session API calls
Made-with: Cursor
2026-03-23 11:32:59 -04:00
cottongin
db369a807e feat: add getLocalDateKey, formatDayHeader, formatTimeOnly date helpers
Made-with: Cursor
2026-03-23 11:31:35 -04:00
cottongin
d49601c54e feat: add offset pagination and X-Prev-Last-Date header to GET /sessions
Made-with: Cursor
2026-03-23 11:30:48 -04:00
cottongin
de1a02b9bb docs: pagination and day grouping implementation plan
Made-with: Cursor
2026-03-23 11:26:37 -04:00
cottongin
07858f973b docs: address spec review feedback (offset validation, header format, Tailwind class, polling)
Made-with: Cursor
2026-03-23 11:18:28 -04:00
cottongin
57ab3cf7ba docs: pagination and day grouping design spec
Made-with: Cursor
2026-03-23 11:16:33 -04:00
cottongin
c25db19008 chore: add superpowers to gitignore 2026-03-23 10:42:48 -04:00
cottongin
85c06ff258 fix: session count label distinguishes visible vs total
Show "X visible (Y total)" when the history list is filtered or limited,
and "X sessions total" only when every session is actually displayed.

Made-with: Cursor
2026-03-23 10:41:38 -04:00
cottongin
3b18034d11 tweak: language for admin presence bar 2026-03-23 10:21:03 -04:00
cottongin
3da97a39ad chore: version bump 2026-03-23 10:03:24 -04:00
cottongin
04f66a32cc feat: docker-compose supports optional ADMIN_CONFIG_PATH
ADMIN_KEY is now optional (falls back handled by load-admins.js).
Added ADMIN_CONFIG_PATH env var and commented volume mount example.

Made-with: Cursor
2026-03-23 10:00:46 -04:00
cottongin
95e7402d81 feat: PresenceBar component shows who is watching each page
Made-with: Cursor
2026-03-23 09:57:31 -04:00
cottongin
f0b614e28a feat: usePresence hook for WebSocket-based page presence
Made-with: Cursor
2026-03-23 09:57:22 -04:00
cottongin
242150d54c feat: WebSocket presence tracking with page_focus and presence_update
Made-with: Cursor
2026-03-23 09:55:56 -04:00
cottongin
a4d74baf51 feat: per-admin localStorage namespacing with migration
Made-with: Cursor
2026-03-23 09:42:50 -04:00
cottongin
9f60c6983d feat: auth route uses named admin lookup, embeds name in JWT
- Login/verify use findAdminByKey; JWT and response include admin name
- Verify returns 403 when token lacks name (legacy tokens)
- Test tokens include name for getAuthToken()
- Set Content-Type on supertest JSON bodies (superagent/mime resolution)

Made-with: Cursor
2026-03-23 09:38:35 -04:00
cottongin
fd72c0d7ee feat: add admin config loader with multi-key support
Made-with: Cursor
2026-03-23 03:46:09 -04:00
cottongin
ac26ac2ac5 Add named admins implementation plan
9 tasks covering config loader, auth changes, frontend identity,
preference namespacing, WebSocket presence, PresenceBar UI, and Docker config.

Made-with: Cursor
2026-03-23 03:41:55 -04:00
cottongin
0e5c66b98f Address spec review feedback for named admins design
Clarify WS auth rejection for stale tokens, enumerate all
migrated localStorage keys, and add theme exception note to overview.

Made-with: Cursor
2026-03-23 03:25:15 -04:00
cottongin
86725b6f40 Add named admins design spec
Covers multi-key admin config, per-admin localStorage preferences,
and real-time presence badges via WebSocket.

Made-with: Cursor
2026-03-23 03:19:03 -04:00
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
cottongin
c756d45e24 fix: guard shard event emissions on both manuallyStopped and gameFinished
Prevent stale events from shards that ended naturally (not via
stopMonitor). handleMessage now gates on gameFinished in addition to
manuallyStopped. handleEntityUpdate properly cleans up on gameFinished
by emitting room.disconnected, removing from activeShards, and calling
disconnect. handleError also removes from activeShards. Probe message
handler and status broadcast bail out when the shard is stopped or the
game has finished.

Made-with: Cursor
2026-03-21 00:07:10 -04:00
41 changed files with 9274 additions and 760 deletions

View File

@@ -0,0 +1,102 @@
name: Bug Report
about: Something is broken or behaving unexpectedly
title: "[Bug] "
labels:
- bug
body:
- type: dropdown
id: area
attributes:
label: Affected Area
description: Which part of the application is affected?
options:
- Game Picker (selection, filters, weighting)
- Game Manager (CRUD, import/export, packs)
- Session Management (create, close, archive, notes)
- History / Session Detail (display, pagination, filters)
- Room Code / Player Count (ecast, monitoring)
- Chat Log Import / Voting
- Webhooks
- Authentication / Admin
- WebSocket / Real-time / Presence
- PWA (install, offline, service worker)
- UI / Styling / Responsiveness
- API (backend endpoint)
- Database / Migrations
- Docker / Deployment
- Other
validations:
required: true
- type: dropdown
id: deploy-method
attributes:
label: Deployment Method
options:
- Docker (docker-compose)
- Local development (npm run dev)
- Other
validations:
required: true
- type: textarea
id: description
attributes:
label: Description
description: Concise summary of what's wrong.
placeholder: "When I do X, Y happens instead of Z."
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to Reproduce
description: Minimal sequence to trigger the bug.
placeholder: |
1. Login as admin
2. Navigate to Picker page
3. Set player count filter to 3
4. Click "Roll the Dice"
5. Observe error
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected Behavior
placeholder: A game matching the filters should be selected.
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual Behavior
placeholder: The page shows a spinner indefinitely and the console logs a 500 error.
validations:
required: true
- type: textarea
id: logs
attributes:
label: Relevant Logs / Errors
description: Paste browser console errors, backend logs (`docker-compose logs backend`), or network responses. Wrap sensitive data.
render: shell
- type: textarea
id: environment
attributes:
label: Environment
description: Browser, OS, Node version, Docker version — whatever is relevant.
placeholder: |
Browser: Firefox 128
OS: macOS 15.3
Docker: 27.x / Compose v2.x
- type: textarea
id: context
attributes:
label: Additional Context
description: Screenshots, related issues, workarounds tried, etc.

View File

@@ -0,0 +1 @@
blank_issues_enabled: true

View File

@@ -0,0 +1,87 @@
name: Feature Request
about: Suggest a new feature or improvement to an existing one
title: "[Feature] "
labels:
- enhancement
body:
- type: dropdown
id: type
attributes:
label: Request Type
options:
- New feature
- Enhancement to existing feature
- UX / UI improvement
- API addition or change
- Developer experience / tooling
validations:
required: true
- type: dropdown
id: area
attributes:
label: Affected Area
description: Which part of the application does this relate to?
multiple: true
options:
- Game Picker
- Game Manager
- Session Management
- History / Session Detail
- Room Code / Player Count
- Chat Log Import / Voting
- Webhooks
- Authentication / Admin
- WebSocket / Real-time / Presence
- PWA
- UI / Styling
- API (backend)
- Database
- Docker / Deployment
- Documentation
- Other
validations:
required: true
- type: textarea
id: problem
attributes:
label: Problem / Motivation
description: What problem does this solve, or what use case does it enable?
placeholder: "During a stream, I need to X but currently have to Y which is slow because Z."
validations:
required: true
- type: textarea
id: solution
attributes:
label: Proposed Solution
description: Describe what you'd like to happen. Be specific about behavior, UI, or API shape if you have ideas.
placeholder: |
Add a "favorites" toggle on each game card in the Manager.
Favorited games appear at the top of the picker pool.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives Considered
description: Other approaches you thought about and why they're less ideal.
- type: dropdown
id: priority
attributes:
label: How important is this to you?
options:
- Nice to have
- Would improve my workflow noticeably
- Blocking a use case I need
validations:
required: true
- type: textarea
id: context
attributes:
label: Additional Context
description: Mockups, screenshots, links to related issues, etc.

4
.gitignore vendored
View File

@@ -39,8 +39,12 @@ Thumbs.db
.local/
.old-chrome-extension/
# Admin config (real keys)
backend/config/admins.json
# Cursor
.cursor/
.superpowers/
chat-summaries/
plan.md

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 cottongin
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

261
README.md
View File

@@ -1,6 +1,6 @@
# Jackbox Party Pack Game Picker
A full-stack web application that helps groups pick games to play from various Jackbox Party Packs. Features include random game selection with filters, session tracking, game management, and popularity scoring through chat log imports.
A full-stack web application that helps groups pick games to play from various Jackbox Party Packs. Features include random game selection with weighted filters, session tracking, game management, popularity scoring through chat log imports and live voting, and Jackbox lobby integration.
## Features
@@ -13,26 +13,37 @@ A full-stack web application that helps groups pick games to play from various J
### Admin Features
- **Game Picker**: Randomly select games with intelligent filters
- Filter by player count, drawing games, game length, and family-friendly status
- Weighted random selection using game and pack favor bias
- Automatic repeat avoidance (prevents same game or alternating pattern)
- Manual game selection option
- Real-time session tracking
- **Game Manager**: Complete CRUD operations for games and packs
- Enable/disable individual games or entire packs
- Set favor bias on games and packs to influence pick weighting
- Import/export games via CSV
- View statistics (play counts, popularity scores)
- View statistics (play counts, upvotes, downvotes, popularity scores)
- Add, edit, and delete games
- **Session Management**: Track gaming sessions over time
- Create and close sessions
- View session history
- Import chat logs to calculate game popularity
- Track which games were played when
- View session history with pagination and filters
- Archive/unarchive sessions
- Add, edit, and delete session notes (hidden from unauthenticated users)
- Export session data
- Bulk session creation
- Per-session game management (add, remove, reorder, set status)
- **Room Code & Player Count**: Live Jackbox lobby integration
- Set room codes on session games
- Automatic player count fetching via Jackbox ecast shard API
- Start/stop player count monitoring
- Live status updates via WebSocket
- **Chat Log Import**: Process chat messages to assess game popularity
- Supports "thisgame++" and "thisgame--" voting
- Automatically matches votes to games based on timestamps
- Updates popularity scores across sessions
- Updates upvote/downvote counts across sessions
- **Live Voting API**: Real-time vote processing from external bots
- Accept live votes via REST API
@@ -50,16 +61,18 @@ A full-stack web application that helps groups pick games to play from various J
### Public Features
- View active session and games currently being played
- Browse session history
- Browse session history (with archived sessions hidden by default)
- Detailed session view with game list and vote breakdowns
- See game statistics and popularity
## Tech Stack
- **Frontend**: React 18 with Vite, Tailwind CSS, React Router
- **Backend**: Node.js with Express
- **Frontend**: React 18 with Vite, Tailwind CSS, React Router 6
- **Backend**: Node.js with Express 4
- **Database**: SQLite with better-sqlite3
- **Real-time**: WebSocket server (`ws`) for presence, subscriptions, and live events
- **Authentication**: JWT-based admin authentication
- **Deployment**: Docker with docker-compose
- **Deployment**: Docker with docker-compose (Node 22 Alpine + nginx Alpine)
## Prerequisites
@@ -78,12 +91,11 @@ A full-stack web application that helps groups pick games to play from various J
Create a `.env` file in the root directory:
```env
PORT=5000
NODE_ENV=production
DB_PATH=/app/data/jackbox.db
JWT_SECRET=your-secret-jwt-key-change-this
ADMIN_KEY=your-admin-key-here
```
`JWT_SECRET` is required. Provide either `ADMIN_KEY` (single admin) or configure named admins (see [Named Admins](#named-admins) below).
3. **Build and start the containers**
```bash
@@ -96,7 +108,7 @@ A full-stack web application that helps groups pick games to play from various J
5. **Login as admin**
- Navigate to the login page
- Enter your `ADMIN_KEY` from the `.env` file
- Enter your `ADMIN_KEY` (or a named admin key) from your configuration
The database will be automatically initialized and populated with games from `games-list.csv` on first run.
@@ -148,10 +160,32 @@ The backend will run on http://localhost:5000
npm run dev
```
The frontend will run on http://localhost:3000 and proxy API requests to the backend.
The frontend will run on http://localhost:3000. Note: the Vite dev proxy is configured to forward `/api` requests to `http://backend:5000` (the Docker Compose service name). For bare-metal local development without Docker, you may need to update the proxy target in `vite.config.js` to `http://localhost:5000`.
## Configuration
### Named Admins
The app supports multiple named admin accounts. Create `backend/config/admins.json` (see `admins.example.json` for the format):
```json
[
{ "name": "Alice", "key": "change-me-alice-key" },
{ "name": "Bob", "key": "change-me-bob-key" }
]
```
Each admin logs in with their unique key. Their name is embedded in the JWT and displayed in the presence bar.
If `admins.json` is not found, the app falls back to the `ADMIN_KEY` environment variable as a single admin named "Admin".
To use a custom path for the admins file, set `ADMIN_CONFIG_PATH` in your environment.
For Docker, uncomment the volume mount in `docker-compose.yml`:
```yaml
- ./backend/config/admins.json:/app/config/admins.json:ro
```
### Branding and Metadata
All app branding, metadata, and PWA configuration is centralized in `frontend/src/config/branding.js`. Edit this file to customize:
@@ -176,41 +210,91 @@ cd frontend
npm run generate-manifest
```
The manifest is automatically generated during the build process, so you don't need to edit it directly.
### Environment Variables
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `JWT_SECRET` | Yes | — | Secret key for signing JWT tokens |
| `ADMIN_KEY` | No | — | Single admin authentication key (fallback when `admins.json` is absent) |
| `ADMIN_CONFIG_PATH` | No | `backend/config/admins.json` | Path to named admins JSON file |
| `PORT` | No | `5000` | Backend server port |
| `NODE_ENV` | No | `development` | Environment (production/development) |
| `DB_PATH` | No | `./data/jackbox.db` | Path to SQLite database file |
| `DEBUG` | No | `false` | Enable debug logging |
## Testing
The project uses Jest for API and integration tests. Tests live in the `tests/` directory at the repository root.
```bash
# Install backend dependencies first
cd backend && npm install && cd ..
# Run tests
npx jest
# Run tests in watch mode
npx jest --watch
```
## Project Structure
```
/
├── backend/
│ ├── routes/ # API route handlers
│ │ ├── auth.js # Authentication endpoints
│ │ ├── games.js # Game CRUD and management
│ │ ├── sessions.js # Session management
│ │ ├── picker.js # Game picker algorithm
│ │ ├── stats.js # Statistics endpoints
│ │ ├── votes.js # Live voting endpoint
│ │ └── webhooks.js # Webhook management
│ ├── middleware/ # Express middleware
│ │ ── auth.js # JWT authentication
├── utils/ # Utility functions
│ └── webhooks.js # Webhook trigger and signature
├── database.js # SQLite database setup
├── bootstrap.js # Database initialization
├── server.js # Express app entry point
│ ├── routes/ # API route handlers
│ │ ├── auth.js # Authentication endpoints
│ │ ├── games.js # Game CRUD, packs, favor bias
│ │ ├── sessions.js # Sessions, archives, notes, room codes, export
│ │ ├── picker.js # Game picker algorithm
│ │ ├── stats.js # Statistics endpoints
│ │ ├── votes.js # Live voting endpoint
│ │ └── webhooks.js # Webhook management
│ ├── middleware/ # Express middleware
│ │ ── auth.js # JWT authentication (required)
│ └── optional-auth.js # JWT authentication (optional, for public routes)
├── config/ # Configuration files
│ ├── admins.example.json # Example named admins config
│ ├── admins.json # Named admins (gitignored)
│ └── load-admins.js # Admin config loader
│ ├── utils/ # Utility modules
│ │ ├── websocket-manager.js # WebSocket server (presence, events)
│ │ ├── ecast-shard-client.js # Jackbox ecast shard integration
│ │ ├── jackbox-api.js # Jackbox API helpers
│ │ ├── notes-preview.js # Session notes preview generation
│ │ └── webhooks.js # Webhook trigger and HMAC signatures
│ ├── database.js # SQLite database setup and migrations
│ ├── bootstrap.js # Database initialization from CSV
│ ├── server.js # Express + WebSocket entry point
│ ├── package.json
│ └── Dockerfile
├── frontend/
│ ├── src/
│ │ ├── pages/ # React page components
│ │ ├── pages/ # React page components
│ │ │ ├── Home.jsx
│ │ │ ├── Login.jsx
│ │ │ ├── Picker.jsx
│ │ │ ├── Manager.jsx
│ │ │ ── History.jsx
│ │ ├── context/ # React context providers
│ │ │ └── AuthContext.jsx
│ │ ├── api/ # API client
│ │ │ ── History.jsx
│ │ │ └── SessionDetail.jsx
│ │ ├── components/ # Reusable UI components
│ │ │ ├── PresenceBar.jsx
│ │ │ ├── Toast.jsx
│ │ │ ├── ThemeToggle.jsx
│ │ │ ├── Logo.jsx
│ │ │ ├── RoomCodeModal.jsx
│ │ │ ├── GamePoolModal.jsx
│ │ │ ├── PopularityBadge.jsx
│ │ │ ├── InstallPrompt.jsx
│ │ │ └── SafariInstallPrompt.jsx
│ │ ├── context/ # React context providers
│ │ │ ├── AuthContext.jsx
│ │ │ └── ThemeContext.jsx
│ │ ├── hooks/ # Custom React hooks
│ │ │ └── usePresence.js
│ │ ├── config/ # Frontend configuration
│ │ │ └── branding.js
│ │ ├── api/ # API client
│ │ │ └── axios.js
│ │ ├── App.jsx
│ │ ├── main.jsx
@@ -219,44 +303,74 @@ The manifest is automatically generated during the build process, so you don't n
│ ├── vite.config.js
│ ├── tailwind.config.js
│ ├── package.json
│ ├── nginx.conf # Nginx config for Docker
│ ├── nginx.conf # Nginx config for Docker (proxy + SPA)
│ └── Dockerfile
├── tests/ # Jest API and integration tests
│ ├── api/ # Route-level tests
│ ├── helpers/ # Test utilities
│ └── jest.setup.js
├── scripts/ # Jackbox lobby inspection utilities
├── docs/ # API documentation, design specs, plans
│ ├── api/ # OpenAPI spec, endpoint docs, guides
│ ├── plans/ # Implementation plans
│ └── archive/ # Archived docs
├── docker-compose.yml
├── games-list.csv # Initial game data
├── jest.config.js
├── games-list.csv # Initial game seed data
└── README.md
```
## API Endpoints
### Health
- `GET /health` - Health check (returns `{ status: "ok" }`)
### Authentication
- `POST /api/auth/login` - Login with admin key
- `POST /api/auth/login` - Login with admin key (returns JWT with admin name)
- `POST /api/auth/verify` - Verify JWT token
### Games
- `GET /api/games` - List all games (with filters)
- `GET /api/games/packs` - List unique pack names
- `GET /api/games/meta/packs` - Get pack list with stats and favor bias
- `GET /api/games/:id` - Get single game
- `POST /api/games` - Create game (admin)
- `PUT /api/games/:id` - Update game (admin)
- `DELETE /api/games/:id` - Delete game (admin)
- `PATCH /api/games/:id/toggle` - Toggle game enabled status (admin)
- `GET /api/games/meta/packs` - Get pack list with stats
- `PATCH /api/games/:id/favor` - Set game favor bias (admin)
- `PATCH /api/games/packs/:name/toggle` - Toggle entire pack (admin)
- `PATCH /api/games/packs/:name/favor` - Set pack favor bias (admin)
- `GET /api/games/export/csv` - Export games to CSV (admin)
- `POST /api/games/import/csv` - Import games from CSV (admin)
### Sessions
- `GET /api/sessions` - List all sessions
- `GET /api/sessions` - List sessions (paginated, filterable, `X-Total-Count` header)
- `GET /api/sessions/active` - Get active session
- `GET /api/sessions/:id` - Get single session
- `GET /api/sessions/:id` - Get single session (notes hidden from unauthenticated users)
- `POST /api/sessions` - Create new session (admin)
- `POST /api/sessions/bulk` - Bulk create sessions (admin)
- `POST /api/sessions/:id/close` - Close session (admin)
- `DELETE /api/sessions/:id` - Delete session (admin)
- `PUT /api/sessions/:id/notes` - Update session notes (admin)
- `DELETE /api/sessions/:id/notes` - Delete session notes (admin)
- `POST /api/sessions/:id/archive` - Archive session (admin)
- `POST /api/sessions/:id/unarchive` - Unarchive session (admin)
- `GET /api/sessions/:id/games` - Get games in session
- `GET /api/sessions/:id/votes` - Get per-game vote breakdown for a session
- `POST /api/sessions/:id/games` - Add game to session (admin)
- `PATCH /api/sessions/:sessionId/games/:gameId/status` - Update game status (admin)
- `DELETE /api/sessions/:sessionId/games/:gameId` - Remove game from session (admin)
- `PATCH /api/sessions/:sessionId/games/:gameId/room-code` - Set room code (admin)
- `PATCH /api/sessions/:sessionId/games/:gameId/player-count` - Update player count (admin)
- `GET /api/sessions/:sessionId/games/:gameId/status-live` - Get live ecast status
- `POST /api/sessions/:sessionId/games/:gameId/start-player-check` - Start player count monitoring (admin)
- `POST /api/sessions/:sessionId/games/:gameId/stop-player-check` - Stop player count monitoring (admin)
- `GET /api/sessions/:id/votes` - Get per-game vote breakdown for a session
- `POST /api/sessions/:id/chat-import` - Import chat log (admin)
- `GET /api/sessions/:id/export` - Export session data (admin)
### Game Picker
- `POST /api/pick` - Pick random game with filters
- `POST /api/pick` - Pick random game with filters and favor bias weighting
### Statistics
- `GET /api/stats` - Get overall statistics
@@ -274,6 +388,9 @@ The manifest is automatically generated during the build process, so you don't n
- `POST /api/webhooks/test/:id` - Test webhook (admin)
- `GET /api/webhooks/:id/logs` - Get webhook logs (admin)
### WebSocket
- `ws://host/api/sessions/live` - Real-time connection for presence, session subscriptions, and live events
## Usage Guide
### Starting a Game Session
@@ -294,9 +411,10 @@ The manifest is automatically generated during the build process, so you don't n
2. Navigate to the Manager page
3. View statistics and pack information
4. Toggle individual games or entire packs on/off
5. Add new games with the "+ Add Game" button
6. Edit or delete existing games
7. Import/Export games via CSV
5. Adjust favor bias to weight certain games or packs in the picker
6. Add new games with the "+ Add Game" button
7. Edit or delete existing games
8. Import/Export games via CSV
### Closing a Session and Importing Chat Logs
@@ -322,6 +440,10 @@ The manifest is automatically generated during the build process, so you don't n
6. Click "Close Session" to finalize
7. Add optional notes about the session
### Archiving Sessions
Closed sessions can be archived to keep the history page clean. Archived sessions are hidden by default but can be viewed by toggling the archive filter. Archiving and unarchiving are available from the session detail page or via bulk actions in the history list.
## Chat Log Format
The chat import feature expects a JSON array where each message has:
@@ -332,12 +454,12 @@ The chat import feature expects a JSON array where each message has:
The system will:
1. Parse each message for vote patterns
2. Match the timestamp to the game being played at that time
3. Update the game's popularity score (+1 for ++, -1 for --)
3. Update the game's upvote/downvote counts (+1 for ++, -1 for --)
4. Store the chat log in the database
## Bot Integration
For integrating external bots (e.g., for live voting and game notifications), see **[BOT_INTEGRATION.md](docs/BOT_INTEGRATION.md)** for detailed documentation including:
For integrating external bots (e.g., for live voting and game notifications), see **[BOT_INTEGRATION.md](docs/archive/BOT_INTEGRATION.md)** for detailed documentation including:
- Live voting API usage
- **WebSocket integration (recommended)** for real-time game notifications
@@ -374,10 +496,10 @@ go run get-player-count.go JYET
- Extracts actual player count from lobby state
These tools retrieve:
- Actual player count (not just max capacity)
- List of current players and their roles (host/player)
- Game state and lobby status
- Audience count
- Actual player count (not just max capacity)
- List of current players and their roles (host/player)
- Game state and lobby status
- Audience count
**Note:** Direct WebSocket connection is not possible without authentication, so the tools join through jackbox.tv to capture the data.
@@ -386,13 +508,17 @@ These tools retrieve:
### games
- id, pack_name, title, min_players, max_players, length_minutes
- has_audience, family_friendly, game_type, secondary_type
- play_count, popularity_score, upvotes, downvotes, enabled, created_at
- play_count, popularity_score, upvotes, downvotes, favor_bias, enabled, created_at
### packs
- id, name (unique), favor_bias, created_at
### sessions
- id, created_at, closed_at, is_active, notes
- id, created_at, closed_at, is_active, notes, archived
### session_games
- id, session_id, game_id, played_at, manually_added, status
- room_code, player_count, player_count_check_status
### chat_logs
- id, session_id, chatter_name, message, timestamp, parsed_vote, message_hash
@@ -422,8 +548,10 @@ The picker uses the following logic:
- Exclude those games from selection pool
- This prevents immediate repeats and alternating patterns
3. **Random selection**:
- Pick a random game from remaining eligible games
3. **Weighted random selection**:
- Games and packs can have a `favor_bias` (+/- integer)
- Positive bias increases pick probability; negative bias decreases it
- Pick a random game from remaining eligible games, weighted by bias
- Return game details and pool size
## Docker Deployment
@@ -446,36 +574,28 @@ docker-compose down
docker-compose up -d --build
```
### Environment Variables
Set these in your `.env` file or docker-compose environment:
- `PORT` - Backend server port (default: 5000)
- `NODE_ENV` - Environment (production/development)
- `DB_PATH` - Path to SQLite database file
- `JWT_SECRET` - Secret key for JWT tokens
- `ADMIN_KEY` - Admin authentication key
### Data Persistence
The SQLite database is stored in `backend/data/` and is persisted via Docker volumes. To backup your data, copy the `backend/data/` directory.
The SQLite database is stored in a named Docker volume (`jackbox-data`) mapped to `/app/data` in the backend container. To backup your data, use `docker cp` or bind-mount a host directory.
## Troubleshooting
### Database not initializing
- Ensure `games-list.csv` is in the root directory
- Check backend logs: `docker-compose logs backend`
- Manually delete `backend/data/jackbox.db` to force re-initialization
- Manually delete the database to force re-initialization
### Can't login as admin
- Verify your `ADMIN_KEY` environment variable is set
- Verify your `ADMIN_KEY` or `admins.json` is configured correctly
- Check that the `.env` file is loaded correctly
- If using named admins, ensure `admins.json` has valid JSON with unique names and keys
- Try restarting the backend service
### Frontend can't reach backend
- Verify both containers are running: `docker-compose ps`
- Check network connectivity: `docker-compose logs frontend`
- Ensure nginx.conf proxy settings are correct
- For local dev, confirm the Vite proxy target matches your backend URL
### Games not showing up
- Check if games are enabled in the Manager
@@ -489,4 +609,3 @@ MIT
## Contributing
Feel free to submit issues and pull requests!

View File

@@ -0,0 +1,4 @@
[
{ "name": "Alice", "key": "change-me-alice-key" },
{ "name": "Bob", "key": "change-me-bob-key" }
]

View File

@@ -0,0 +1,55 @@
const fs = require('fs');
const path = require('path');
const DEFAULT_CONFIG_PATH = path.join(__dirname, 'admins.json');
function loadAdmins() {
const configPath = process.env.ADMIN_CONFIG_PATH || DEFAULT_CONFIG_PATH;
if (fs.existsSync(configPath)) {
const raw = fs.readFileSync(configPath, 'utf-8');
const admins = JSON.parse(raw);
if (!Array.isArray(admins) || admins.length === 0) {
throw new Error(`Admin config at ${configPath} must be a non-empty array`);
}
const names = new Set();
const keys = new Set();
for (const admin of admins) {
if (!admin.name || !admin.key) {
throw new Error('Each admin must have a "name" and "key" property');
}
if (names.has(admin.name)) {
throw new Error(`Duplicate admin name: ${admin.name}`);
}
if (keys.has(admin.key)) {
throw new Error(`Duplicate admin key found`);
}
names.add(admin.name);
keys.add(admin.key);
}
console.log(`[Auth] Loaded ${admins.length} admin(s) from ${configPath}`);
return admins;
}
if (process.env.ADMIN_KEY) {
console.log('[Auth] No admins config file found, falling back to ADMIN_KEY env var');
return [{ name: 'Admin', key: process.env.ADMIN_KEY }];
}
throw new Error(
'No admin configuration found. Provide backend/config/admins.json or set ADMIN_KEY env var.'
);
}
const admins = loadAdmins();
function findAdminByKey(key) {
const match = admins.find(a => a.key === key);
return match ? { name: match.name } : null;
}
module.exports = { findAdminByKey, admins };

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
db.exec(`
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

@@ -1,15 +1,10 @@
const express = require('express');
const jwt = require('jsonwebtoken');
const { JWT_SECRET, authenticateToken } = require('../middleware/auth');
const { findAdminByKey } = require('../config/load-admins');
const router = express.Router();
if (!process.env.ADMIN_KEY) {
throw new Error('ADMIN_KEY environment variable is required');
}
const ADMIN_KEY = process.env.ADMIN_KEY;
// Login with admin key
router.post('/login', (req, res) => {
const { key } = req.body;
@@ -17,31 +12,34 @@ router.post('/login', (req, res) => {
return res.status(400).json({ error: 'Admin key is required' });
}
if (key !== ADMIN_KEY) {
const admin = findAdminByKey(key);
if (!admin) {
return res.status(401).json({ error: 'Invalid admin key' });
}
// Generate JWT token
const token = jwt.sign(
{ role: 'admin', timestamp: Date.now() },
{ role: 'admin', name: admin.name, timestamp: Date.now() },
JWT_SECRET,
{ expiresIn: '24h' }
);
res.json({
token,
res.json({
token,
name: admin.name,
message: 'Authentication successful',
expiresIn: '24h'
});
});
// Verify token validity
router.post('/verify', authenticateToken, (req, res) => {
res.json({
valid: true,
user: req.user
if (!req.user.name) {
return res.status(403).json({ error: 'Token missing admin identity, please re-login' });
}
res.json({
valid: true,
user: req.user
});
});
module.exports = router;

View File

@@ -5,6 +5,8 @@ const db = require('../database');
const { triggerWebhook } = require('../utils/webhooks');
const { getWebSocketManager } = require('../utils/websocket-manager');
const { startMonitor, stopMonitor, getMonitorSnapshot } = require('../utils/ecast-shard-client');
const { computeNotesPreview } = require('../utils/notes-preview');
const { optionalAuthenticateToken } = require('../middleware/optional-auth');
const router = express.Router();
@@ -19,17 +21,79 @@ function createMessageHash(username, message, timestamp) {
// Get all sessions
router.get('/', (req, res) => {
try {
const filter = req.query.filter || 'default';
const limitParam = req.query.limit || 'all';
const offsetParam = req.query.offset || '0';
let offset = parseInt(offsetParam, 10);
if (isNaN(offset) || offset < 0) offset = 0;
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}`;
}
}
let offsetClause = '';
if (offset > 0) {
offsetClause = `OFFSET ${offset}`;
}
const sessions = db.prepare(`
SELECT
s.*,
s.id,
s.created_at,
s.closed_at,
s.is_active,
s.archived,
s.notes,
COUNT(sg.id) as games_played
FROM sessions s
LEFT JOIN session_games sg ON s.id = sg.session_id
${whereClause}
GROUP BY s.id
ORDER BY s.created_at DESC
${limitClause}
${offsetClause}
`).all();
res.json(sessions);
const result = sessions.map(({ notes, ...session }) => {
const { has_notes, notes_preview } = computeNotesPreview(notes);
return { ...session, has_notes, notes_preview };
});
const absoluteTotal = db.prepare('SELECT COUNT(*) as total FROM sessions').get();
if (offset > 0) {
const prevRow = db.prepare(`
SELECT s.created_at
FROM sessions s
${whereClause}
ORDER BY s.created_at DESC
LIMIT 1 OFFSET ${offset - 1}
`).get();
if (prevRow) {
res.set('X-Prev-Last-Date', prevRow.created_at);
}
}
res.set('X-Total-Count', String(countRow.total));
res.set('X-Absolute-Total', String(absoluteTotal.total));
res.json(result);
} catch (error) {
res.status(500).json({ error: error.message });
}
@@ -61,7 +125,7 @@ router.get('/active', (req, res) => {
});
// Get single session by ID
router.get('/:id', (req, res) => {
router.get('/:id', optionalAuthenticateToken, (req, res) => {
try {
const session = db.prepare(`
SELECT
@@ -77,7 +141,14 @@ router.get('/:id', (req, res) => {
return res.status(404).json({ error: 'Session not found' });
}
res.json(session);
const { has_notes, notes_preview } = computeNotesPreview(session.notes);
if (req.user) {
res.json({ ...session, has_notes, notes_preview });
} else {
const { notes, ...publicSession } = session;
res.json({ ...publicSession, has_notes, notes_preview });
}
} catch (error) {
res.status(500).json({ error: error.message });
}
@@ -133,6 +204,62 @@ router.post('/', authenticateToken, (req, res) => {
}
});
// Bulk session operations (admin only)
router.post('/bulk', authenticateToken, (req, res) => {
try {
const { action, ids } = req.body;
if (!Array.isArray(ids) || ids.length === 0) {
return res.status(400).json({ error: 'ids must be a non-empty array' });
}
const validActions = ['archive', 'unarchive', 'delete'];
if (!validActions.includes(action)) {
return res.status(400).json({ error: `action must be one of: ${validActions.join(', ')}` });
}
const placeholders = ids.map(() => '?').join(',');
const sessions = db.prepare(
`SELECT id, is_active FROM sessions WHERE id IN (${placeholders})`
).all(...ids);
if (sessions.length !== ids.length) {
const foundIds = sessions.map(s => s.id);
const missingIds = ids.filter(id => !foundIds.includes(id));
return res.status(404).json({ error: 'Some sessions not found', missingIds });
}
if (action === 'archive' || action === 'delete') {
const activeIds = sessions.filter(s => s.is_active === 1).map(s => s.id);
if (activeIds.length > 0) {
return res.status(400).json({
error: `Cannot ${action} active sessions. Close them first.`,
activeIds
});
}
}
const bulkOperation = db.transaction(() => {
if (action === 'archive') {
db.prepare(`UPDATE sessions SET archived = 1 WHERE id IN (${placeholders})`).run(...ids);
} else if (action === 'unarchive') {
db.prepare(`UPDATE sessions SET archived = 0 WHERE id IN (${placeholders})`).run(...ids);
} else if (action === 'delete') {
db.prepare(`DELETE FROM chat_logs WHERE session_id IN (${placeholders})`).run(...ids);
db.prepare(`DELETE FROM live_votes WHERE session_id IN (${placeholders})`).run(...ids);
db.prepare(`DELETE FROM session_games WHERE session_id IN (${placeholders})`).run(...ids);
db.prepare(`DELETE FROM sessions WHERE id IN (${placeholders})`).run(...ids);
}
});
bulkOperation();
res.json({ success: true, affected: ids.length });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Close/finalize session (admin only)
router.post('/:id/close', authenticateToken, (req, res) => {
try {
@@ -227,6 +354,84 @@ router.delete('/:id', authenticateToken, (req, res) => {
}
});
// Update session notes (admin only)
router.put('/:id/notes', authenticateToken, (req, res) => {
try {
const { notes } = req.body;
const session = db.prepare('SELECT id FROM sessions WHERE id = ?').get(req.params.id);
if (!session) {
return res.status(404).json({ error: 'Session not found' });
}
db.prepare('UPDATE sessions SET notes = ? WHERE id = ?').run(notes, req.params.id);
const updated = db.prepare(`
SELECT s.*, COUNT(sg.id) as games_played
FROM sessions s
LEFT JOIN session_games sg ON s.id = sg.session_id
WHERE s.id = ?
GROUP BY s.id
`).get(req.params.id);
res.json(updated);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Clear session notes (admin only)
router.delete('/:id/notes', authenticateToken, (req, res) => {
try {
const session = db.prepare('SELECT id FROM sessions WHERE id = ?').get(req.params.id);
if (!session) {
return res.status(404).json({ error: 'Session not found' });
}
db.prepare('UPDATE sessions SET notes = NULL WHERE id = ?').run(req.params.id);
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Archive a session (admin only)
router.post('/:id/archive', authenticateToken, (req, res) => {
try {
const session = db.prepare('SELECT id, is_active FROM sessions WHERE id = ?').get(req.params.id);
if (!session) {
return res.status(404).json({ error: 'Session not found' });
}
if (session.is_active === 1) {
return res.status(400).json({ error: 'Cannot archive an active session. Please close it first.' });
}
db.prepare('UPDATE sessions SET archived = 1 WHERE id = ?').run(req.params.id);
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Unarchive a session (admin only)
router.post('/:id/unarchive', authenticateToken, (req, res) => {
try {
const session = db.prepare('SELECT id FROM sessions WHERE id = ?').get(req.params.id);
if (!session) {
return res.status(404).json({ error: 'Session not found' });
}
db.prepare('UPDATE sessions SET archived = 0 WHERE id = ?').run(req.params.id);
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Get games played in a session
router.get('/:id/games', (req, res) => {
try {

View File

@@ -114,7 +114,9 @@ class EcastShardClient {
this.stopStatusBroadcast();
this.statusInterval = setInterval(() => {
this._refreshPlayerCount().finally(() => {
this.onEvent('game.status', this.getSnapshot());
if (!this.manuallyStopped && !this.gameFinished) {
this.onEvent('game.status', this.getSnapshot());
}
});
}, 20000);
}
@@ -146,7 +148,7 @@ class EcastShardClient {
const timeout = setTimeout(() => done(probe), 10000);
probe.on('message', (data) => {
if (welcomed) return;
if (welcomed || this.manuallyStopped) { clearTimeout(timeout); done(probe); return; }
try {
const msg = JSON.parse(data.toString());
if (msg.opcode === 'client/welcome') {
@@ -155,15 +157,17 @@ class EcastShardClient {
if (playerCount > this.playerCount || playerNames.length !== this.playerNames.length) {
this.playerCount = playerCount;
this.playerNames = playerNames;
this.onEvent('lobby.player-joined', {
sessionId: this.sessionId,
gameId: this.gameId,
roomCode: this.roomCode,
playerName: playerNames[playerNames.length - 1] || '',
playerCount,
players: [...playerNames],
maxPlayers: this.maxPlayers,
});
if (!this.manuallyStopped) {
this.onEvent('lobby.player-joined', {
sessionId: this.sessionId,
gameId: this.gameId,
roomCode: this.roomCode,
playerName: playerNames[playerNames.length - 1] || '',
playerCount,
players: [...playerNames],
maxPlayers: this.maxPlayers,
});
}
} else if (playerCount !== this.playerCount) {
this.playerCount = playerCount;
this.playerNames = playerNames;
@@ -196,6 +200,7 @@ class EcastShardClient {
}
handleMessage(message) {
if (this.manuallyStopped || this.gameFinished) return;
switch (message.opcode) {
case 'client/welcome':
this.handleWelcome(message.result);
@@ -301,6 +306,15 @@ class EcastShardClient {
playerCount: this.playerCount,
players: [...this.playerNames],
});
this.onEvent('room.disconnected', {
sessionId: this.sessionId,
gameId: this.gameId,
roomCode: this.roomCode,
reason: 'room_closed',
finalPlayerCount: this.playerCount,
});
activeShards.delete(`${this.sessionId}-${this.gameId}`);
this.disconnect();
}
}
}
@@ -367,6 +381,7 @@ class EcastShardClient {
reason: 'room_closed',
finalPlayerCount: this.playerCount,
});
activeShards.delete(`${this.sessionId}-${this.gameId}`);
this.disconnect();
}
}

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

View File

@@ -32,6 +32,8 @@ class WebSocketManager {
const clientInfo = {
authenticated: false,
userId: null,
adminName: null,
currentPage: null,
subscribedSessions: new Set(),
lastPing: Date.now()
};
@@ -96,6 +98,15 @@ class WebSocketManager {
clientInfo.lastPing = Date.now();
this.send(ws, { type: 'pong' });
break;
case 'page_focus':
if (!clientInfo.authenticated) {
this.sendError(ws, 'Not authenticated');
return;
}
clientInfo.currentPage = message.page || null;
this.broadcastPresence();
break;
default:
this.sendError(ws, `Unknown message type: ${message.type}`);
@@ -117,7 +128,13 @@ class WebSocketManager {
if (clientInfo) {
clientInfo.authenticated = true;
clientInfo.userId = decoded.role; // 'admin' for now
clientInfo.userId = decoded.role;
clientInfo.adminName = decoded.name || null;
if (!decoded.name) {
this.sendError(ws, 'Token missing admin identity, please re-login', 'auth_error');
return;
}
this.send(ws, {
type: 'auth_success',
@@ -283,9 +300,31 @@ class WebSocketManager {
this.clients.delete(ws);
console.log('[WebSocket] Client disconnected and cleaned up');
this.broadcastPresence();
}
}
broadcastPresence() {
const admins = [];
this.clients.forEach((info) => {
if (info.authenticated && info.adminName && info.currentPage) {
admins.push({ name: info.adminName, page: info.currentPage });
}
});
const message = {
type: 'presence_update',
timestamp: new Date().toISOString(),
admins
};
this.clients.forEach((info, ws) => {
if (info.authenticated && ws.readyState === ws.OPEN) {
this.send(ws, message);
}
});
}
/**
* Start heartbeat to detect dead connections
*/

View File

@@ -10,11 +10,13 @@ services:
- NODE_ENV=production
- DB_PATH=/app/data/jackbox.db
- JWT_SECRET=${JWT_SECRET:?JWT_SECRET is required}
- ADMIN_KEY=${ADMIN_KEY:?ADMIN_KEY is required}
- ADMIN_KEY=${ADMIN_KEY:-}
- ADMIN_CONFIG_PATH=${ADMIN_CONFIG_PATH:-}
- DEBUG=false
volumes:
- jackbox-data:/app/data
- ./games-list.csv:/app/games-list.csv:ro
# - ./backend/config/admins.json:/app/config/admins.json:ro
ports:
- "5000:5000"
networks:

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,611 @@
# Pagination & Day Grouping Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add offset-based pagination and day-grouped session rendering to the History page.
**Architecture:** Backend adds `offset` param and `X-Prev-Last-Date` header to `GET /sessions`. Frontend adds page state, groups sessions by local date at render time with styled day headers, and renders a Prev/Next pagination bar.
**Tech Stack:** Node.js/Express/better-sqlite3 (backend), React/Tailwind CSS (frontend), Jest/supertest (tests)
---
### Task 1: Backend — Add `offset` param and `X-Prev-Last-Date` header
**Files:**
- Modify: `backend/routes/sessions.js:22-76` (the `GET /` handler)
- Test: `tests/api/session-archive.test.js` (add new tests to the existing `GET /api/sessions` describe block)
- [ ] **Step 1: Write failing tests for `offset` and `X-Prev-Last-Date`**
Add these tests at the end of the `GET /api/sessions — filter and limit` describe block in `tests/api/session-archive.test.js`:
```javascript
test('offset skips the first N sessions', async () => {
for (let i = 0; i < 5; i++) {
seedSession({ is_active: 0, notes: null });
}
const allRes = await request(app).get('/api/sessions?filter=all&limit=all');
const offsetRes = await request(app).get('/api/sessions?filter=all&limit=2&offset=2');
expect(offsetRes.status).toBe(200);
expect(offsetRes.body).toHaveLength(2);
expect(offsetRes.body[0].id).toBe(allRes.body[2].id);
expect(offsetRes.body[1].id).toBe(allRes.body[3].id);
});
test('offset defaults to 0 when not provided', async () => {
for (let i = 0; i < 3; i++) {
seedSession({ is_active: 0, notes: null });
}
const res = await request(app).get('/api/sessions?filter=all&limit=2');
expect(res.status).toBe(200);
expect(res.body).toHaveLength(2);
});
test('negative offset is clamped to 0', async () => {
seedSession({ is_active: 0, notes: null });
const res = await request(app).get('/api/sessions?filter=all&offset=-5');
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
});
test('non-numeric offset is clamped to 0', async () => {
seedSession({ is_active: 0, notes: null });
const res = await request(app).get('/api/sessions?filter=all&offset=abc');
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
});
test('offset past end returns empty array', async () => {
seedSession({ is_active: 0, notes: null });
const res = await request(app).get('/api/sessions?filter=all&limit=5&offset=100');
expect(res.status).toBe(200);
expect(res.body).toHaveLength(0);
expect(res.headers['x-total-count']).toBe('1');
});
test('X-Prev-Last-Date header is set with correct value when offset > 0', async () => {
for (let i = 0; i < 5; i++) {
seedSession({ is_active: 0, notes: null });
}
const allRes = await request(app).get('/api/sessions?filter=all&limit=all');
const res = await request(app).get('/api/sessions?filter=all&limit=2&offset=2');
expect(res.headers['x-prev-last-date']).toBe(allRes.body[1].created_at);
});
test('X-Prev-Last-Date header is absent when offset is 0', async () => {
seedSession({ is_active: 0, notes: null });
const res = await request(app).get('/api/sessions?filter=all&limit=2');
expect(res.headers['x-prev-last-date']).toBeUndefined();
});
test('X-Total-Count is unaffected by offset', 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&offset=6');
expect(res.headers['x-total-count']).toBe('10');
expect(res.body).toHaveLength(3);
});
test('offset works with filter=default', async () => {
for (let i = 0; i < 5; i++) {
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=default&limit=2&offset=2');
expect(res.status).toBe(200);
expect(res.body).toHaveLength(2);
expect(res.headers['x-total-count']).toBe('5');
res.body.forEach(s => expect(s.archived).toBe(0));
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `npx jest tests/api/session-archive.test.js --no-coverage --forceExit`
Expected: 9 new tests FAIL (offset is not yet implemented)
- [ ] **Step 3: Implement offset and X-Prev-Last-Date in the GET handler**
In `backend/routes/sessions.js`, modify the `router.get('/')` handler (lines 22-76). After parsing `limitParam` (line 25), add offset parsing:
```javascript
const offsetParam = req.query.offset || '0';
let offset = parseInt(offsetParam, 10);
if (isNaN(offset) || offset < 0) offset = 0;
```
After the `limitClause` block (line 46), build the offset clause:
```javascript
let offsetClause = '';
if (offset > 0) {
offsetClause = `OFFSET ${offset}`;
}
```
Update the sessions query (line 62) to include `${offsetClause}` after `${limitClause}`:
```sql
ORDER BY s.created_at DESC
${limitClause}
${offsetClause}
```
Before `res.set('X-Total-Count', ...)`, add the `X-Prev-Last-Date` logic:
```javascript
if (offset > 0) {
const prevRow = db.prepare(`
SELECT s.created_at
FROM sessions s
${whereClause}
ORDER BY s.created_at DESC
LIMIT 1 OFFSET ${offset - 1}
`).get();
if (prevRow) {
res.set('X-Prev-Last-Date', prevRow.created_at);
}
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `npx jest tests/api/session-archive.test.js --no-coverage --forceExit`
Expected: ALL tests pass (24 existing + 9 new = 33)
- [ ] **Step 5: Commit**
```bash
git add backend/routes/sessions.js tests/api/session-archive.test.js
git commit -m "feat: add offset pagination and X-Prev-Last-Date header to GET /sessions"
```
---
### Task 2: Frontend — Date utility helpers
**Files:**
- Modify: `frontend/src/utils/dateUtils.js`
- [ ] **Step 1: Add `getLocalDateKey` and `formatDayHeader` helpers**
Append to `frontend/src/utils/dateUtils.js`:
```javascript
/**
* Get a locale-independent date key for grouping sessions by local calendar day
* @param {string} sqliteTimestamp
* @returns {string} - e.g., "2026-03-23"
*/
export function getLocalDateKey(sqliteTimestamp) {
const d = parseUTCTimestamp(sqliteTimestamp);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
/**
* Format a SQLite timestamp as a day header string (e.g., "Sunday, Mar 23, 2026")
* @param {string} sqliteTimestamp
* @returns {string}
*/
export function formatDayHeader(sqliteTimestamp) {
const d = parseUTCTimestamp(sqliteTimestamp);
return d.toLocaleDateString(undefined, {
weekday: 'long',
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
/**
* Format a SQLite timestamp as a time-only string (e.g., "7:30 PM")
* @param {string} sqliteTimestamp
* @returns {string}
*/
export function formatTimeOnly(sqliteTimestamp) {
const d = parseUTCTimestamp(sqliteTimestamp);
return d.toLocaleTimeString(undefined, {
hour: 'numeric',
minute: '2-digit',
});
}
```
- [ ] **Step 2: Verify frontend builds**
Run: `cd frontend && npm run build`
Expected: Build succeeds with no errors
- [ ] **Step 3: Commit**
```bash
git add frontend/src/utils/dateUtils.js
git commit -m "feat: add getLocalDateKey, formatDayHeader, formatTimeOnly date helpers"
```
---
### Task 3: Frontend — Pagination state and API integration
**Files:**
- Modify: `frontend/src/pages/History.jsx:14-75` (state declarations and `loadSessions`)
- [ ] **Step 1: Add page state and update loadSessions**
In `History.jsx`, add state after line 17 (`absoluteTotal`):
```javascript
const [page, setPage] = useState(1);
const [prevLastDate, setPrevLastDate] = useState(null);
```
Update `loadSessions` (the `api.get` call around line 32) to pass `offset`:
```javascript
const limitNum = limit === 'all' ? null : parseInt(limit, 10);
const offset = limitNum ? (page - 1) * limitNum : 0;
const response = await api.get('/sessions', {
params: { filter, limit, offset: offset || undefined }
});
```
After setting `absoluteTotal`, add:
```javascript
setPrevLastDate(response.headers['x-prev-last-date'] || null);
```
Add `page` to the `useCallback` dependency array for `loadSessions`.
- [ ] **Step 2: Add page reset logic**
Update `handleFilterChange` and `handleLimitChange` to reset page:
```javascript
const handleFilterChange = (newFilter) => {
setFilter(newFilter);
localStorage.setItem(prefixKey(adminName, 'history-filter'), newFilter);
setSelectedIds(new Set());
setPage(1);
};
const handleLimitChange = (newLimit) => {
setLimit(newLimit);
localStorage.setItem(prefixKey(adminName, 'history-show-limit'), newLimit);
setSelectedIds(new Set());
setPage(1);
};
```
Add auto-reset when page becomes empty. Place this check **after** all state updates (`setSessions`, `setTotalCount`, `setAbsoluteTotal`, `setPrevLastDate`) to avoid stale state:
```javascript
setSessions(response.data);
setTotalCount(parseInt(response.headers['x-total-count'] || '0', 10));
setAbsoluteTotal(parseInt(response.headers['x-absolute-total'] || '0', 10));
setPrevLastDate(response.headers['x-prev-last-date'] || null);
if (response.data.length === 0 && offset > 0) {
setPage(1);
}
```
Also add `setPage(1)` to `exitSelectMode`:
```javascript
const exitSelectMode = () => {
setSelectMode(false);
setSelectedIds(new Set());
setShowBulkDeleteConfirm(false);
setPage(1);
};
```
And in the select mode toggle button's `onClick` (where `setSelectMode(true)` is called), add `setPage(1)` after it. Similarly in `handlePointerDown` where `setSelectMode(true)` is called, add `setPage(1)` after it.
- [ ] **Step 3: Verify frontend builds**
Run: `cd frontend && npm run build`
Expected: Build succeeds
- [ ] **Step 4: Commit**
```bash
git add frontend/src/pages/History.jsx
git commit -m "feat: add pagination state and offset to session API calls"
```
---
### Task 4: Frontend — Day grouping rendering
**Files:**
- Modify: `frontend/src/pages/History.jsx:1-8` (imports) and `208-316` (session list rendering)
- [ ] **Step 1: Update imports**
Replace the `dateUtils` import at line 6:
```javascript
import { formatDayHeader, formatTimeOnly, getLocalDateKey, isSunday } from '../utils/dateUtils';
```
(Remove `formatLocalDate` since session cards will now show time-only under day headers.)
- [ ] **Step 2: Add grouping logic and render day headers**
Replace the session list rendering section (`{sessions.map(session => { ... })}`) with day-grouped rendering. The grouping is computed at render time using `useMemo`:
Add before the `return` statement (above `if (loading)`):
```javascript
const groupedSessions = useMemo(() => {
const groups = [];
let currentKey = null;
sessions.forEach(session => {
const dateKey = getLocalDateKey(session.created_at);
if (dateKey !== currentKey) {
currentKey = dateKey;
groups.push({ dateKey, sessions: [session] });
} else {
groups[groups.length - 1].sessions.push(session);
}
});
return groups;
}, [sessions]);
```
Add `useMemo` to the React import at line 1.
Replace the `{sessions.map(session => { ... })}` block inside `<div className="space-y-2">` with:
```jsx
{groupedSessions.map((group, groupIdx) => {
const isSundayGroup = isSunday(group.sessions[0].created_at);
const isContinued = groupIdx === 0 && page > 1 && prevLastDate &&
getLocalDateKey(prevLastDate) === group.dateKey;
return (
<div key={group.dateKey}>
{/* Day header bar */}
<div className="bg-gray-100 dark:bg-[#1e2a3a] rounded-md px-3.5 py-2 mb-2 flex justify-between items-center border-l-[3px] border-indigo-500">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-indigo-700 dark:text-indigo-300">
{formatDayHeader(group.sessions[0].created_at)}
</span>
{isContinued && (
<span className="text-xs text-gray-400 dark:text-gray-500 italic">(continued)</span>
)}
</div>
{!isContinued && (
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500 dark:text-gray-400">
{group.sessions.length} session{group.sessions.length !== 1 ? 's' : ''}
</span>
{isSundayGroup && (
<span className="text-xs font-semibold text-amber-700 dark:text-amber-300">🎲 Game Night</span>
)}
</div>
)}
</div>
{/* Session cards under this day */}
<div className="ml-3 space-y-1.5 mb-4">
{group.sessions.map(session => {
const isActive = session.is_active === 1;
const isSelected = selectedIds.has(session.id);
const isArchived = session.archived === 1;
const canSelect = selectMode && !isActive;
return (
<div
key={session.id}
className={`border rounded-lg transition ${
selectMode && isActive
? 'opacity-50 cursor-not-allowed border-gray-300 dark:border-gray-600'
: isSelected
? 'border-indigo-500 bg-indigo-50 dark:bg-indigo-900/20 cursor-pointer'
: 'border-gray-300 dark:border-gray-600 hover:border-indigo-400 dark:hover:border-indigo-500 cursor-pointer'
}`}
onClick={() => {
if (longPressFired.current) {
longPressFired.current = false;
return;
}
if (selectMode) {
if (!isActive) toggleSelection(session.id);
} else {
navigate(`/history/${session.id}`);
}
}}
onPointerDown={() => {
if (!isActive) handlePointerDown(session.id);
}}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
>
<div className="p-4">
<div className="flex items-start gap-3">
{selectMode && (
<div className={`mt-0.5 w-5 h-5 flex-shrink-0 rounded border-2 flex items-center justify-center ${
isActive
? 'border-gray-300 dark:border-gray-600 bg-gray-100 dark:bg-gray-700'
: isSelected
? 'border-indigo-600 bg-indigo-600'
: 'border-gray-300 dark:border-gray-600'
}`}>
{isSelected && (
<span className="text-white text-xs font-bold"></span>
)}
</div>
)}
<div className="flex-1 min-w-0">
<div className="flex justify-between items-center mb-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-gray-800 dark:text-gray-100">
Session #{session.id}
</span>
{isActive && (
<span className="bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 text-xs px-2 py-0.5 rounded">
Active
</span>
)}
{isArchived && (filter === 'all' || filter === 'archived') && (
<span className="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 text-xs px-2 py-0.5 rounded">
Archived
</span>
)}
</div>
<span className="text-sm text-gray-500 dark:text-gray-400">
{session.games_played} game{session.games_played !== 1 ? 's' : ''}
</span>
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{formatTimeOnly(session.created_at)}
</div>
{session.has_notes && session.notes_preview && (
<div className="mt-2 text-sm text-indigo-400 dark:text-indigo-300 bg-indigo-50 dark:bg-indigo-900/20 px-3 py-2 rounded border-l-2 border-indigo-500">
{session.notes_preview}
</div>
)}
</div>
</div>
</div>
{!selectMode && isAuthenticated && isActive && (
<div className="px-4 pb-4 pt-0">
<button
onClick={(e) => {
e.stopPropagation();
setClosingSession(session.id);
}}
className="w-full bg-orange-600 dark:bg-orange-700 text-white px-4 py-2 rounded text-sm hover:bg-orange-700 dark:hover:bg-orange-800 transition"
>
End Session
</button>
</div>
)}
</div>
);
})}
</div>
</div>
);
})}
```
Each session card inside the group keeps its existing structure (selection, badges, notes preview, etc.) but:
- The date line changes from `formatLocalDate(session.created_at)` to `formatTimeOnly(session.created_at)`
- Remove the per-card `isSundaySession` badge (`🎲 Game Night` span) and the `· Sunday` text — these are now on the day header
- Remove the `isSundaySession` const from inside the card map — it's computed per-group instead
- [ ] **Step 3: Verify frontend builds**
Run: `cd frontend && npm run build`
Expected: Build succeeds
- [ ] **Step 4: Commit**
```bash
git add frontend/src/pages/History.jsx
git commit -m "feat: render sessions grouped by day with styled header bars"
```
---
### Task 5: Frontend — Pagination bar
**Files:**
- Modify: `frontend/src/pages/History.jsx` (add pagination bar below session list, above multi-select action bar)
- [ ] **Step 1: Add pagination bar JSX**
After the closing `</div>` of `<div className="space-y-2">` (the session list) and before the multi-select action bar `{selectMode && selectedIds.size > 0 && (`, add:
```jsx
{/* Pagination bar */}
{limit !== 'all' && (() => {
const limitNum = parseInt(limit, 10);
const totalPages = Math.ceil(totalCount / limitNum);
if (totalPages <= 1) return null;
return (
<div className="flex justify-center items-center gap-4 py-3 mt-3 border-t border-gray-200 dark:border-gray-700">
<button
onClick={() => { setPage(p => p - 1); setSelectedIds(new Set()); }}
disabled={page <= 1}
className={`px-4 py-1.5 rounded-md text-sm font-medium transition ${
page <= 1
? 'bg-gray-600 dark:bg-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed'
: 'bg-indigo-600 text-white hover:bg-indigo-700'
}`}
>
Prev
</button>
<span className="text-sm text-gray-500 dark:text-gray-400">
Page {page} of {totalPages}
</span>
<button
onClick={() => { setPage(p => p + 1); setSelectedIds(new Set()); }}
disabled={page >= totalPages}
className={`px-4 py-1.5 rounded-md text-sm font-medium transition ${
page >= totalPages
? 'bg-gray-600 dark:bg-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed'
: 'bg-indigo-600 text-white hover:bg-indigo-700'
}`}
>
Next
</button>
</div>
);
})()}
```
- [ ] **Step 2: Verify frontend builds**
Run: `cd frontend && npm run build`
Expected: Build succeeds
- [ ] **Step 3: Commit**
```bash
git add frontend/src/pages/History.jsx
git commit -m "feat: add Prev/Next pagination bar to session history"
```
---
### Task 6: Final verification
**Files:** None (verification only)
- [ ] **Step 1: Run full backend test suite**
Run: `npx jest --no-coverage --forceExit`
Expected: All tests pass (147 existing + 9 new = 156)
- [ ] **Step 2: Verify frontend build**
Run: `cd frontend && npm run build`
Expected: Clean build, no warnings
- [ ] **Step 3: Final commit if any cleanup needed**

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,161 @@
# Named Admins Design
**Date:** 2026-03-23
**Status:** Approved
## Overview
Replace the single shared `ADMIN_KEY` with named admin accounts, each with their own key. Per-admin preferences are stored in namespaced `localStorage` (except theme, which stays shared — see Edge Cases). A real-time "who is watching" presence bar shows which admins are on the same page.
## Requirements
1. **Named admins with multiple keys** — each admin has a name and a unique key, defined in a server-side config file.
2. **Per-admin preferences** — UI preferences (saved filter view, show limit, etc.) are linked to the admin who set them via namespaced `localStorage`.
3. **Presence badge** — a "who is watching" card in the page header shows which admins are viewing the same page. The current admin sees "me" for themselves, full names for others.
## Approach
Identity-in-JWT with a config file. No new database tables. Preferences stay in `localStorage` (per-browser, which is desirable). Presence piggybacks on the existing WebSocket infrastructure. Falls back to the old `ADMIN_KEY` env var for backward compatibility.
---
## Section 1: Admin Configuration
### Config file
- **`backend/config/admins.example.json`** — committed to repo, shows the expected shape:
```json
[
{ "name": "Alice", "key": "change-me-alice-key" },
{ "name": "Bob", "key": "change-me-bob-key" }
]
```
- **`backend/config/admins.json`** — gitignored, contains real keys.
### Loader module
**New file: `backend/config/load-admins.js`**
Startup behavior:
1. Read path from `ADMIN_CONFIG_PATH` env var, or default to `backend/config/admins.json`.
2. If the file exists: parse and validate (must be an array of `{ name, key }`, no duplicate names or keys).
3. If the file does not exist: fall back to `ADMIN_KEY` env var → `[{ name: "Admin", key: ADMIN_KEY }]`.
4. If neither exists: throw at startup (fail-fast, same as current behavior).
Exports:
- `findAdminByKey(key)` — returns `{ name }` or `null`.
---
## Section 2: Authentication Changes (Backend)
### Login endpoint (`backend/routes/auth.js`)
- Replace single `ADMIN_KEY` comparison with `findAdminByKey(key)`.
- On match, embed the admin name in the JWT payload: `{ role: 'admin', name: 'Alice', timestamp: Date.now() }`.
- Add `name` to the login response JSON: `{ token, name, message, expiresIn }`.
- Startup guard changes from checking `ADMIN_KEY` to checking that `loadAdmins()` returned at least one admin.
### Verify endpoint
No changes needed. Already returns `{ valid: true, user: req.user }`. The decoded JWT now naturally includes `name`, so the frontend gets it for free.
### Auth middleware (`backend/middleware/auth.js`)
No changes. Already decodes the full JWT payload into `req.user`. Routes that need the admin name can read `req.user.name`.
---
## Section 3: Frontend Auth & Per-Admin Preferences
### AuthContext changes (`frontend/src/context/AuthContext.jsx`)
- Add `adminName` to state (alongside `token`, `isAuthenticated`).
- `login()`: read `name` from the login response JSON. Store in state and `localStorage` as `adminName`.
- `verify()`: read `name` from `response.data.user.name`. Restore `adminName` from that.
- `logout()`: clear `adminName` from state and `localStorage`.
- Expose `adminName` from the context.
### Preference namespacing
A utility function (e.g. in `frontend/src/utils/adminPrefs.js`):
- `prefixKey(adminName, key)` → returns `${adminName}:${key}` when `adminName` is set, or plain `key` as fallback.
- History page changes from `localStorage.getItem('history-filter')` → `localStorage.getItem(prefixKey(adminName, 'history-filter'))`.
### One-time migration
On login, if old un-namespaced keys exist (`history-filter`, `history-show-limit`), copy them to the namespaced versions (`alice:history-filter`, `alice:history-show-limit`) and delete the originals. This preserves existing preferences for the first admin who logs in after the upgrade.
---
## Section 4: Presence System — "Who Is Watching"
### WebSocket changes (`backend/utils/websocket-manager.js`)
- On `auth` message: JWT now carries `name`. Store as `clientInfo.adminName` instead of `clientInfo.userId = decoded.role`.
- New message type `page_focus`: clients send `{ type: 'page_focus', page: '/history' }` on navigation. Stored as `clientInfo.currentPage`.
- On `page_focus` and on `removeClient` (disconnect): broadcast `presence_update` to all authenticated clients.
- Presence payload: `{ type: 'presence_update', admins: [{ name: 'Alice', page: '/history' }, { name: 'Bob', page: '/picker' }] }`.
- Unauthenticated clients do not participate in presence.
### Message flow
1. Admin connects → sends `{ type: 'auth', token }` → server stores `adminName` from JWT.
2. Frontend route change → sends `{ type: 'page_focus', page: '/history' }` → server stores page, broadcasts presence.
3. Admin disconnects → server removes them, broadcasts updated presence.
4. Each client receives the full presence list and filters locally to admins on the same page.
### Frontend hook — `usePresence`
- Connects to the existing WebSocket, sends `page_focus` on route changes (via `useLocation`).
- Listens for `presence_update` messages.
- Filters to admins on the current page.
- Returns `{ viewers }` — array of names, with the current admin's name replaced by `"me"`.
### Frontend component — `PresenceBar`
- Renders below the `<nav>`, above page content (in `App.jsx` route layout area).
- Only renders when the current admin is authenticated **and at least one other admin is on the same page**. A solo admin sees no presence bar — "me" alone is not useful information.
- Small card with caption "who is watching" and a row of name badges (rounded pills).
- Styling: subtle, fits the existing indigo/gray theme.
- Non-admin visitors see nothing.
Example when Alice is on `/history` with Bob:
```
┌─ who is watching ──────────┐
│ [me] [Bob] │
└────────────────────────────┘
```
---
## Files Changed (Summary)
| File | Change |
|------|--------|
| `backend/config/admins.example.json` | New — committed template |
| `backend/config/admins.json` | New — gitignored, real keys |
| `.gitignore` | Add `backend/config/admins.json` |
| `backend/config/load-admins.js` | New — config loader + `findAdminByKey` |
| `backend/routes/auth.js` | Use `findAdminByKey`, embed `name` in JWT and response |
| `backend/utils/websocket-manager.js` | Store `adminName`, handle `page_focus`, broadcast `presence_update` |
| `frontend/src/context/AuthContext.jsx` | Add `adminName` state, persist/restore, expose via context |
| `frontend/src/utils/adminPrefs.js` | New — `prefixKey` utility + migration helper |
| `frontend/src/pages/History.jsx` | Use namespaced localStorage keys |
| `frontend/src/hooks/usePresence.js` | New — WebSocket presence hook |
| `frontend/src/components/PresenceBar.jsx` | New — "who is watching" UI component |
| `frontend/src/App.jsx` | Render `PresenceBar` in layout |
| `docker-compose.yml` | Add `ADMIN_CONFIG_PATH` env var (optional) |
| `tests/jest.setup.js` | Update test admin config |
## Edge Cases
- **Existing JWTs after deploy:** Tokens issued before this change lack `name`. The `verify` endpoint and `AuthContext` should treat a missing `name` as stale and force re-login (call `logout()`). WebSocket auth should also reject tokens missing `name` (send `auth_error`). Since tokens expire in 24h, this is a brief transition.
- **Theme preference:** `ThemeContext` uses the global `theme` localStorage key. Theme stays **shared** (not namespaced per admin) — it's a browser-level display preference, not an admin workflow preference.
## Out of Scope
- UI-based key management (keys are managed server-side only)
- Audit logging / login history (could be added later with a lightweight SQLite table if desired)
- Server-side preference storage (per-browser localStorage is sufficient and desirable)
- Admin key hashing (keys are compared with strict equality, same as current `ADMIN_KEY`)

View File

@@ -0,0 +1,99 @@
# Pagination & Day Grouping — Design Spec
**Date:** 2026-03-23
**Status:** Approved
## Overview
Two enhancements to the session History page:
1. **Pagination** — When "Show X" is set to a value other than "All", add Prev/Next navigation to access older sessions.
2. **Day Grouping** — Group sessions that occurred on the same calendar day under a shared header bar.
## Backend Changes
### `GET /api/sessions` — New `offset` parameter
Add an `offset` query parameter (default `0`) to the existing endpoint. Works with the existing `limit`, `filter`, `X-Total-Count`, and `X-Absolute-Total` headers.
```
GET /api/sessions?filter=default&limit=5&offset=10
```
**Offset validation:** Non-numeric or negative values are clamped to 0. An offset past the end returns an empty array (the pagination bar will show "Page X of Y" and the user can navigate back).
**New response header:**
- `X-Prev-Last-Date` — When `offset > 0`, the raw SQLite `created_at` timestamp (same format as `created_at` in response body, e.g. `"2026-03-23 19:30:00"`) of the session immediately before the current page (the session at position `offset - 1`). Used by the frontend to detect whether the first day group on the current page is a continuation from the previous page. Omitted when `offset` is 0. The frontend parses this with the existing `parseUTCTimestamp` utility.
**SQL changes:** Add `OFFSET` clause to the existing query. For `X-Prev-Last-Date`, run a small secondary query to fetch the `created_at` of the session at position `offset - 1` (same filter/ordering).
No other backend changes required.
## Frontend Changes
### State
- `page` (number, default `1`) — current page number. Derived from offset: `offset = (page - 1) * limit`. **Not persisted** in localStorage; resets to 1 on navigation.
- `prevLastDate` (string|null) — from `X-Prev-Last-Date` header. Used for "(continued)" detection.
### Page math
```
totalPages = Math.ceil(totalCount / limitNum)
offset = (page - 1) * limitNum
```
When `limit` is `"all"`, pagination is disabled (no offset, no pagination bar).
### `loadSessions` changes
Pass `offset` as a query parameter alongside `filter` and `limit`. Read `X-Prev-Last-Date` from response headers.
### Page reset triggers
Changing `filter`, `limit`, or entering/exiting `selectMode` resets `page` to 1.
### Day Grouping (render-time only)
Group the flat session array by local calendar date at render time. For each group:
1. **Day header bar** — Styled with `bg-[#1e2a3a]` (dark) / `bg-gray-100` (light), left border accent (`border-l-[3px] border-indigo-500`), contains:
- Full date: "Sunday, Mar 23, 2026"
- Right side: session count ("2 sessions") and, if Sunday, "🎲 Game Night"
2. **Session cards** — Indented slightly (`ml-3`) beneath their day header. Display **time only** (e.g., "7:30 PM") since the full date is in the header. Remove the per-card "· Sunday" text and per-card "🎲 Game Night" badge since that information is now on the day header.
### "(continued)" detection
When `page > 1` and `prevLastDate` is set:
- Parse the previous page's last session date to a local calendar date string
- If it matches the first day group's date, append an italic "(continued)" tag to that day header (no session count shown for continued groups since the count would be incomplete)
### Pagination bar
Rendered below the session list, above the multi-select action bar (if active). Only shown when `limit !== "all"` and `totalPages > 1`.
Layout: `← Prev` button | "Page X of Y" text | `Next →` button
- Prev button disabled (grayed out) on page 1
- Next button disabled on last page
- Active buttons use indigo (`bg-indigo-600`)
- Disabled buttons use gray (`bg-gray-600/700` with `cursor-not-allowed`)
### Multi-select interaction
Day header bars are not selectable. Only session cards participate in multi-select. Checkboxes render inside the indented card area as they do today. Changing pages clears selected IDs but keeps select mode active.
### Polling behavior
The existing 3-second polling interval refetches the current page (same offset/limit/filter). If sessions are deleted while the user is on a later page and the page becomes empty, the next poll cycle detects `sessions.length === 0 && page > 1` and resets to page 1.
### "Visible" count update
The existing "X visible (Y total)" label continues to work as-is. `sessions.length` reflects the current page's sessions.
## Scope
- No changes to `SessionDetail.jsx`
- No changes to bulk endpoints
- No new dependencies
- The `dateUtils.js` gains a `formatDayHeader` helper (e.g., "Sunday, Mar 23, 2026") and a `getLocalDateKey` helper for grouping
- Existing tests for `GET /sessions` updated to cover `offset` parameter; new tests for `X-Prev-Last-Date` header

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",
"private": true,
"dependencies": {
"@tailwindcss/typography": "^0.5.19",
"axios": "^1.6.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.1",
"axios": "^1.6.2"
"react-markdown": "^10.1.0",
"react-router-dom": "^6.20.1"
},
"devDependencies": {
"@types/react": "^18.2.43",
@@ -26,4 +28,3 @@
"generate-manifest": "node generate-manifest.js"
}
}

View File

@@ -7,11 +7,13 @@ import Logo from './components/Logo';
import ThemeToggle from './components/ThemeToggle';
import InstallPrompt from './components/InstallPrompt';
import SafariInstallPrompt from './components/SafariInstallPrompt';
import PresenceBar from './components/PresenceBar';
import Home from './pages/Home';
import Login from './pages/Login';
import Picker from './pages/Picker';
import Manager from './pages/Manager';
import History from './pages/History';
import SessionDetail from './pages/SessionDetail';
function App() {
const { isAuthenticated, logout } = useAuth();
@@ -155,12 +157,16 @@ function App() {
</div>
</nav>
{/* Admin Presence */}
<PresenceBar />
{/* Main Content */}
<main className="container mx-auto px-4 py-8 flex-grow">
<Routes>
<Route path="/" element={<Home />} />
<Route path="/login" element={<Login />} />
<Route path="/history" element={<History />} />
<Route path="/history/:id" element={<SessionDetail />} />
<Route path="/picker" element={<Picker />} />
<Route path="/manager" element={<Manager />} />
</Routes>

View File

@@ -0,0 +1,41 @@
import React from 'react';
import { usePresence } from '../hooks/usePresence';
import { useAuth } from '../context/AuthContext';
function PresenceBar() {
const { isAuthenticated } = useAuth();
const { viewers } = usePresence();
if (!isAuthenticated) return null;
const otherViewers = viewers.filter(v => v !== 'me');
if (otherViewers.length === 0) return null;
return (
<div className="container mx-auto px-2 sm:px-4 pt-3">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 px-4 py-2">
<div className="flex items-center gap-3">
<span className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider font-medium flex-shrink-0">
who's here?
</span>
<div className="flex flex-wrap gap-1.5">
{viewers.map((name, i) => (
<span
key={`${name}-${i}`}
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
name === 'me'
? 'bg-indigo-100 dark:bg-indigo-900/40 text-indigo-700 dark:text-indigo-300'
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
>
{name}
</span>
))}
</div>
</div>
</div>
</div>
);
}
export default PresenceBar;

View File

@@ -2,7 +2,7 @@ export const branding = {
app: {
name: 'HSO Jackbox Game Picker',
shortName: 'Jackbox Game Picker',
version: '0.6.0 - Fish Tank Edition',
version: '0.6.4 - Fish Tank Edition',
description: 'Spicing up Hyper Spaceout game nights!',
},
meta: {
@@ -11,7 +11,7 @@ export const branding = {
themeColor: '#4F46E5', // Indigo-600
},
links: {
github: '', // Optional: Add your repo URL
github: 'https://code.cottongin.xyz/HyperSpaceOut/jackboxpartypack-gamepicker', // Optional: Add your repo URL
support: 'cottongin@cottongin.xyz', // Optional: Add support/contact URL
}
};

View File

@@ -1,5 +1,6 @@
import React, { createContext, useState, useContext, useEffect } from 'react';
import axios from 'axios';
import { migratePreferences } from '../utils/adminPrefs';
const AuthContext = createContext();
@@ -13,6 +14,7 @@ export const useAuth = () => {
export const AuthProvider = ({ children }) => {
const [token, setToken] = useState(localStorage.getItem('adminToken'));
const [adminName, setAdminName] = useState(localStorage.getItem('adminName'));
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [loading, setLoading] = useState(true);
@@ -20,10 +22,17 @@ export const AuthProvider = ({ children }) => {
const verifyToken = async () => {
if (token) {
try {
await axios.post('/api/auth/verify', {}, {
const response = await axios.post('/api/auth/verify', {}, {
headers: { Authorization: `Bearer ${token}` }
});
setIsAuthenticated(true);
const name = response.data.user?.name;
if (name) {
setAdminName(name);
localStorage.setItem('adminName', name);
} else {
logout();
}
} catch (error) {
console.error('Token verification failed:', error);
logout();
@@ -38,27 +47,33 @@ export const AuthProvider = ({ children }) => {
const login = async (key) => {
try {
const response = await axios.post('/api/auth/login', { key });
const newToken = response.data.token;
const { token: newToken, name } = response.data;
localStorage.setItem('adminToken', newToken);
localStorage.setItem('adminName', name);
setToken(newToken);
setAdminName(name);
setIsAuthenticated(true);
migratePreferences(name);
return { success: true };
} catch (error) {
return {
success: false,
error: error.response?.data?.error || 'Login failed'
return {
success: false,
error: error.response?.data?.error || 'Login failed'
};
}
};
const logout = () => {
localStorage.removeItem('adminToken');
localStorage.removeItem('adminName');
setToken(null);
setAdminName(null);
setIsAuthenticated(false);
};
const value = {
token,
adminName,
isAuthenticated,
loading,
login,
@@ -67,4 +82,3 @@ export const AuthProvider = ({ children }) => {
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};

View File

@@ -0,0 +1,83 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useLocation } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
const WS_RECONNECT_DELAY = 3000;
const PING_INTERVAL = 30000;
export function usePresence() {
const { token, adminName, isAuthenticated } = useAuth();
const location = useLocation();
const [viewers, setViewers] = useState([]);
const wsRef = useRef(null);
const pingRef = useRef(null);
const reconnectRef = useRef(null);
const getWsUrl = useCallback(() => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${protocol}//${window.location.host}/api/sessions/live`;
}, []);
const connect = useCallback(() => {
if (!isAuthenticated || !token) return;
const ws = new WebSocket(getWsUrl());
wsRef.current = ws;
ws.onopen = () => {
ws.send(JSON.stringify({ type: 'auth', token }));
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'auth_success') {
ws.send(JSON.stringify({ type: 'page_focus', page: location.pathname }));
pingRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }));
}
}, PING_INTERVAL);
}
if (msg.type === 'presence_update') {
const currentPage = location.pathname;
const onSamePage = msg.admins
.filter(a => a.page === currentPage)
.map(a => a.name === adminName ? 'me' : a.name);
setViewers(onSamePage);
}
};
ws.onclose = () => {
clearInterval(pingRef.current);
reconnectRef.current = setTimeout(connect, WS_RECONNECT_DELAY);
};
ws.onerror = () => {
ws.close();
};
}, [isAuthenticated, token, adminName, location.pathname, getWsUrl]);
useEffect(() => {
connect();
return () => {
clearTimeout(reconnectRef.current);
clearInterval(pingRef.current);
if (wsRef.current) {
wsRef.current.onclose = null;
wsRef.current.close();
}
};
}, [connect]);
useEffect(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'page_focus', page: location.pathname }));
}
}, [location.pathname]);
return { viewers };
}

File diff suppressed because it is too large Load Diff

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

@@ -0,0 +1,19 @@
const PREF_KEYS = ['history-filter', 'history-show-limit'];
export function prefixKey(adminName, key) {
if (!adminName) return key;
return `${adminName}:${key}`;
}
export function migratePreferences(adminName) {
if (!adminName) return;
for (const key of PREF_KEYS) {
const oldValue = localStorage.getItem(key);
const newKey = prefixKey(adminName, key);
if (oldValue !== null && localStorage.getItem(newKey) === null) {
localStorage.setItem(newKey, oldValue);
localStorage.removeItem(key);
}
}
}

View File

@@ -47,3 +47,53 @@ export function formatLocalDateTime(sqliteTimestamp) {
return parseUTCTimestamp(sqliteTimestamp).toLocaleString();
}
/**
* Check if a SQLite timestamp falls on a Sunday (in local timezone)
* @param {string} sqliteTimestamp
* @returns {boolean}
*/
export function isSunday(sqliteTimestamp) {
return parseUTCTimestamp(sqliteTimestamp).getDay() === 0;
}
/**
* Get a locale-independent date key for grouping sessions by local calendar day
* @param {string} sqliteTimestamp
* @returns {string} - e.g., "2026-03-23"
*/
export function getLocalDateKey(sqliteTimestamp) {
const d = parseUTCTimestamp(sqliteTimestamp);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
/**
* Format a SQLite timestamp as a day header string (e.g., "Sunday, Mar 23, 2026")
* @param {string} sqliteTimestamp
* @returns {string}
*/
export function formatDayHeader(sqliteTimestamp) {
const d = parseUTCTimestamp(sqliteTimestamp);
return d.toLocaleDateString(undefined, {
weekday: 'long',
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
/**
* Format a SQLite timestamp as a time-only string (e.g., "7:30 PM")
* @param {string} sqliteTimestamp
* @returns {string}
*/
export function formatTimeOnly(sqliteTimestamp) {
const d = parseUTCTimestamp(sqliteTimestamp);
return d.toLocaleTimeString(undefined, {
hour: 'numeric',
minute: '2-digit',
});
}

View File

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

View File

@@ -0,0 +1,227 @@
const path = require('path');
const fs = require('fs');
const os = require('os');
describe('load-admins', () => {
const originalEnv = { ...process.env };
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'admins-test-'));
delete process.env.ADMIN_CONFIG_PATH;
delete process.env.ADMIN_KEY;
});
afterEach(() => {
process.env = { ...originalEnv };
fs.rmSync(tmpDir, { recursive: true, force: true });
jest.resetModules();
});
function writeConfig(admins) {
const filePath = path.join(tmpDir, 'admins.json');
fs.writeFileSync(filePath, JSON.stringify(admins));
return filePath;
}
test('loads admins from ADMIN_CONFIG_PATH', () => {
const configPath = writeConfig([
{ name: 'Alice', key: 'key-a' },
{ name: 'Bob', key: 'key-b' }
]);
process.env.ADMIN_CONFIG_PATH = configPath;
const { findAdminByKey } = require('../../backend/config/load-admins');
expect(findAdminByKey('key-a')).toEqual({ name: 'Alice' });
expect(findAdminByKey('key-b')).toEqual({ name: 'Bob' });
expect(findAdminByKey('wrong')).toBeNull();
});
test('falls back to ADMIN_KEY when no config file', () => {
process.env.ADMIN_CONFIG_PATH = path.join(tmpDir, 'nonexistent.json');
process.env.ADMIN_KEY = 'legacy-key';
const { findAdminByKey } = require('../../backend/config/load-admins');
expect(findAdminByKey('legacy-key')).toEqual({ name: 'Admin' });
expect(findAdminByKey('wrong')).toBeNull();
});
test('throws when neither config file nor ADMIN_KEY exists', () => {
process.env.ADMIN_CONFIG_PATH = path.join(tmpDir, 'nonexistent.json');
expect(() => {
require('../../backend/config/load-admins');
}).toThrow();
});
test('rejects duplicate admin names', () => {
const configPath = writeConfig([
{ name: 'Alice', key: 'key-a' },
{ name: 'Alice', key: 'key-b' }
]);
process.env.ADMIN_CONFIG_PATH = configPath;
expect(() => {
require('../../backend/config/load-admins');
}).toThrow(/duplicate/i);
});
test('rejects duplicate keys', () => {
const configPath = writeConfig([
{ name: 'Alice', key: 'same-key' },
{ name: 'Bob', key: 'same-key' }
]);
process.env.ADMIN_CONFIG_PATH = configPath;
expect(() => {
require('../../backend/config/load-admins');
}).toThrow(/duplicate/i);
});
});
const request = require('supertest');
describe('POST /api/auth/login — named admins', () => {
let app;
beforeAll(() => {
process.env.ADMIN_KEY = 'test-admin-key';
process.env.ADMIN_CONFIG_PATH = '/tmp/nonexistent-admins.json';
jest.resetModules();
({ app } = require('../../backend/server'));
});
test('login returns admin name in response', async () => {
const res = await request(app)
.post('/api/auth/login')
.set('Content-Type', 'application/json')
.send({ key: 'test-admin-key' });
expect(res.status).toBe(200);
expect(res.body.name).toBeDefined();
expect(res.body.token).toBeDefined();
});
test('verify returns admin name in user object', async () => {
const loginRes = await request(app)
.post('/api/auth/login')
.set('Content-Type', 'application/json')
.send({ key: 'test-admin-key' });
const res = await request(app)
.post('/api/auth/verify')
.set('Authorization', `Bearer ${loginRes.body.token}`);
expect(res.status).toBe(200);
expect(res.body.user.name).toBeDefined();
});
test('invalid key still returns 401', async () => {
const res = await request(app)
.post('/api/auth/login')
.set('Content-Type', 'application/json')
.send({ key: 'wrong-key' });
expect(res.status).toBe(401);
});
});
const WebSocket = require('ws');
const jwt = require('jsonwebtoken');
const http = require('http');
describe('WebSocket presence', () => {
let server, wsUrl;
beforeAll((done) => {
process.env.ADMIN_KEY = 'test-admin-key';
process.env.ADMIN_CONFIG_PATH = '/tmp/nonexistent-admins.json';
jest.resetModules();
const { app } = require('../../backend/server');
const { WebSocketManager, setWebSocketManager } = require('../../backend/utils/websocket-manager');
server = http.createServer(app);
const wsManager = new WebSocketManager(server);
setWebSocketManager(wsManager);
server.listen(0, () => {
const port = server.address().port;
wsUrl = `ws://localhost:${port}/api/sessions/live`;
done();
});
});
afterAll((done) => {
server.close(done);
});
function makeToken(name) {
return jwt.sign({ role: 'admin', name }, process.env.JWT_SECRET, { expiresIn: '1h' });
}
function connectAndAuth(name) {
return new Promise((resolve) => {
const ws = new WebSocket(wsUrl);
ws.on('open', () => {
ws.send(JSON.stringify({ type: 'auth', token: makeToken(name) }));
});
ws.on('message', (data) => {
const msg = JSON.parse(data.toString());
if (msg.type === 'auth_success') {
resolve(ws);
}
});
});
}
function waitForMessage(ws, type) {
return new Promise((resolve) => {
const handler = (data) => {
const msg = JSON.parse(data.toString());
if (msg.type === type) {
ws.off('message', handler);
resolve(msg);
}
};
ws.on('message', handler);
});
}
test('page_focus triggers presence_update with admin name and page', async () => {
const ws1 = await connectAndAuth('Alice');
const ws2 = await connectAndAuth('Bob');
const presencePromise = waitForMessage(ws2, 'presence_update');
ws1.send(JSON.stringify({ type: 'page_focus', page: '/history' }));
const msg = await presencePromise;
expect(msg.admins).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: 'Alice', page: '/history' })
])
);
ws1.close();
ws2.close();
});
test('disconnect removes admin from presence', async () => {
const ws1 = await connectAndAuth('Alice');
const ws2 = await connectAndAuth('Bob');
ws1.send(JSON.stringify({ type: 'page_focus', page: '/picker' }));
ws2.send(JSON.stringify({ type: 'page_focus', page: '/picker' }));
await new Promise(r => setTimeout(r, 100));
const presencePromise = waitForMessage(ws2, 'presence_update');
ws1.close();
const msg = await presencePromise;
const names = msg.admins.map(a => a.name);
expect(names).not.toContain('Alice');
ws2.close();
});
});

View File

@@ -7,7 +7,7 @@ describe('GET /api/sessions (regression)', () => {
cleanDb();
});
test('GET /api/sessions/:id returns session object', async () => {
test('GET /api/sessions/:id returns session object with preview for unauthenticated', async () => {
const session = seedSession({ is_active: 1, notes: 'Test session' });
const res = await request(app).get(`/api/sessions/${session.id}`);
@@ -17,9 +17,11 @@ describe('GET /api/sessions (regression)', () => {
expect.objectContaining({
id: session.id,
is_active: 1,
notes: 'Test session',
has_notes: true,
notes_preview: 'Test session',
})
);
expect(res.body.notes).toBeUndefined();
expect(res.body).toHaveProperty('games_played');
});

View File

@@ -0,0 +1,405 @@
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);
});
test('offset skips the first N sessions', async () => {
for (let i = 0; i < 5; i++) {
seedSession({ is_active: 0, notes: null });
}
const allRes = await request(app).get('/api/sessions?filter=all&limit=all');
const offsetRes = await request(app).get('/api/sessions?filter=all&limit=2&offset=2');
expect(offsetRes.status).toBe(200);
expect(offsetRes.body).toHaveLength(2);
expect(offsetRes.body[0].id).toBe(allRes.body[2].id);
expect(offsetRes.body[1].id).toBe(allRes.body[3].id);
});
test('offset defaults to 0 when not provided', async () => {
for (let i = 0; i < 3; i++) {
seedSession({ is_active: 0, notes: null });
}
const res = await request(app).get('/api/sessions?filter=all&limit=2');
expect(res.status).toBe(200);
expect(res.body).toHaveLength(2);
});
test('negative offset is clamped to 0', async () => {
seedSession({ is_active: 0, notes: null });
const res = await request(app).get('/api/sessions?filter=all&offset=-5');
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
});
test('non-numeric offset is clamped to 0', async () => {
seedSession({ is_active: 0, notes: null });
const res = await request(app).get('/api/sessions?filter=all&offset=abc');
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
});
test('offset past end returns empty array', async () => {
seedSession({ is_active: 0, notes: null });
const res = await request(app).get('/api/sessions?filter=all&limit=5&offset=100');
expect(res.status).toBe(200);
expect(res.body).toHaveLength(0);
expect(res.headers['x-total-count']).toBe('1');
});
test('X-Prev-Last-Date header is set with correct value when offset > 0', async () => {
for (let i = 0; i < 5; i++) {
seedSession({ is_active: 0, notes: null });
}
const allRes = await request(app).get('/api/sessions?filter=all&limit=all');
const res = await request(app).get('/api/sessions?filter=all&limit=2&offset=2');
expect(res.headers['x-prev-last-date']).toBe(allRes.body[1].created_at);
});
test('X-Prev-Last-Date header is absent when offset is 0', async () => {
seedSession({ is_active: 0, notes: null });
const res = await request(app).get('/api/sessions?filter=all&limit=2');
expect(res.headers['x-prev-last-date']).toBeUndefined();
});
test('X-Total-Count is unaffected by offset', 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&offset=6');
expect(res.headers['x-total-count']).toBe('10');
expect(res.body).toHaveLength(3);
});
test('offset works with filter=default', async () => {
for (let i = 0; i < 5; i++) {
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=default&limit=2&offset=2');
expect(res.status).toBe(200);
expect(res.body).toHaveLength(2);
expect(res.headers['x-total-count']).toBe('5');
res.body.forEach(s => expect(s.archived).toBe(0));
});
});
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())
.set('Content-Type', 'application/json')
.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())
.set('Content-Type', 'application/json')
.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())
.set('Content-Type', 'application/json')
.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())
.set('Content-Type', 'application/json')
.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())
.set('Content-Type', 'application/json')
.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())
.set('Content-Type', 'application/json')
.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())
.set('Content-Type', 'application/json')
.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())
.set('Content-Type', 'application/json')
.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())
.set('Content-Type', 'application/json')
.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')
.set('Content-Type', 'application/json')
.send({ action: 'archive', ids: [1] });
expect(res.status).toBe(401);
});
});

View File

@@ -0,0 +1,264 @@
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())
.set('Content-Type', 'application/json')
.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())
.set('Content-Type', 'application/json')
.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())
.set('Content-Type', 'application/json')
.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`)
.set('Content-Type', 'application/json')
.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')
.set('Content-Type', 'application/json')
.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);
});
});

View File

@@ -2,7 +2,7 @@ const jwt = require('jsonwebtoken');
const db = require('../../backend/database');
function getAuthToken() {
return jwt.sign({ role: 'admin' }, process.env.JWT_SECRET, { expiresIn: '1h' });
return jwt.sign({ role: 'admin', name: 'TestAdmin' }, process.env.JWT_SECRET, { expiresIn: '1h' });
}
function getAuthHeader() {