10 Commits
Author SHA1 Message Date
robert c41a8aa994 Merge branch 'main' into single-source 2026-07-07 11:21:12 -04:00
david raistrick 02344eb3f5 Bump Docker base images to node 22; mock firestore comment cleanup
Both Dockerfiles (root firebase-mode + docker/ caddy) bumped node:18-alpine
to node:22-alpine. better-sqlite3 v12 (from PR #6) requires node 20+; node 18
builds fail with node-gyp/python errors. openssl-legacy-provider flag kept
for react-scripts 5 compat.

.dockerignore: removed .env* exclusion so .env.local COPY works in firebase
image build.

mock firestore applyConstraints comment: offset handled by adapter (firebase.js
slices), mock never sees it — clarified dead-code reasoning.
2026-07-06 23:49:47 -04:00
keen e191aa5ad2 Merge pull request 'Fix white /logs page in firebase mode: emulate offset in adapter' (#6) from white-log-page into main
Reviewed-on: #6
2026-07-06 23:32:37 -04:00
david raistrick c544bc305a Merge branch 'pr-6' into single-source 2026-07-06 23:24:25 -04:00
david raistrick 835a4663f7 Docker build cache: .dockerignore, cache mount, layer reorder, restart script
- .dockerignore: exclude node_modules/.git/tmp/data/logs
- Dockerfile: BuildKit cache mount for npm, --omit=dev at install, drop
  npm prune step
- Reorder layers: shared/src/frontend copy before build, server copy +
  better-sqlite3 rebuild after. Server/shared changes no longer invalidate
  frontend build layer unless shared (FE dep) changes
- Remove duplicate shared COPY in runtime stage (bundled in FE build)
- docker/restart.sh: stop + rebuild + start in one script
2026-07-06 23:22:19 -04:00
robertandClaude Fable 5 995347d255 Fix white /logs page in firebase mode: emulate offset in adapter
The logs page white-screened when deployed against Firestore. LogsView
paginates with the neutral offset() constraint, which the server/sqlite
adapter pushes into SQL — but the firebase adapter's subscribeCollection
had no case for it and passed the raw {__type:'offset'} object through
to the SDK's query(), which throws on non-QueryConstraint values. The
throw fired inside useFirestoreCollection's effect on first render,
unmounting the whole tree. Dev never hit it because dev runs the server
adapter.

Fix: shared toSdkConstraints() translator in the firebase adapter,
used by getCollection and subscribeCollection. The client SDK has no
offset, so emulate it — widen limit to offset+limit and slice the
leading docs off the snapshot (same read cost as the admin SDK's
native offset, which also bills skipped docs). Unknown constraint
types are now dropped instead of passed through, so a future neutral
builder can't reintroduce the crash. This also fixes getCollection
silently skipping offset, which returned page 1 for every page.

Server/sqlite adapter unchanged; offset still runs in SQL. Two new
shared contract tests (offset pagination + offset past end) run
against both adapters and pass: firebase mock 33, live sqlite 40.

Also bump better-sqlite3 ^11.3.0 -> ^12.0.0: v11 fails to compile
against Node 26's V8 (GetPrototype removed), so server tests could
not run at all on Node 26. v12.11.1 builds clean; full server suite
passes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 22:50:29 -04:00
david raistrick 9159755846 Scope player card transitions: ring/shadow fast, fade/transform slow
transition-all duration-1000 on PlayerParticipantCard wrapper slowed current-
turn ring/border animation. Turn pointer visibly laggy at 400ms turn intervals.

Fix: scoped transition properties. opacity/transform/filter/border-color = 1s
for fade/death animations. box-shadow = 300ms for current-turn ring highlight,
so turn pointer snaps between participants while fades stay smooth.
2026-07-06 22:48:10 -04:00
david raistrick dbe018825b Merge remote-tracking branch 'upstream/main' into single-source 2026-07-06 22:39:57 -04:00
david raistrick 3b75ec9b3d Animate player display inactive transitions and death state
Player display now tracks displayParticipants separately from raw encounter
participants so active->inactive can animate out before removal, and inactive->
active can animate in. This preserves 1-list order, keeps DM display behavior
unchanged, and avoids fading dying/stable participants.

PlayerParticipantCard handles transitions:
- active->inactive: scale/slide/fade out, then parent removes from display list
- inactive->active: scale/slide/fade in
- alive->dead: keep visible with dim/desaturate/red-rim death cue + skull pulse
- dying/stable: full visible, no fade/removal

DisplayView inactive characterization updated to wait for exit animation before
asserting removal. TODO item removed.
2026-07-06 22:38:33 -04:00
robert 3646a9cf8e Merge pull request 'D&D 5e death-save state machine, undo/redo fixes, dev bulk delete' (#4) from single-source into main
Reviewed-on: #4
2026-07-06 22:14:05 -04:00
13 changed files with 201 additions and 232 deletions
+10
View File
@@ -0,0 +1,10 @@
node_modules
**/node_modules
.git
tmp
data
*.log
npm-debug.log*
.DS_Store
coverage
.nyc_output
+1 -1
View File
@@ -1,7 +1,7 @@
# Dockerfile # Dockerfile
# Stage 1: Build the React application # Stage 1: Build the React application
FROM node:18-alpine AS build FROM node:22-alpine AS build
LABEL stage="build-local-testing" LABEL stage="build-local-testing"
+2 -160
View File
@@ -10,17 +10,8 @@ Backlog of bugs + long-term items. Milestones live in REWORK_PLAN.md.
not sure good way to do this not sure good way to do this
### npm install warnings cleanup pass
### FEAT: player display fade transitions for inactive state lots of updates
- 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.
@@ -77,25 +68,6 @@ not sure good way to do this
- Ambiguous label. May expand work based on what NPC means here (ally? monster? - Ambiguous label. May expand work based on what NPC means here (ally? monster?
display-only? skip in turn order?). Clarify intent before UX changes. 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) ### 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 ## FEAT - clarify what end encounter does and what initiatives reset means
## Done (history) ## 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.
+12 -11
View File
@@ -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 context = repo root.
# ---- build stage: frontend + install backend deps ---- # ---- build stage: frontend + install backend deps ----
FROM node:18-alpine AS build FROM node:22-alpine AS build
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
COPY shared/package.json ./shared/ COPY shared/package.json ./shared/
COPY server/package.json ./server/ 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 shared/ ./shared/
COPY server/ ./server/
COPY src/ ./src/ COPY src/ ./src/
COPY public/ ./public/ COPY public/ ./public/
COPY tailwind.config.js postcss.config.js ./ 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) # build frontend (server storage, same-origin /api + /ws via caddy)
ARG REACT_APP_TRACKER_APP_ID=ttrpg-initiative-tracker-default ARG REACT_APP_TRACKER_APP_ID=ttrpg-initiative-tracker-default
ENV REACT_APP_STORAGE=server ENV REACT_APP_STORAGE=server
ENV REACT_APP_TRACKER_APP_ID=$REACT_APP_TRACKER_APP_ID ENV REACT_APP_TRACKER_APP_ID=$REACT_APP_TRACKER_APP_ID
RUN NODE_OPTIONS=--openssl-legacy-provider npm run build RUN NODE_OPTIONS=--openssl-legacy-provider npm run build
# prune backend dev deps for runtime # server after backend - it's fast. so changing server only doesnt invalidate slow frontend.
RUN npm prune --omit=dev COPY server/ ./server/
# better-sqlite3 native build (alpine musl). Cached if server unchanged.
RUN cd server && npm rebuild better-sqlite3
# ---- runtime stage: caddy + node ---- # ---- runtime stage: caddy + node ----
FROM node:18-alpine FROM node:22-alpine
RUN apk add --no-cache caddy RUN apk add --no-cache caddy
WORKDIR /app 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/package*.json ./
COPY --from=build /app/shared/package.json ./shared/ COPY --from=build /app/shared/package.json ./shared/
COPY --from=build /app/server/package.json ./server/ COPY --from=build /app/server/package.json ./server/
COPY shared/ ./shared/
COPY server/ ./server/ COPY server/ ./server/
# built frontend served by caddy # built frontend served by caddy
COPY --from=build /app/build /srv COPY --from=build /app/build /srv
+21
View File
@@ -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"
+7 -21
View File
@@ -7151,14 +7151,17 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/better-sqlite3": { "node_modules/better-sqlite3": {
"version": "11.10.0", "version": "12.11.1",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz",
"integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", "integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"bindings": "^1.5.0", "bindings": "^1.5.0",
"prebuild-install": "^7.1.1" "prebuild-install": "^7.1.1"
},
"engines": {
"node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x"
} }
}, },
"node_modules/bfj": { "node_modules/bfj": {
@@ -21316,23 +21319,6 @@
} }
} }
}, },
"node_modules/tailwindcss/node_modules/yaml": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
"license": "ISC",
"optional": true,
"peer": true,
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/tapable": { "node_modules/tapable": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
@@ -23021,7 +23007,7 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@ttrpg/shared": "*", "@ttrpg/shared": "*",
"better-sqlite3": "^11.3.0", "better-sqlite3": "^12.0.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.19.2", "express": "^4.19.2",
"nanoid": "^5.0.7", "nanoid": "^5.0.7",
+1
View File
@@ -1,6 +1,7 @@
// server/index.js — generic KV document store over HTTP + WebSocket. // server/index.js — generic KV document store over HTTP + WebSocket.
// firebase mirror: doc-tree model. Thin REST, path-based WS push. // firebase mirror: doc-tree model. Thin REST, path-based WS push.
// Adapter (src/storage/server.js) = passthrough, no shape translation. // Adapter (src/storage/server.js) = passthrough, no shape translation.
// TEST: cache layer rebuild check.
'use strict'; 'use strict';
+1 -1
View File
@@ -11,7 +11,7 @@
}, },
"dependencies": { "dependencies": {
"@ttrpg/shared": "*", "@ttrpg/shared": "*",
"better-sqlite3": "^11.3.0", "better-sqlite3": "^12.0.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.19.2", "express": "^4.19.2",
"nanoid": "^5.0.7", "nanoid": "^5.0.7",
+94 -11
View File
@@ -2554,6 +2554,61 @@ function AdminView({ userId }) {
// DISPLAY VIEW COMPONENT (Player View) // 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 (
<div
ref={divRef}
className={`[transition:opacity_1s_ease-in-out,transform_1s_ease-in-out,filter_1s_ease-in-out,border-color_1s_ease-in-out,box-shadow_300ms_ease-in-out] ${fadeClass} ${deathClass} ${className}`}
>
{children}
{isDead && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<span className="text-6xl opacity-80 animate-pulse"></span>
</div>
)}
</div>
);
}
function DisplayView() { function DisplayView() {
const { data: activeDisplayData, isLoading: isLoadingActiveDisplay, error: activeDisplayError } = useFirestoreDocument( const { data: activeDisplayData, isLoading: isLoadingActiveDisplay, error: activeDisplayError } = useFirestoreDocument(
getPath.activeDisplay() getPath.activeDisplay()
@@ -2566,9 +2621,37 @@ function DisplayView() {
const [isPlayerDisplayActive, setIsPlayerDisplayActive] = useState(false); const [isPlayerDisplayActive, setIsPlayerDisplayActive] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false);
const [wakeLockEnabled, setWakeLockEnabled] = useState(false); const [wakeLockEnabled, setWakeLockEnabled] = useState(false);
const [displayParticipants, setDisplayParticipants] = useState([]);
const wakeLockRef = useRef(null); const wakeLockRef = useRef(null);
const currentParticipantRef = 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(() => { useEffect(() => {
const onFsChange = () => setIsFullscreen(!!document.fullscreenElement); const onFsChange = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', onFsChange); document.addEventListener('fullscreenchange', onFsChange);
@@ -2706,13 +2789,9 @@ function DisplayView() {
const hidePlayerHp = activeDisplayData?.hidePlayerHp ?? true; const hidePlayerHp = activeDisplayData?.hidePlayerHp ?? true;
const hideNpcHp = activeDisplayData?.hideNpcHp ?? false; const hideNpcHp = activeDisplayData?.hideNpcHp ?? false;
let participantsToRender = []; // 1-list model: displayParticipants IS the display order (participants[] order
if (participants) { // plus temporary exiting cards). Do NOT re-sort by initiative.
// 1-list model: participants[] IS the display order (DM drag = source of const participantsToRender = displayParticipants;
// 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);
}
const displayStyles = campaignBackgroundUrl const displayStyles = campaignBackgroundUrl
? { ? {
@@ -2783,10 +2862,14 @@ function DisplayView() {
} }
return ( return (
<div <PlayerParticipantCard
key={p.id} key={p.id}
ref={isCurrentTurn ? currentParticipantRef : null} id={p.id}
className={`p-4 md:p-6 rounded-lg shadow-lg transition-all ${participantBgColor} ${status === 'dead' ? 'opacity-40 grayscale' : (!p.isActive ? 'opacity-40 grayscale' : '')}`} isActive={p.isActive !== false}
status={status}
onExit={handleExit}
divRef={isCurrentTurn ? currentParticipantRef : null}
className={`relative p-4 md:p-6 rounded-lg shadow-lg ${participantBgColor}`}
> >
<div className="flex justify-between items-center mb-2"> <div className="flex justify-between items-center mb-2">
<h3 <h3
@@ -2850,7 +2933,7 @@ function DisplayView() {
{!p.isActive && !isZeroHp && ( {!p.isActive && !isZeroHp && (
<p className="text-center text-lg font-semibold text-stone-300 mt-2">(Inactive)</p> <p className="text-center text-lg font-semibold text-stone-300 mt-2">(Inactive)</p>
)} )}
</div> </PlayerParticipantCard>
); );
})} })}
</div> </div>
+3 -3
View File
@@ -105,9 +105,9 @@ export function onSnapshot(refOrQuery, onSuccess, onError) {
return unsub; return unsub;
} }
// Apply Firestore-style query constraints (orderBy desc/asc, limit) to mock docs. // Apply Firestore-style query constraints (orderBy desc/asc, limit, where)
// Mirrors real SDK semantics enough for contract tests. Only orderBy + limit // to mock docs. Mirrors real SDK semantics for contract tests. Offset handled
// supported (App's LOG_QUERY uses exactly these). // by adapter (firebase.js slices) — mock never sees it.
function applyConstraints(docs, constraints) { function applyConstraints(docs, constraints) {
let out = [...docs]; let out = [...docs];
for (const c of constraints) { for (const c of constraints) {
+18
View File
@@ -294,6 +294,7 @@ function runStorageContract(name, factory) {
describe('subscribeCollection queryConstraints', () => { describe('subscribeCollection queryConstraints', () => {
const orderByC = (field, dir) => ({ __type: 'orderBy', field, dir }); const orderByC = (field, dir) => ({ __type: 'orderBy', field, dir });
const limitC = (n) => ({ __type: 'limit', n }); const limitC = (n) => ({ __type: 'limit', n });
const offsetC = (n) => ({ __type: 'offset', offset: n });
beforeEach(async () => { beforeEach(async () => {
// seed 5 log docs, timestamps out of order // seed 5 log docs, timestamps out of order
@@ -324,6 +325,23 @@ function runStorageContract(name, factory) {
}); });
expect(result).toHaveLength(5); expect(result).toHaveLength(5);
}); });
// Log page pagination (LogsView pageQuery): orderBy + limit + offset.
// Server pushes offset to SQL; firebase adapter emulates (widen limit,
// slice). Both MUST return the same page.
test('offset skips first N after ordering (pagination)', async () => {
const result = await new Promise((resolve) => {
storage.subscribeCollection('logs', resolve, [orderByC('timestamp', 'desc'), limitC(2), offsetC(2)]);
});
expect(result.map(d => d.msg)).toEqual(['two', 'four']);
});
test('offset past end returns empty page', async () => {
const result = await new Promise((resolve) => {
storage.subscribeCollection('logs', resolve, [orderByC('timestamp', 'desc'), limitC(2), offsetC(10)]);
});
expect(result).toEqual([]);
});
}); });
}); });
} }
+27 -21
View File
@@ -71,6 +71,27 @@ export function getAuthInstance() { return authInstance; }
// App.js can now import { storage } and call storage.setDoc(path, data). // App.js can now import { storage } and call storage.setDoc(path, data).
// Hooks (useFirestoreDocument etc) still use SDK directly for now. // Hooks (useFirestoreDocument etc) still use SDK directly for now.
// Translate neutral {__type} constraints (src/storage/index.js builders) to
// SDK builders. The client SDK has no offset, so emulate it: widen limit to
// offset+limit and report offsetN for the caller to slice off the leading
// docs. Read cost matches native offset (admin SDK also bills skipped docs);
// the server adapter pushes offset into SQL instead. Unknown constraint types
// are dropped — a raw object handed to query() throws and white-screens.
function toSdkConstraints(queryConstraints) {
let offsetN = 0;
let limitN = null;
const fbConstraints = [];
for (const c of queryConstraints || []) {
if (!c || !c.__type) continue;
if (c.__type === 'where') fbConstraints.push(where(c.field, c.op, c.value));
else if (c.__type === 'orderBy') fbConstraints.push(orderBy(c.field, c.dir || 'asc'));
else if (c.__type === 'limit') limitN = c.n;
else if (c.__type === 'offset') offsetN = c.offset || 0;
}
if (limitN !== null) fbConstraints.push(limit(limitN + offsetN));
return { fbConstraints, offsetN };
}
export function createFirebaseStorage() { export function createFirebaseStorage() {
const db = dbInstance; const db = dbInstance;
if (!db) throw new Error('Firestore not initialized. Call initFirebase() first.'); if (!db) throw new Error('Firestore not initialized. Call initFirebase() first.');
@@ -101,18 +122,10 @@ export function createFirebaseStorage() {
}, },
async getCollection(collectionPath, queryConstraints = []) { async getCollection(collectionPath, queryConstraints = []) {
// Translate neutral {__type} constraints to firebase SDK builders. const { fbConstraints, offsetN } = toSdkConstraints(queryConstraints);
const fbConstraints = [];
for (const c of queryConstraints || []) {
if (!c || !c.__type) continue;
if (c.__type === 'where') fbConstraints.push(where(c.field, c.op, c.value));
else if (c.__type === 'orderBy') fbConstraints.push(orderBy(c.field, c.dir || 'asc'));
else if (c.__type === 'limit') fbConstraints.push(limit(c.n));
// firebase SDK has no offset; skip (UI uses server adapter in dev).
}
const q = fbConstraints.length ? query(collection(db, collectionPath), ...fbConstraints) : collection(db, collectionPath); const q = fbConstraints.length ? query(collection(db, collectionPath), ...fbConstraints) : collection(db, collectionPath);
const snapshot = await getDocsReal(q); const snapshot = await getDocsReal(q);
return snapshot.docs.map(d => ({ id: d.id, ...d.data() })); return snapshot.docs.slice(offsetN).map(d => ({ id: d.id, ...d.data() }));
}, },
async countCollection(collectionPath) { async countCollection(collectionPath) {
@@ -174,19 +187,12 @@ export function createFirebaseStorage() {
subscribeCollection(collectionPath, cb, queryConstraints = [], errCb) { subscribeCollection(collectionPath, cb, queryConstraints = [], errCb) {
recordAdapterCall({ fn: 'subscribeCollection', path: collectionPath }); recordAdapterCall({ fn: 'subscribeCollection', path: collectionPath });
// queryConstraints = neutral {__type} builders (from index.js). const { fbConstraints, offsetN } = toSdkConstraints(queryConstraints);
// Translate to SDK orderBy/limit. const q = fbConstraints.length > 0
const sdkConstraints = queryConstraints.map(c => { ? query(collection(db, collectionPath), ...fbConstraints)
if (c.__type === 'where') return where(c.field, c.op, c.value);
if (c.__type === 'orderBy') return orderBy(c.field, c.dir);
if (c.__type === 'limit') return limit(c.n);
return c; // pass-through (forward compat)
});
const q = sdkConstraints.length > 0
? query(collection(db, collectionPath), ...sdkConstraints)
: collection(db, collectionPath); : collection(db, collectionPath);
return onSnapshot(q, (snap) => { return onSnapshot(q, (snap) => {
cb(snap.docs.map(d => ({ id: d.id, ...d.data() }))); cb(snap.docs.slice(offsetN).map(d => ({ id: d.id, ...d.data() })));
}, (err) => { }, (err) => {
console.error(`subscribeCollection ${collectionPath}:`, err); console.error(`subscribeCollection ${collectionPath}:`, err);
if (typeof errCb === 'function') errCb(err); if (typeof errCb === 'function') errCb(err);
@@ -94,7 +94,7 @@ describe('DisplayView characterization', () => {
expect(screen.getAllByText(/Dead/i).length).toBeGreaterThan(0); 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([ seedActiveDisplay([
{ ...participant('conscious'), id: 'active-pc', name: 'Active PC', isActive: true }, { ...participant('conscious'), id: 'active-pc', name: 'Active PC', isActive: true },
{ ...participant('conscious'), id: 'inactive-pc', name: 'Inactive PC', isActive: false }, { ...participant('conscious'), id: 'inactive-pc', name: 'Inactive PC', isActive: false },
@@ -103,7 +103,8 @@ describe('DisplayView characterization', () => {
render(<App />); render(<App />);
await waitFor(() => expect(screen.getByText('Active PC')).toBeInTheDocument()); 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(); expect(screen.queryByText('Inactive Monster')).not.toBeInTheDocument();
}); });
}); });