Builds replayable combat logs with first-class undo/redo, unified verification tooling, fast indexed log queries, and stricter CI.

feat(combat): add first-class undo and redo controls

Add undo and redo controls to the combat UI so the DM can recover from recent
actions without leaving the encounter view.

Undo and redo operate on the current encounter's combat history. Empty stacks
produce clear feedback instead of failing silently. Redo order follows normal
stack behavior after multiple undos.

This makes combat history actionable during play, not just visible in the log.

feat(logs): make combat logs replayable

Replace plain combat log messages with structured combat events that can be
used by the UI, exported as JSON, replayed, and verified.

Each new log entry records the action type, encounter identity, participant
identity, a small action delta, undo intent, and a turn snapshot. Download and
copy now export the event stream as JSON so a saved combat log is useful for
offline analysis and debugging.

Legacy logs remain viewable, but new logs use the structured event format.

feat(logs): make undo and redo transactional

Apply undo and redo as single storage operations so the encounter state and log
state cannot drift apart.

Server storage applies the encounter update and the log undone flag inside one
SQLite transaction. Firebase storage uses a batch write for the same behavior.
The storage contract now includes undo/redo semantics.

This replaces fragile multi-write undo behavior where a failure could update
the encounter without marking the log, or mark the log without updating the
encounter.

feat(combat): add unified replay and verification tool

Add one combat CLI for replaying live combat and verifying combat logs.

Replay drives the live backend through the same shared combat logic used by the
app, writes a JSON event log to an explicit output path, and automatically
verifies the result. Verification checks for DM-visible combat problems such as
skipped turns, double actions, bad round changes, and unexpected turn order
changes.

The tool uses the same JSON event stream produced by log downloads, supports
verbose turn output, and handles Ctrl-C by ending the encounter, writing the
partial log, and verifying what was captured.

fix(perf): keep long combat logging fast

Remove the combat-time log query bottleneck that made long replays slow as log
volume grew.

Combat controls no longer subscribe to the log collection just to keep undo and
redo state warm. Undo and redo now query the latest matching encounter log only
when clicked. Server collection queries support exact filters, ordering,
limits, and offsets, and SQLite indexes keep latest-log and per-encounter log
lookups fast.

Also fix duplicate WebSocket handler registration so realtime updates do not
double-fire under write load.

fix(turns): make toggle active a status change

Make toggle active a roster/status edit instead of a turn advance.

Deactivating the current participant no longer passes the turn or increments
the round. The current turn stays where it is until the DM explicitly clicks
Next Turn, and Next Turn skips inactive participants during normal rotation.

This matches the initiative design: slot order is stable, toggle active does
not move participants, and round changes only come from explicit turn advance.

chore(ci): make warnings and hangs fail fast

Tighten test and build checks so failures are visible instead of noisy or
silent.

Builds run with CI enabled so warnings fail production builds. The full test
command runs app, shared, and server suites with hard timeouts so hangs fail
quickly. Static eslint coverage fails on warnings as well as errors.

