diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4f7bee5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +node_modules +**/node_modules +.git +tmp +data +*.log +npm-debug.log* +.DS_Store +coverage +.nyc_output diff --git a/Dockerfile b/Dockerfile index 415e43a..35d0d9d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # Dockerfile # Stage 1: Build the React application -FROM node:18-alpine AS build +FROM node:22-alpine AS build LABEL stage="build-local-testing" @@ -48,4 +48,4 @@ COPY nginx-docker.conf /etc/nginx/conf.d/default.conf EXPOSE 80 # Start Nginx when the container launches -CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file +CMD ["nginx", "-g", "daemon off;"] diff --git a/TODO.md b/TODO.md index ea91014..529c687 100644 --- a/TODO.md +++ b/TODO.md @@ -10,17 +10,8 @@ Backlog of bugs + long-term items. Milestones live in REWORK_PLAN.md. not sure good way to do this - -### FEAT: player display fade transitions for inactive state -- Inactive is DM-triggered via Mark Inactive. -- Player display should fade inactive participant out, then remove from display list. -- Reactivating should fade participant in. -- DM display keeps inactive participant visible. -- Dead state does not imply inactive/disabled. -- Dying/stable must not fade out or leave layout holes. -- Dead can keep skull/Dead label; create some good visual cues, no removal - but a cool transition to death would be nice. - - +### npm install warnings cleanup pass +lots of updates @@ -77,25 +68,6 @@ not sure good way to do this - Ambiguous label. May expand work based on what NPC means here (ally? monster? display-only? skip in turn order?). Clarify intent before UX changes. -### FEAT: first-class undo/redo UI buttons (B --- do now) -- Toolbar buttons ↶/↷ in AdminView header, not buried in /logs. -- Undo = revert latest non-undone log. Redo = re-apply latest undone. -- Uses current 2-write undo (non-tx). Race safety = log refactor later. -- Disabled when stack empty. Keyboard shortcuts (cmd+z / cmd+shift+z). - -### FEAT-LOG: unified log refactor (was FEAT-2 + M6, batched) -- Single event schema, one source of truth: - `{ ts, type, payload, undo_payload, undone, encounterId, - snapshot:{ round, currentTurnParticipantId, turnOrderIds, activeIds } }` -- Common format consumed by: UI log view, download/copy export, - replay-combat, analyze-turns. One shape, four consumers. -- Transactional undo: server endpoint `POST /api/undo/:eventId`. Single - SQLite tx applies undo_payload + flips `undone`. Replaces fragile 2-write - (log update + encounter update as separate calls). -- Download/copy: exports event stream as JSON for offline analysis. -- replay-combat + analyze-turns rewritten to emit/consume same event shape. -- Migration: keep old log entries readable; new format for new writes. - ### quality of life fix: 2. UI says "Campaign Characters", field is players --- naming mismatch (separate concern, flag for later) @@ -107,133 +79,3 @@ not sure good way to do this ## FEAT - clarify what end encounter does and what initiatives reset means ## Done (history) - -### FEAT: first-class undo/redo UI buttons (DONE) -- ↶/↷ pills in InitiativeControls, always visible when encounter open. -- Undo = latest non-undone log (per encounter). Redo = latest undone. -- encounterPath added to all 14 log contexts (filter key). -- redo:patch (forward) added to undoData. Real redo replays forward state. -- Disabled when stack empty. Tooltip shows target action. -- Uses current 2-write undo (non-tx). Race safety = FEAT-LOG refactor. - -### Architecture: 1-list turn order model (slot, never sort) -- Single source: turnOrderIds === participants.map(id). No re-sort after - startEncounter. nextTurn skips inactive (predicate), inactive stay in slot. -- Drag (reorder) = same-init tie-break only. Cross-init blocked. -- startEncounter sorts ALL participants by init once, then frozen. -- addParticipant/updateParticipant slot by init (slotIndexForInit), preserve - drag order. Display renders participants[] directly (no sort). -- Static guard test errs if `.sort(` reintroduced outside allowlist. -- Design doc: docs/INITIATIVE_ORDERING.md. - -### Single source of truth: combat logic -- All 15 App.js handlers delegate to @ttrpg/shared. ~498 lines inline dupes deleted. -- shared/turn.js = only place turn logic lives. - -### Storage parity (firebase + server adapters) -- Neutral queryConstraints ({__type:'orderBy'|'limit'}) honored by both adapters. -- Shared contract test runs both identically. Memory adapter deleted; factory - throws on unknown mode. -- ws storage mode renamed to server (env var + adapter name). - -### Logging contract -- Every mutating op logs message + undo payload. No-op = null log. -- Structural enforcement: per-op contract test (turn.logging.test.js) + - static source-scan guard (static.no-unlogged.test.js). - -### Custom conditions per campaign (DONE) -- Freeform per-campaign conditions. Add applies to participant + persists to - campaign palette in one step. Badge render uses merged allConditions - (built-ins + custom). toggleCondition accepts any string. Dedup - case-insensitive. maxLength 40. Combat + replay tests prove arbitrary - string ids survive round-trip. - -### Death saves: D&D 5e status model (DONE) -- `status` is source of truth: conscious, dying, stable, dead. -- Characters and NPCs use death saves; monsters skip death saves and become dead/inactive at 0 HP. -- Death-save actions: Success, Fail, Nat1, Nat20, Stabilize. -- Revive: dead → 0 HP, stable/unconscious, active. -- Dead characters/NPCs stay in encounter/initiative until DM removes or marks inactive. -- Player display hides inactive participants; DM display keeps them visible. - -### FEAT-3: initiative first-class entry (DONE) -- Initiative field at add-char, add-monster, edit participant. -- Inline edit wired. Tie-break = drag order. - -### UI feedback: toast + info modal (DONE) -- All 23 native alert() replaced. ToastStack (6s auto-dismiss + manual X) - for transient failures. InfoModal (persistent OK) for validations. - React context provider wraps all 3 App branches. -- Fixed: native alert vanished instantly on browser focus loss. - -### Filter dup chars from add-participant dropdown (DONE) -- Character dropdown excludes chars already in encounter. Prevents - dup-add at source. No more dup alert path needed. - -### Test timeouts (DONE) -- jest.setTimeout(10000) in setupTests.js (CRA blocks config-level timeout). - -### Warning = failure in tests (DONE) -- console.error/warn throw in test env. - -### BUG-1: addParticipant + pause/resume corrupts rotation -- RESOLVED as side effect of BUG-2 fix. - -### BUG-2: addParticipant allows duplicate id -- FIXED (addParticipant throws on dup id). - -### BUG-4: hide-player-HP breaks display view -- FIXED --- mock honors setDoc{merge}, all 5 activeDisplay sites use merge. - -### BUG-5: mid-round addParticipant/revive corrupts rotation -- FIXED --- slot-array + DRY advance core nextActiveAfter. - -### BUG-6: reorderParticipants doesn't update turnOrderIds -- FIXED structurally by 1-list model. - -### BUG-7: reorderParticipants not logged -- FIXED --- returns log:{message, undo}. Handler calls logAction. deathSave, - addParticipants, updateParticipant logging gaps also closed. - -### BUG-8: server adapter has no reconnect -- FIXED --- onclose reconnects + re-subscribes existing paths. - -### BUG-10: deact+reactivate same round double-acts participant -- FIXED --- 1-list model keeps slot position on toggle. Reactivate does not - grant second turn. Test: turn.bug10.test.js. - -### BUG-11: FE Combat.scenario test crashes -- FIXED --- moved to shared/turn.combat.test.js, pure functions, 100 rounds. - -### BUG-12: campaign selection follows activeDisplay -- FIXED. - -### BUG-13: reorderParticipants crossing current pointer = ambiguous -- FIXED --- block cross-pointer reorder during active encounter (both dirs). - Full fix needs actedThisRound tracking. Pragmatic block prevents skip/double. - Pre-combat: free reorder. Test: turn.bug13.test.js. - -### BUG-14: addParticipant init-insertion breaks after drag-reorder -- FIXED --- slotIndexForInit scans current list (post-drag aware). - -### BUG-15: DisplayView re-sorts (drag order not preserved) -- FIXED --- display renders participants[] directly. - -### BUG-16: subscribeCollection hook drops queryConstraints -- FIXED --- neutral builders, both adapters honor orderBy/limit. - -### BUG-17: dead SDK imports in App.js -- FIXED --- trimmed (auth + getFirestore + getStorage remain). - -### BUG-18: stale comments reference deleted memory adapter -- FIXED. - -### FEAT-1: Dead characters/NPCs stay in turn order -- DONE --- character/NPC death does not auto-remove. DM controls inactive/remove. -- Non-NPC monsters still become inactive automatically at death. - -### combat.scenario 100 rounds not turns -- DONE --- loops by actual round-wrap count. - -### feat: add all characters to participants list -- DONE --- addParticipants bulk add wired. diff --git a/docker/Dockerfile b/docker/Dockerfile index 2b75ad1..53b4971 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,34 +1,36 @@ -# docker/Dockerfile — single container: caddy (front) + node (back). +# syntax=docker/dockerfile:1 +# docker/Dockerfile --- single container: caddy (front) + node (back). # Build context = repo root. # ---- build stage: frontend + install backend deps ---- -FROM node:18-alpine AS build +FROM node:22-alpine AS build WORKDIR /app COPY package*.json ./ COPY shared/package.json ./shared/ COPY server/package.json ./server/ -RUN npm install --include-workspace-root +RUN --mount=type=cache,target=/root/.npm \ + npm install --include-workspace-root --omit=dev --prefer-offline +# frontend code (changes often) COPY shared/ ./shared/ -COPY server/ ./server/ COPY src/ ./src/ COPY public/ ./public/ COPY tailwind.config.js postcss.config.js ./ -# better-sqlite3 native build (alpine musl) -RUN cd server && npm rebuild better-sqlite3 - # build frontend (server storage, same-origin /api + /ws via caddy) ARG REACT_APP_TRACKER_APP_ID=ttrpg-initiative-tracker-default ENV REACT_APP_STORAGE=server ENV REACT_APP_TRACKER_APP_ID=$REACT_APP_TRACKER_APP_ID RUN NODE_OPTIONS=--openssl-legacy-provider npm run build -# prune backend dev deps for runtime -RUN npm prune --omit=dev +# server after backend - it's fast. so changing server only doesnt invalidate slow frontend. +COPY server/ ./server/ +# better-sqlite3 native build (alpine musl). Cached if server unchanged. +RUN cd server && npm rebuild better-sqlite3 + # ---- runtime stage: caddy + node ---- -FROM node:18-alpine +FROM node:22-alpine RUN apk add --no-cache caddy WORKDIR /app @@ -38,7 +40,6 @@ COPY --from=build /app/server/node_modules ./server/node_modules COPY --from=build /app/package*.json ./ COPY --from=build /app/shared/package.json ./shared/ COPY --from=build /app/server/package.json ./server/ -COPY shared/ ./shared/ COPY server/ ./server/ # built frontend served by caddy COPY --from=build /app/build /srv diff --git a/docker/restart.sh b/docker/restart.sh new file mode 100755 index 0000000..d9d236d --- /dev/null +++ b/docker/restart.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# scripts/docker-restart.sh — rebuild + restart docker container. +# Usage: ./scripts/docker-restart.sh +set -euo pipefail +cd "$(dirname "$0")/.." + +echo "=== stopping existing container ===" +docker compose -f docker/docker-compose.yml down 2>/dev/null || true + +echo "=== rebuilding image ===" +docker compose -f docker/docker-compose.yml build + +echo "=== starting container ===" +docker compose -f docker/docker-compose.yml up -d + +echo "=== status ===" +docker compose -f docker/docker-compose.yml ps + +echo "" +echo "app: http://localhost:${PORT:-8080}" +echo "logs: docker compose -f docker/docker-compose.yml logs -f" diff --git a/server/index.js b/server/index.js index cb9c8c7..ce81cbc 100644 --- a/server/index.js +++ b/server/index.js @@ -1,6 +1,7 @@ // server/index.js — generic KV document store over HTTP + WebSocket. // firebase mirror: doc-tree model. Thin REST, path-based WS push. // Adapter (src/storage/server.js) = passthrough, no shape translation. +// TEST: cache layer rebuild check. 'use strict'; diff --git a/src/App.js b/src/App.js index 671f20d..b52f429 100644 --- a/src/App.js +++ b/src/App.js @@ -2554,6 +2554,61 @@ function AdminView({ userId }) { // DISPLAY VIEW COMPONENT (Player View) // ============================================================================ +// Player participant card: animate state transitions. +// mount/activate : animate in (opacity+scale+slide) +// deactivate : animate out then signal exit +// alive→dead : transition to death cue (dim+desaturate+skull pulse+red rim) +// conscious/dying/stable: full visible, no fade +function PlayerParticipantCard({ id, isActive, status, onExit, className, children, divRef }) { + // phase: 'hidden' (opacity-0, start state) -> 'visible' (opacity-100) + // disable: 'visible' -> 'exiting' (opacity-0, transition plays) -> onExit + const [phase, setPhase] = useState('hidden'); + const isDead = status === 'dead'; + + useEffect(() => { + if (!isActive) { + // ensure current 'visible' frame painted, THEN flip to exiting so + // browser sees opacity change and plays the fade-out. + const raf = requestAnimationFrame(() => { + setPhase('exiting'); + }); + const t = setTimeout(() => onExit(id), 1000); + return () => { cancelAnimationFrame(raf); clearTimeout(t); }; + } + // enable/reactivate: paint hidden frame first, THEN flip to visible + // so browser sees opacity change and plays the transition. + setPhase('hidden'); + const raf = requestAnimationFrame(() => { + requestAnimationFrame(() => setPhase('visible')); + }); + return () => cancelAnimationFrame(raf); + }, [isActive, id, onExit]); + + // animate: opacity + scale + slide. Visible motion, not just fade. + const fadeClass = phase === 'visible' + ? 'opacity-100 scale-100 translate-y-0' + : 'opacity-0 scale-75 translate-y-4'; + + // death transition: fires once when alive→dead. dim + desaturate + red rim. + const deathClass = isDead + ? 'brightness-50 saturate-0 border-2 border-red-900/70 shadow-red-900/50 shadow-2xl' + : ''; + + return ( +
+ {children} + {isDead && ( +
+ ☠️ +
+ )} +
+ ); +} + function DisplayView() { const { data: activeDisplayData, isLoading: isLoadingActiveDisplay, error: activeDisplayError } = useFirestoreDocument( getPath.activeDisplay() @@ -2566,9 +2621,37 @@ function DisplayView() { const [isPlayerDisplayActive, setIsPlayerDisplayActive] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); const [wakeLockEnabled, setWakeLockEnabled] = useState(false); + const [displayParticipants, setDisplayParticipants] = useState([]); const wakeLockRef = useRef(null); const currentParticipantRef = useRef(null); + // Player display transition state. Active participants render normally. + // Active→inactive: keep prior card, mark __displayActive=false, animate out, + // then PlayerParticipantCard calls handleExit to remove from display list. + // Inactive→active: card reappears with __displayActive=true and fades in. + useEffect(() => { + const source = (activeEncounterData && activeEncounterData.participants) || []; + setDisplayParticipants(prev => { + const prevById = new Map(prev.map(p => [p.id, p])); + const out = []; + for (const p of source) { + const prevP = prevById.get(p.id); + if (p.isActive !== false) { + out.push({ ...p, __displayActive: true }); + } else if (prevP && prevP.__displayActive !== false) { + out.push({ ...p, __displayActive: false }); + } else if (prevP && prevP.__displayActive === false) { + out.push(prevP); + } + } + return out; + }); + }, [activeEncounterData]); + + const handleExit = useCallback((id) => { + setDisplayParticipants(prev => prev.filter(p => p.id !== id)); + }, []); + useEffect(() => { const onFsChange = () => setIsFullscreen(!!document.fullscreenElement); document.addEventListener('fullscreenchange', onFsChange); @@ -2706,13 +2789,9 @@ function DisplayView() { const hidePlayerHp = activeDisplayData?.hidePlayerHp ?? true; const hideNpcHp = activeDisplayData?.hideNpcHp ?? false; - let participantsToRender = []; - if (participants) { - // 1-list model: participants[] IS the display order (DM drag = source of - // truth). Do NOT re-sort by initiative — that diverges from AdminView / - // turnOrderIds after any cross-init drag (BUG-15). - participantsToRender = participants.filter(p => p.isActive !== false); - } + // 1-list model: displayParticipants IS the display order (participants[] order + // plus temporary exiting cards). Do NOT re-sort by initiative. + const participantsToRender = displayParticipants; const displayStyles = campaignBackgroundUrl ? { @@ -2783,10 +2862,14 @@ function DisplayView() { } return ( -

(Inactive)

)} -