Tests were updated around the new async combat logging flow, structured log
events, transactional undo, replay verification, and toggle-active semantics.
This commit is contained in:
david raistrick
2026-07-06 10:33:28 -04:00
parent 9f8b09c7d9
commit 2569cc4497
51 changed files with 4465 additions and 2164 deletions
+413 -176
View File
@@ -1,12 +1,12 @@
import React, { useState, useEffect, useRef, useMemo, createContext, useContext, useCallback } from 'react';
import * as shared from '@ttrpg/shared';
import { initializeApp, getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken, getFirestore, orderBy, limit, getStorage, getStorageMode } from './storage';
import { initializeApp, getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken, getFirestore, where, orderBy, limit, offset, getStorage, getStorageMode } from './storage';
import {
PlusCircle, Users, Swords, Trash2, Eye, Edit3, Save, XCircle, ChevronsUpDown,
UserCheck, UserX, HeartCrack, HeartPulse, Zap, EyeOff, ExternalLink, AlertTriangle,
Play as PlayIcon, Pause as PauseIcon, SkipForward as SkipForwardIcon,
StopCircle as StopCircleIcon, Users2, Dices, ChevronUp, ChevronDown, ScrollText,
Maximize2, Minimize2, Moon, Coffee, Clock, ChevronRight, CheckCircle2, X,
Maximize2, Minimize2, Moon, Coffee, Clock, ChevronRight, X,
Undo2, Redo2
} from 'lucide-react';
@@ -118,8 +118,6 @@ const APP_VERSION = 'v0.4';
const {
DEFAULT_MAX_HP, DEFAULT_INIT_MOD, MONSTER_DEFAULT_INIT_MOD,
generateId, rollD20, formatInitMod,
sortParticipantsByInitiative, syncTurnOrder,
computeTurnOrderAfterRemoval,
startEncounter, nextTurn, togglePause, endEncounter,
addParticipant, addParticipants, updateParticipant, removeParticipant,
toggleParticipantActive: combatToggleActive,
@@ -127,7 +125,6 @@ const {
deathSave: combatDeathSave,
toggleCondition: combatToggleCondition,
reorderParticipants,
activateDisplay, clearDisplay, toggleHidePlayerHp: combatToggleHidePlayerHp,
} = shared;
const ROLL_DISPLAY_DURATION = 5000;
@@ -235,18 +232,28 @@ const getPath = {
// generateId, rollD20, formatInitMod, sortParticipantsByInitiative,
// computeTurnOrderAfterRemoval/Addition: imported from @ttrpg/shared (1-list model).
const LOG_QUERY = [orderBy('timestamp', 'desc'), limit(500)];
// Display limit: recent logs only. Download/copy fetch full set on demand.
// Keep small — 500 = ~900KB per snapshot = slow load.
const LOG_PAGE_SIZE = 100;
const LOG_ORDER = orderBy('ts', 'desc');
const logAction = async (message, context = {}, undoData = null) => {
if (!db) return;
try {
const entry = { timestamp: Date.now(), message, ...context };
if (undoData) entry.undo = undoData;
await storage.addDoc(getPath.logs(), entry);
} catch (err) {
console.error('Error writing log:', err);
}
};
// Combat actions now live in shared/turn.js and WRITE THEIR OWN LOGS.
// Every mutating func is async, takes ctx {storage, encPath, logPath},
// persists encounter patch + log entry itself. No separate logEvent call.
//
// buildCtx: standard context for combat action calls. Lazy getStorage()
// so handlers work even if module-level `storage` assigned late (test/auth).
const buildCtx = (encounterPath) => ({
storage: getStorage(),
encPath: encounterPath,
logPath: getPath.logs(),
});
// Read undo payload: new lean `undo` (id-based) or legacy (`undo_payload`/`undo`).
// Lean events need expandUndo() at click time (needs current enc doc).
// expandUndo lives in shared/turn.js (re-exported via @ttrpg/shared).
const { expandUndo } = shared;
const getUndo = (entry) => entry.undo_payload || entry.undo || null;
// ============================================================================
@@ -307,12 +314,14 @@ function useFirestoreCollection(collectionPath, queryConstraints = []) {
setData(items);
setIsLoading(false);
}, queryConstraints, (err) => {
console.error(`Error fetching collection ${collectionPath}:`, err);
console.error(`[useFirestoreCollection] ERR ${collectionPath}:`, err);
setError(err.message || "Failed to fetch collection.");
setIsLoading(false);
setData([]);
});
return () => { if (typeof unsubscribe === 'function') unsubscribe(); };
return () => {
if (typeof unsubscribe === 'function') unsubscribe();
};
// queryString, not array ref
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [collectionPath, queryString]);
@@ -349,8 +358,35 @@ function Modal({ onClose, title, children }) {
}
function ConfirmationModal({ isOpen, onClose, onConfirm, title, message }) {
const [pending, setPending] = useState(false);
const [error, setError] = useState(null);
// Reset transient state each time modal opens.
useEffect(() => {
if (isOpen) { setPending(false); setError(null); }
}, [isOpen]);
if (!isOpen) return null;
const handleConfirm = async () => {
if (pending) return; // double-click guard
setPending(true);
setError(null);
try {
await onConfirm();
onClose();
} catch (err) {
console.error('ConfirmationModal action failed:', err);
setError(err?.message || 'Action failed. Try again.');
setPending(false);
}
};
const close = () => {
if (pending) return; // block close mid-action
onClose();
};
return (
<div className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center p-4 z-50">
<div className="bg-stone-900 p-6 rounded-lg shadow-xl w-full max-w-md">
@@ -359,18 +395,23 @@ function ConfirmationModal({ isOpen, onClose, onConfirm, title, message }) {
<h2 className="text-xl font-semibold text-yellow-300">{title || "Confirm Action"}</h2>
</div>
<p className="text-stone-300 mb-6">{message || "Are you sure you want to proceed?"}</p>
{error && (
<p className="text-red-400 text-sm mb-4">{error}</p>
)}
<div className="flex justify-end space-x-3">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-stone-300 bg-stone-700 hover:bg-stone-600 rounded-md transition-colors"
onClick={close}
disabled={pending}
className="px-4 py-2 text-sm font-medium text-stone-300 bg-stone-700 hover:bg-stone-600 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Cancel
</button>
<button
onClick={onConfirm}
className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-md transition-colors"
onClick={handleConfirm}
disabled={pending}
className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Confirm
{pending ? "Working..." : "Confirm"}
</button>
</div>
</div>
@@ -955,13 +996,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
};
try {
const { patch, log } = addParticipant(encounter, newParticipant);
await storage.updateDoc(encounterPath, patch);
logAction(log.message, { encounterName: encounter.name, encounterPath }, {
encounterPath,
updates: log.undo,
redo: patch,
});
await addParticipant(encounter, newParticipant, buildCtx(encounterPath));
setLastRollDetails({
name: nameToAdd,
@@ -1019,15 +1054,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
}
try {
const { patch, log } = addParticipants(encounter, newParticipants);
await storage.updateDoc(encounterPath, patch);
if (log) {
logAction(log.message, { encounterName: encounter.name, encounterPath }, {
encounterPath,
updates: log.undo,
redo: patch,
});
}
await addParticipants(encounter, newParticipants, buildCtx(encounterPath));
} catch (err) {
showToast("Failed to add all characters. Please try again."); }
};
@@ -1035,15 +1062,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
const handleUpdateParticipant = async (updatedData) => {
if (!db || !editingParticipant) return;
try {
const { patch, log } = updateParticipant(encounter, editingParticipant.id, updatedData);
await storage.updateDoc(encounterPath, patch);
if (log) {
logAction(log.message, { encounterName: encounter.name, encounterPath }, {
encounterPath,
updates: log.undo,
redo: patch,
});
}
await updateParticipant(encounter, editingParticipant.id, updatedData, buildCtx(encounterPath));
setEditingParticipant(null);
} catch (err) {
showToast("Failed to update participant. Please try again."); }
@@ -1058,15 +1077,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
const n = parseInt(value, 10);
if (isNaN(n)) return;
try {
const { patch, log } = updateParticipant(encounter, participantId, { initiative: n });
await storage.updateDoc(encounterPath, patch);
if (log) {
logAction(log.message, { encounterName: encounter.name, encounterPath }, {
encounterPath,
updates: log.undo,
redo: patch,
});
}
await updateParticipant(encounter, participantId, { initiative: n }, buildCtx(encounterPath));
} catch (err) {
// fall through silently
}
@@ -1080,13 +1091,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
const confirmDeleteParticipant = async () => {
if (!db || !itemToDelete) return;
try {
const { patch, log } = removeParticipant(encounter, itemToDelete.id);
await storage.updateDoc(encounterPath, patch);
logAction(log.message, { encounterName: encounter.name, encounterPath }, {
encounterPath,
updates: log.undo,
redo: patch,
});
await removeParticipant(encounter, itemToDelete.id, buildCtx(encounterPath));
} catch (err) {
showToast("Failed to delete participant. Please try again."); }
setShowDeleteConfirm(false);
@@ -1096,13 +1101,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
const toggleParticipantActive = async (participantId) => {
if (!db) return;
try {
const { patch, log } = combatToggleActive(encounter, participantId);
await storage.updateDoc(encounterPath, patch);
logAction(log.message, { encounterName: encounter.name, encounterPath }, {
encounterPath,
updates: log.undo,
redo: patch,
});
await combatToggleActive(encounter, participantId, buildCtx(encounterPath));
} catch (err) {
// fall through silently
}
@@ -1118,17 +1117,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
return;
}
try {
const { patch, log } = combatApplyHpChange(encounter, participantId, changeType, amount);
if (patch) {
await storage.updateDoc(encounterPath, patch);
if (log) {
logAction(log.message, { encounterName: encounter.name, encounterPath }, {
encounterPath,
updates: log.undo,
redo: patch,
});
}
}
await combatApplyHpChange(encounter, participantId, changeType, amount, buildCtx(encounterPath));
setHpChangeValues(prev => ({ ...prev, [participantId]: '' }));
} catch (err) {
// fall through silently
@@ -1138,8 +1127,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
const handleDeathSaveChange = async (participantId, type, saveNumber) => {
if (!db) return;
try {
const { patch, isDying } = combatDeathSave(encounter, participantId, type, saveNumber);
await storage.updateDoc(encounterPath, patch);
const { isDying } = await combatDeathSave(encounter, participantId, type, saveNumber, buildCtx(encounterPath));
if (isDying) {
setTimeout(async () => {
const finalParticipants = encounter.participants.filter(p => p.id !== participantId);
@@ -1154,15 +1142,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
const toggleCondition = async (participantId, conditionId) => {
if (!db) return;
try {
const { patch, log } = combatToggleCondition(encounter, participantId, conditionId);
await storage.updateDoc(encounterPath, patch);
const cond = allConditions.find(c => c.id === conditionId);
const condLabel = cond ? `${cond.label} ${cond.emoji || ''}`.trim() : conditionId;
logAction(log.message.replace(conditionId, condLabel), { encounterName: encounter.name, encounterPath }, {
encounterPath,
updates: log.undo,
redo: patch,
});
await combatToggleCondition(encounter, participantId, conditionId, buildCtx(encounterPath));
} catch (err) {
// fall through silently
}
@@ -1183,12 +1163,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
const cur = (encounter.participants.find(p => p.id === participantId)?.conditions) || [];
if (!cur.includes(trimmed)) {
try {
const { patch, log } = combatToggleCondition(encounter, participantId, trimmed);
await storage.updateDoc(encounterPath, patch);
logAction(log.message.replace(trimmed, trimmed), { encounterName: encounter.name, encounterPath }, {
encounterPath, updates: log.undo,
redo: patch,
});
await combatToggleCondition(encounter, participantId, trimmed, buildCtx(encounterPath));
} catch (err) {}
}
}
@@ -1211,15 +1186,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
return;
}
try {
const { patch, log } = reorderParticipants(encounter, draggedItemId, targetId);
await storage.updateDoc(encounterPath, patch);
if (log) {
logAction(log.message, { encounterName: encounter.name, encounterPath }, {
encounterPath,
updates: log.undo,
redo: patch,
});
}
await reorderParticipants(encounter, draggedItemId, targetId, buildCtx(encounterPath));
} catch (err) {
// drag invalid (id not found) — ignore
}
@@ -1685,23 +1652,38 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
const { showToast, showInfo } = useUIFeedback();
const [showEndConfirm, setShowEndConfirm] = useState(false);
const { data: activeDisplayData } = useFirestoreDocument(getPath.activeDisplay());
const { data: logsData } = useFirestoreCollection(getPath.logs(), LOG_QUERY);
const hidePlayerHp = activeDisplayData?.hidePlayerHp ?? true;
const hideNpcHp = activeDisplayData?.hideNpcHp ?? false;
// Undo/redo stacks for THIS encounter.
const encLogs = (logsData || []).filter(l => l.encounterPath === encounterPath && l.undo);
const undoTarget = encLogs.find(l => !l.undone) || null;
// redo = latest undone entry that is NEWER than the latest undone entry's
// predecessor — simplest correct heuristic: first entry (desc order) with undone=true
// whose immediate newer entry is undone or absent.
const redoTarget = encLogs.find(l => l.undone) || null;
// Undo/redo queried on click. No live logs subscription during combat.
const latestLogQuery = [where('encounterPath', '==', encounterPath), LOG_ORDER, limit(25)];
const findUndoTarget = async (redo = false) => {
const rows = (await storage.getCollection(getPath.logs(), latestLogQuery) || [])
.filter(l => l.undo || l.undo_payload);
if (!redo) return rows.find(l => !l.undone) || null;
// Redo stack = contiguous undone entries at top of log (newest first).
// If A3 then A2 undone, redo must replay A2 first, then A3.
const undoneBlock = [];
for (const row of rows) {
if (!row.undone) break;
undoneBlock.push(row);
}
return undoneBlock.length ? undoneBlock[undoneBlock.length - 1] : null;
};
const handleUndo = async () => {
if (!db || !undoTarget) return;
if (!db) return;
try {
await storage.updateDoc(undoTarget.undo.encounterPath, undoTarget.undo.updates);
await storage.updateDoc(`${getPath.logs()}/${undoTarget.id}`, { undone: true });
if (storage.undo) {
const undoTarget = await findUndoTarget(false);
if (!undoTarget) { showToast('No action to undo.'); return; }
const expanded = undoTarget.undo
? expandUndo(undoTarget, encounter) // lean: build patch from current enc
: getUndo(undoTarget); // legacy
if (!expanded) return;
await storage.undo({ logPath: `${getPath.logs()}/${undoTarget.id}`, undo: expanded });
}
} catch (err) {
console.error('Error undoing action:', err);
showToast('Failed to undo. The encounter may have changed or no longer exists.');
@@ -1709,10 +1691,17 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
};
const handleRedo = async () => {
if (!db || !redoTarget) return;
if (!db) return;
try {
await storage.updateDoc(redoTarget.undo.encounterPath, redoTarget.undo.redo);
await storage.updateDoc(`${getPath.logs()}/${redoTarget.id}`, { undone: false });
if (storage.undo) {
const redoTarget = await findUndoTarget(true);
if (!redoTarget) { showToast('No action to redo.'); return; }
const expanded = redoTarget.undo
? expandUndo(redoTarget, encounter)
: getUndo(redoTarget);
if (!expanded) return;
await storage.undo({ logPath: `${getPath.logs()}/${redoTarget.id}`, undo: expanded, redo: true });
}
} catch (err) {
console.error('Error redoing action:', err);
showToast('Failed to redo.');
@@ -1722,7 +1711,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
const handleToggleHidePlayerHp = async () => {
if (!db) return;
try {
await storage.setDoc(getPath.activeDisplay(), combatToggleHidePlayerHp(hidePlayerHp).patch, { merge: true });
await storage.setDoc(getPath.activeDisplay(), { hidePlayerHp: !hidePlayerHp }, { merge: true });
} catch (err) {
console.error("Error toggling hidePlayerHp:", err);
}
@@ -1743,14 +1732,8 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
}
try {
const { patch, log } = startEncounter(encounter);
await storage.updateDoc(encounterPath, patch);
await storage.setDoc(getPath.activeDisplay(), activateDisplay({ campaignId, encounterId: encounter.id }).patch, { merge: true });
logAction(log.message, { encounterName: encounter.name, encounterPath }, {
encounterPath,
updates: log.undo,
redo: patch,
});
await startEncounter(encounter, buildCtx(encounterPath));
await storage.setDoc(getPath.activeDisplay(), { activeCampaignId: campaignId, activeEncounterId: encounter.id }, { merge: true });
} catch (err) {
showToast(err.message || "Failed to start encounter. Please try again."); }
};
@@ -1758,13 +1741,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
const handleTogglePause = async () => {
if (!db || !encounter || !encounter.isStarted) return;
try {
const { patch, log } = togglePause(encounter);
await storage.updateDoc(encounterPath, patch);
logAction(log.message, { encounterName: encounter.name, encounterPath }, {
encounterPath,
updates: log.undo,
redo: patch,
});
await togglePause(encounter, buildCtx(encounterPath));
} catch (err) {
// fall through silently
}
@@ -1775,13 +1752,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
if (!encounter.currentTurnParticipantId || !encounter.turnOrderIds || encounter.turnOrderIds.length === 0) return;
try {
const { patch, log } = nextTurn(encounter);
await storage.updateDoc(encounterPath, patch);
logAction(log.message, { encounterName: encounter.name, encounterPath }, {
encounterPath,
updates: log.undo,
redo: patch,
});
await nextTurn(encounter, buildCtx(encounterPath));
} catch (err) {
// nextTurn throws if no active participants — auto-end combat
if (err.message === 'Encounter not running.' || err.message === 'No active turn.') return;
@@ -1797,14 +1768,8 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
const confirmEndEncounter = async () => {
if (!db) return;
try {
const { patch, log } = endEncounter(encounter);
await storage.updateDoc(encounterPath, patch);
await storage.setDoc(getPath.activeDisplay(), clearDisplay().patch, { merge: true });
logAction(log.message, { encounterName: encounter.name, encounterPath }, {
encounterPath,
updates: log.undo,
redo: patch,
});
await endEncounter(encounter, buildCtx(encounterPath));
await storage.setDoc(getPath.activeDisplay(), { activeCampaignId: null, activeEncounterId: null }, { merge: true });
} catch (err) {
showToast("Failed to end encounter. Please try again."); }
@@ -1863,21 +1828,19 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
)}
</div>
{/* Undo / Redo — always available when encounter open */}
{/* Undo / Redo — queried on click (no live logs subscription during combat) */}
<div className="mt-3 flex gap-2">
<button
onClick={handleUndo}
disabled={!undoTarget}
className="flex-1 px-3 py-2 text-xs font-medium text-white bg-stone-700 hover:bg-stone-600 rounded-md transition-colors flex items-center justify-center disabled:opacity-30 disabled:cursor-not-allowed"
title={undoTarget ? `Undo: ${undoTarget.message}` : 'Nothing to undo'}
className="flex-1 px-3 py-2 text-xs font-medium text-white bg-amber-700 hover:bg-amber-600 rounded-md transition-colors flex items-center justify-center"
title="Undo latest action for this encounter"
>
<Undo2 size={14} className="mr-1" /> Undo
</button>
<button
onClick={handleRedo}
disabled={!redoTarget}
className="flex-1 px-3 py-2 text-xs font-medium text-white bg-stone-700 hover:bg-stone-600 rounded-md transition-colors flex items-center justify-center disabled:opacity-30 disabled:cursor-not-allowed"
title={redoTarget ? `Redo: ${redoTarget.message}` : 'Nothing to redo'}
className="flex-1 px-3 py-2 text-xs font-medium text-white bg-sky-700 hover:bg-sky-600 rounded-md transition-colors flex items-center justify-center"
title="Redo latest undone action for this encounter"
>
<Redo2 size={14} className="mr-1" /> Redo
</button>
@@ -1927,7 +1890,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
// ============================================================================
function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharacters }) {
const { showToast, showInfo } = useUIFeedback();
const { showToast } = useUIFeedback();
const { data: encountersData, isLoading: isLoadingEncounters } = useFirestoreCollection(
campaignId ? getPath.encounters(campaignId) : null
);
@@ -2008,14 +1971,28 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
const encounterId = itemToDelete.id;
try {
await storage.deleteDoc(getPath.encounter(campaignId, encounterId));
// cascade: delete logs for this encounter
const encPath = getPath.encounter(campaignId, encounterId);
const allLogs = await storage.getCollection(getPath.logs());
const encLogs = allLogs.filter(l =>
(l.encounterId && l.encounterId === encounterId) ||
(l.encounterPath && l.encounterPath.includes(encPath))
);
if (encLogs.length > 0) {
await storage.batchWrite(encLogs.map(l => ({
type: 'delete',
path: `${getPath.logs()}/${l.id || l.path?.split('/').pop()}`,
})));
}
await storage.deleteDoc(encPath);
if (selectedEncounterId === encounterId) {
setSelectedEncounterId(null);
}
if (activeDisplayInfo && activeDisplayInfo.activeEncounterId === encounterId) {
await storage.updateDoc(getPath.activeDisplay(), clearDisplay().patch);
await storage.updateDoc(getPath.activeDisplay(), { activeCampaignId: null, activeEncounterId: null });
}
} catch (err) {
console.error("Error deleting encounter:", err);
@@ -2033,7 +2010,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
const currentActiveEncounter = activeDisplayInfo?.activeEncounterId;
if (currentActiveCampaign === campaignId && currentActiveEncounter === encounterId) {
await storage.setDoc(getPath.activeDisplay(), clearDisplay().patch, { merge: true });
await storage.setDoc(getPath.activeDisplay(), { activeCampaignId: null, activeEncounterId: null }, { merge: true });
} else {
await storage.setDoc(getPath.activeDisplay(), {
activeCampaignId: campaignId,
@@ -2172,7 +2149,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
// ============================================================================
function AdminView({ userId }) {
const { showToast, showInfo } = useUIFeedback();
const { showToast } = useUIFeedback();
const { data: campaignsData, isLoading: isLoadingCampaigns, error: campaignsError } = useFirestoreCollection(
getPath.campaigns()
);
@@ -2204,15 +2181,30 @@ function AdminView({ userId }) {
})
);
// newest first by createdAt (fallback to name for stable order).
detailedCampaigns.sort((a, b) => {
const at = a.createdAt || 0;
const bt = b.createdAt || 0;
if (at === bt) return (a.name || '').localeCompare(b.name || '');
return bt - at;
});
setCampaignsWithDetails(detailedCampaigns);
};
fetchDetails();
} else if (campaignsData) {
const sorted = [...campaignsData].sort((a, b) => {
const at = a.createdAt || 0;
const bt = b.createdAt || 0;
if (at === bt) return (a.name || '').localeCompare(b.name || '');
return bt - at;
});
setCampaignsWithDetails(
campaignsData.map(c => ({ ...c, characters: c.players || [], encounterCount: 0 }))
sorted.map(c => ({ ...c, characters: c.players || [], encounterCount: 0 }))
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [campaignsData]);
useEffect(() => {
@@ -2266,7 +2258,24 @@ function AdminView({ userId }) {
const id = e.id || e.path?.split('/').pop();
return { type: 'delete', path: `${encountersPath}/${id}` };
});
if (deleteOps.length > 0) await storage.batchWrite(deleteOps);
// cascade: delete logs for every encounter in this campaign
const allLogs = await storage.getCollection(getPath.logs());
const encPaths = new Set(encounters.map(e => `${encountersPath}/${e.id || e.path?.split('/').pop()}`));
const encIds = new Set(encounters.map(e => e.id || e.path?.split('/').pop()));
const logOps = allLogs
.filter(l =>
(l.encounterId && encIds.has(l.encounterId)) ||
(l.encounterPath && [...encPaths].some(p => l.encounterPath.includes(p)))
)
.map(l => ({
type: 'delete',
path: `${getPath.logs()}/${l.id || l.path?.split('/').pop()}`,
}));
if (deleteOps.length > 0 || logOps.length > 0) {
await storage.batchWrite([...deleteOps, ...logOps]);
}
await storage.deleteDoc(getPath.campaign(campaignId));
@@ -2277,7 +2286,7 @@ function AdminView({ userId }) {
const activeDisplay = await storage.getDoc(getPath.activeDisplay());
if (activeDisplay && activeDisplay.activeCampaignId === campaignId) {
await storage.updateDoc(getPath.activeDisplay(), clearDisplay().patch);
await storage.updateDoc(getPath.activeDisplay(), { activeCampaignId: null, activeEncounterId: null });
}
} catch (err) {
console.error("Error deleting campaign:", err);
@@ -2740,9 +2749,143 @@ function DisplayView() {
function LogsView() {
const { showToast } = useUIFeedback();
const { data: logs, isLoading } = useFirestoreCollection(getPath.logs(), LOG_QUERY);
const [page, setPage] = useState(0);
const pageQuery = useMemo(() => [LOG_ORDER, limit(LOG_PAGE_SIZE), offset(page * LOG_PAGE_SIZE)], [page]);
const { data: logs, isLoading } = useFirestoreCollection(getPath.logs(), pageQuery);
const { data: campaignsData } = useFirestoreCollection(getPath.campaigns());
const [showClearConfirm, setShowClearConfirm] = useState(false);
const [undoingId, setUndoingId] = useState(null);
const [dlMenu, setDlMenu] = useState(false);
const [cpMenu, setCpMenu] = useState(false);
// Full typed-log set for download/copy dropdown lists. Page query (LOG_QUERY,
// 100/page) only sees current page, so the encounter list was truncated.
// Lazy-fetch on first menu open; reused after. Server getCollection with no
// constraint returns all docs, so this is the full set (not page-limited).
// Ref guard (not state): StrictMode double-mount sets state then unmounts
// before fetch resolves; state-based loading flag dead-locks the remount.
const [allLogs, setAllLogs] = useState(null);
const allLogsFetched = useRef(false);
useEffect(() => {
if (!dlMenu && !cpMenu) return;
if (allLogs || allLogsFetched.current) return;
allLogsFetched.current = true;
let cancelled = false;
getStorage().getCollection(getPath.logs())
.then(docs => { if (!cancelled) setAllLogs(docs.filter(l => l.type)); })
.catch(err => { console.error('Error loading all logs:', err); allLogsFetched.current = false; })
.finally(() => { if (cancelled) allLogsFetched.current = false; });
return () => { cancelled = true; };
}, [dlMenu, cpMenu, allLogs]);
// Total log count (separate cheap server count query, not full fetch).
const [totalCount, setTotalCount] = useState(null);
useEffect(() => {
let cancelled = false;
getStorage().countCollection(getPath.logs())
.then(n => { if (!cancelled) setTotalCount(n); })
.catch(() => { if (!cancelled) setTotalCount(null); });
return () => { cancelled = true; };
}, [logs]);
const shown = (logs || []).length;
const rangeStart = totalCount ? page * LOG_PAGE_SIZE + 1 : 0;
const rangeEnd = totalCount ? Math.min((page + 1) * LOG_PAGE_SIZE, totalCount) : 0;
const hasPrev = page > 0;
const hasNext = totalCount === null ? shown === LOG_PAGE_SIZE : rangeEnd < totalCount;
// Parse campaign id out of encounterPath (.../campaigns/{cid}/encounters/{eid}).
const cidFromPath = (p) => {
if (!p) return null;
const m = String(p).match(/campaigns\/([^/]+)\/encounters/);
return m ? m[1] : null;
};
const campaignNameById = new Map(
(campaignsData || []).map(c => [c.id, c.name || '(unnamed campaign)'])
);
// Group typed logs by encounter. Each item shows campaign · encounter ·
// last-entry date · entry count. Sorted newest first by last-entry ts.
// Legacy logs (no type) excluded — kept for human scroll only.
const encounterGroups = (() => {
const src = allLogs ?? (logs || []);
const map = new Map(); // key: encounterId || encounterPath || 'none'
for (const l of src) {
if (!l.type) continue;
const key = l.encounterId || l.encounterPath || 'none';
const cid = cidFromPath(l.encounterPath);
const campName = cid ? (campaignNameById.get(cid) || '(unknown campaign)') : '(unknown campaign)';
const g = map.get(key) || {
key,
encounter: l.encounterName || '(unnamed encounter)',
campaign: campName,
count: 0,
latest: 0,
};
g.count++;
g.latest = Math.max(g.latest, l.ts || l.timestamp || 0);
map.set(key, g);
}
return [...map.values()].sort((a, b) => b.latest - a.latest);
})();
const typedCount = encounterGroups.reduce((n, g) => n + g.count, 0);
const fmtDate = (ts) => ts ? new Date(ts).toLocaleString() : '—';
// Filename-safe human date: 2026-07-04_15-30
const fileDate = (ts) => ts
? new Date(ts).toISOString().slice(0,16).replace('T', '_').replace(':', '-')
: new Date().toISOString().slice(0,16).replace('T', '_').replace(':', '-');
const dlFileName = (g) => g
? `ttrpg-logs-${(g.campaign + '_' + g.encounter).replace(/[^a-z0-9]+/gi, '_')}-${fileDate(g.latest)}.json`
: `ttrpg-logs-all-${fileDate(Date.now())}.json`;
const handleDownloadFor = async (encKey) => {
setDlMenu(false);
try {
const all = await storage.getCollection(getPath.logs());
const filtered = encKey
? all.filter(l => (l.encounterId || l.encounterPath || 'none') === encKey)
: all;
const json = shared.logEvent.serializeEvents(filtered);
if (json === '[]') {
showToast('No typed log entries for that selection.');
return;
}
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const grp = encKey ? encounterGroups.find(g => g.key === encKey) : null;
a.download = dlFileName(grp);
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (err) {
console.error('Error downloading logs:', err);
showToast('Failed to download logs.');
}
};
const handleCopyFor = async (encKey) => {
setCpMenu(false);
try {
const all = await storage.getCollection(getPath.logs());
const filtered = encKey
? all.filter(l => (l.encounterId || l.encounterPath || 'none') === encKey)
: all;
const json = shared.logEvent.serializeEvents(filtered);
if (json === '[]') {
showToast('No typed log entries for that selection.');
return;
}
await navigator.clipboard.writeText(json);
showToast('Logs copied to clipboard.');
} catch (err) {
console.error('Error copying logs:', err);
showToast('Failed to copy logs.');
}
};
const handleClearLogs = async () => {
try {
@@ -2761,11 +2904,16 @@ function LogsView() {
};
const handleUndo = async (entry) => {
if (!db || !entry.undo) return;
if (!db) return;
setUndoingId(entry.id);
try {
await storage.updateDoc(entry.undo.encounterPath, entry.undo.updates);
await storage.updateDoc(`${getPath.logs()}/${entry.id}`, { undone: true });
if (storage.undo) {
const expanded = entry.undo
? expandUndo(entry, await storage.getDoc(entry.encounterPath))
: getUndo(entry);
if (!expanded) { setUndoingId(null); return; }
await storage.undo({ logPath: `${getPath.logs()}/${entry.id}`, undo: expanded });
}
} catch (err) {
console.error('Error undoing action:', err);
showToast("Failed to roll back. The encounter may have changed or no longer exists."); }
@@ -2784,9 +2932,75 @@ function LogsView() {
>
Back to Tracker
</a>
<div className="relative">
<button
onClick={() => { setDlMenu(v => !v); setCpMenu(false); }}
disabled={isLoading || typedCount === 0}
className="px-4 py-2 rounded-md text-sm font-medium bg-stone-700 hover:bg-stone-600 text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
Download
</button>
{dlMenu && (
<div className="absolute right-0 mt-1 w-80 bg-stone-800 border border-stone-700 rounded-md shadow-xl z-20 py-1 max-h-96 overflow-y-auto">
<button
onClick={() => handleDownloadFor(null)}
className="block w-full text-left px-4 py-2 text-sm text-stone-100 hover:bg-stone-700"
>
All encounters ({typedCount})
</button>
{encounterGroups.length > 0 && (
<div className="border-t border-stone-700 my-1" />
)}
{encounterGroups.map(g => (
<button
key={g.key}
onClick={() => handleDownloadFor(g.key)}
className="block w-full text-left px-3 py-2 text-sm text-stone-100 hover:bg-stone-700"
>
<div className="font-medium truncate">{g.encounter}</div>
<div className="text-xs text-stone-400 truncate">{g.campaign}</div>
<div className="text-xs text-stone-500">{fmtDate(g.latest)} · {g.count} entries</div>
</button>
))}
</div>
)}
</div>
<div className="relative">
<button
onClick={() => { setCpMenu(v => !v); setDlMenu(false); }}
disabled={isLoading || typedCount === 0}
className="px-4 py-2 rounded-md text-sm font-medium bg-stone-700 hover:bg-stone-600 text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
Copy
</button>
{cpMenu && (
<div className="absolute right-0 mt-1 w-80 bg-stone-800 border border-stone-700 rounded-md shadow-xl z-20 py-1 max-h-96 overflow-y-auto">
<button
onClick={() => handleCopyFor(null)}
className="block w-full text-left px-4 py-2 text-sm text-stone-100 hover:bg-stone-700"
>
All encounters ({typedCount})
</button>
{encounterGroups.length > 0 && (
<div className="border-t border-stone-700 my-1" />
)}
{encounterGroups.map(g => (
<button
key={g.key}
onClick={() => handleCopyFor(g.key)}
className="block w-full text-left px-3 py-2 text-sm text-stone-100 hover:bg-stone-700"
>
<div className="font-medium truncate">{g.encounter}</div>
<div className="text-xs text-stone-400 truncate">{g.campaign}</div>
<div className="text-xs text-stone-500">{fmtDate(g.latest)} · {g.count} entries</div>
</button>
))}
</div>
)}
</div>
<button
onClick={() => setShowClearConfirm(true)}
disabled={isLoading || logs.length === 0}
disabled={isLoading || (logs || []).length === 0}
className="px-4 py-2 rounded-md text-sm font-medium bg-red-800 hover:bg-red-700 text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
Clear Log
@@ -2802,7 +3016,30 @@ function LogsView() {
<p className="text-stone-400 text-center mt-12 text-lg">No log entries yet.</p>
) : (
<>
<p className="text-stone-500 text-sm mb-4">{logs.length} entries newest first</p>
<div className="flex items-center justify-between mb-4">
<p className="text-stone-500 text-sm">
{totalCount === null
? `${shown} entries — newest first`
: `Showing ${rangeStart}${rangeEnd} of ${totalCount}`}
</p>
<div className="flex items-center gap-2">
<button
onClick={() => setPage(p => Math.max(0, p - 1))}
disabled={!hasPrev}
className="px-3 py-1 rounded text-sm bg-stone-700 hover:bg-stone-600 text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
Prev
</button>
<span className="text-stone-500 text-sm">Page {page + 1}</span>
<button
onClick={() => setPage(p => p + 1)}
disabled={!hasNext}
className="px-3 py-1 rounded text-sm bg-stone-700 hover:bg-stone-600 text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
Next
</button>
</div>
</div>
<div className="space-y-1 max-w-5xl">
{logs.map(entry => (
<div
@@ -2814,7 +3051,7 @@ function LogsView() {
}`}
>
<span className="text-stone-500 whitespace-nowrap font-mono shrink-0">
{new Date(entry.timestamp).toLocaleString()}
{fmtDate(entry.ts || entry.timestamp)}
</span>
{entry.encounterName && (
<span className="text-amber-600 whitespace-nowrap shrink-0">[{entry.encounterName}]</span>
@@ -2824,7 +3061,7 @@ function LogsView() {
</span>
{entry.undone ? (
<span className="shrink-0 text-xs text-stone-600 italic">rolled back</span>
) : entry.undo ? (
) : getUndo(entry) ? (
<button
onClick={() => handleUndo(entry)}
disabled={undoingId === entry.id}