+ ); })}
diff --git a/src/__mocks__/firebase/firestore.js b/src/__mocks__/firebase/firestore.js index 9ba0654..e4ceb8a 100644 --- a/src/__mocks__/firebase/firestore.js +++ b/src/__mocks__/firebase/firestore.js @@ -105,9 +105,9 @@ export function onSnapshot(refOrQuery, onSuccess, onError) { return unsub; } -// Apply Firestore-style query constraints (orderBy desc/asc, limit) to mock docs. -// Mirrors real SDK semantics enough for contract tests. Only orderBy + limit -// supported (App's LOG_QUERY uses exactly these). +// Apply Firestore-style query constraints (orderBy desc/asc, limit, where) +// to mock docs. Mirrors real SDK semantics for contract tests. Offset handled +// by adapter (firebase.js slices) — mock never sees it. function applyConstraints(docs, constraints) { let out = [...docs]; for (const c of constraints) { diff --git a/src/tests/DisplayView.characterization.test.js b/src/tests/DisplayView.characterization.test.js index 842b096..c367bca 100644 --- a/src/tests/DisplayView.characterization.test.js +++ b/src/tests/DisplayView.characterization.test.js @@ -94,7 +94,7 @@ describe('DisplayView characterization', () => { expect(screen.getAllByText(/Dead/i).length).toBeGreaterThan(0); }); - test('DisplayView hides inactive participants for all types', async () => { + test('DisplayView hides inactive participants for all types after fade-out', async () => { seedActiveDisplay([ { ...participant('conscious'), id: 'active-pc', name: 'Active PC', isActive: true }, { ...participant('conscious'), id: 'inactive-pc', name: 'Inactive PC', isActive: false }, @@ -103,7 +103,8 @@ describe('DisplayView characterization', () => { render(); await waitFor(() => expect(screen.getByText('Active PC')).toBeInTheDocument()); - expect(screen.queryByText('Inactive PC')).not.toBeInTheDocument(); + // inactive held during exit animation, removed after transition + await waitFor(() => expect(screen.queryByText('Inactive PC')).not.toBeInTheDocument(), { timeout: 1500 }); expect(screen.queryByText('Inactive Monster')).not.toBeInTheDocument(); }); });