Add combat action log at /logs

Instruments 9 handlers (combat start/end/pause/resume, next turn,
participant add/remove/toggle, HP changes, conditions) to write
timestamped entries to a Firestore logs collection. New LogsView
at /logs shows entries newest-first with encounter context, and
includes a Clear Log button. Adds a View Logs link in the header.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-16 10:25:17 -04:00
parent e23cea205a
commit 4150267925
+143 -10
View File
@@ -1,12 +1,12 @@
import React, { useState, useEffect, useRef, useMemo } from 'react'; import React, { useState, useEffect, useRef, useMemo } from 'react';
import { initializeApp } from 'firebase/app'; import { initializeApp } from 'firebase/app';
import { getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken } from 'firebase/auth'; import { getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken } from 'firebase/auth';
import { getFirestore, doc, setDoc, getDoc, getDocs, collection, onSnapshot, updateDoc, deleteDoc, query, writeBatch } from 'firebase/firestore'; import { getFirestore, doc, setDoc, getDoc, getDocs, addDoc, collection, onSnapshot, updateDoc, deleteDoc, query, orderBy, limit, writeBatch } from 'firebase/firestore';
import { import {
PlusCircle, Users, Swords, Trash2, Eye, Edit3, Save, XCircle, ChevronsUpDown, PlusCircle, Users, Swords, Trash2, Eye, Edit3, Save, XCircle, ChevronsUpDown,
UserCheck, UserX, HeartCrack, HeartPulse, Zap, EyeOff, ExternalLink, AlertTriangle, UserCheck, UserX, HeartCrack, HeartPulse, Zap, EyeOff, ExternalLink, AlertTriangle,
Play as PlayIcon, Pause as PauseIcon, SkipForward as SkipForwardIcon, Play as PlayIcon, Pause as PauseIcon, SkipForward as SkipForwardIcon,
StopCircle as StopCircleIcon, Users2, Dices, ChevronUp, ChevronDown StopCircle as StopCircleIcon, Users2, Dices, ChevronUp, ChevronDown, ScrollText
} from 'lucide-react'; } from 'lucide-react';
// Custom CSS for death animation (player view only) // Custom CSS for death animation (player view only)
@@ -126,7 +126,8 @@ const getPath = {
campaign: (id) => `${PUBLIC_DATA_PATH}/campaigns/${id}`, campaign: (id) => `${PUBLIC_DATA_PATH}/campaigns/${id}`,
encounters: (campaignId) => `${PUBLIC_DATA_PATH}/campaigns/${campaignId}/encounters`, encounters: (campaignId) => `${PUBLIC_DATA_PATH}/campaigns/${campaignId}/encounters`,
encounter: (campaignId, encounterId) => `${PUBLIC_DATA_PATH}/campaigns/${campaignId}/encounters/${encounterId}`, encounter: (campaignId, encounterId) => `${PUBLIC_DATA_PATH}/campaigns/${campaignId}/encounters/${encounterId}`,
activeDisplay: () => `${PUBLIC_DATA_PATH}/activeDisplay/status` activeDisplay: () => `${PUBLIC_DATA_PATH}/activeDisplay/status`,
logs: () => `${PUBLIC_DATA_PATH}/logs`
}; };
// ============================================================================ // ============================================================================
@@ -152,6 +153,17 @@ const sortParticipantsByInitiative = (participants, originalOrder) => {
}); });
}; };
const LOG_QUERY = [orderBy('timestamp', 'desc'), limit(500)];
const logAction = async (message, context = {}) => {
if (!db) return;
try {
await addDoc(collection(db, getPath.logs()), { timestamp: Date.now(), message, ...context });
} catch (err) {
console.error('Error writing log:', err);
}
};
// Returns turnOrderIds/currentTurnParticipantId updates when a participant leaves active combat. // Returns turnOrderIds/currentTurnParticipantId updates when a participant leaves active combat.
const computeTurnOrderAfterRemoval = (encounter, removedId, updatedParticipants) => { const computeTurnOrderAfterRemoval = (encounter, removedId, updatedParticipants) => {
if (!encounter.isStarted) return {}; if (!encounter.isStarted) return {};
@@ -884,6 +896,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
await updateDoc(doc(db, encounterPath), { await updateDoc(doc(db, encounterPath), {
participants: [...participants, newParticipant] participants: [...participants, newParticipant]
}); });
logAction(`${nameToAdd} added to encounter (Initiative: ${finalInitiative})`, { encounterName: encounter.name });
setLastRollDetails({ setLastRollDetails({
name: nameToAdd, name: nameToAdd,
@@ -983,6 +996,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
participants: updatedParticipants, participants: updatedParticipants,
...computeTurnOrderAfterRemoval(encounter, itemToDelete.id, updatedParticipants) ...computeTurnOrderAfterRemoval(encounter, itemToDelete.id, updatedParticipants)
}); });
logAction(`${itemToDelete.name} removed from encounter`, { encounterName: encounter.name });
} catch (err) { } catch (err) {
console.error("Error deleting participant:", err); console.error("Error deleting participant:", err);
alert("Failed to delete participant. Please try again."); alert("Failed to delete participant. Please try again.");
@@ -1009,6 +1023,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
try { try {
await updateDoc(doc(db, encounterPath), { participants: updatedParticipants, ...turnUpdates }); await updateDoc(doc(db, encounterPath), { participants: updatedParticipants, ...turnUpdates });
logAction(`${participant.name} ${newIsActive ? 'reactivated' : 'deactivated'}`, { encounterName: encounter.name });
} catch (err) { } catch (err) {
console.error("Error toggling active state:", err); console.error("Error toggling active state:", err);
} }
@@ -1073,6 +1088,14 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
try { try {
await updateDoc(doc(db, encounterPath), { participants: updatedParticipants, ...turnUpdates }); await updateDoc(doc(db, encounterPath), { participants: updatedParticipants, ...turnUpdates });
setHpChangeValues(prev => ({ ...prev, [participantId]: '' })); setHpChangeValues(prev => ({ ...prev, [participantId]: '' }));
const hpLine = `${participant.currentHp}${newHp} HP`;
const deathSuffix = (isDead && !wasDead) ? (participant.type === 'character' ? ' — Unconscious' : ' — Defeated') : '';
const resurSuffix = wasResurrected ? ' — Revived' : '';
if (changeType === 'damage') {
logAction(`${participant.name} took ${amount} damage (${hpLine})${deathSuffix}`, { encounterName: encounter.name });
} else {
logAction(`${participant.name} healed for ${amount} (${hpLine})${resurSuffix}`, { encounterName: encounter.name });
}
} catch (err) { } catch (err) {
console.error("Error applying HP change:", err); console.error("Error applying HP change:", err);
} }
@@ -1124,16 +1147,22 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
const toggleCondition = async (participantId, conditionId) => { const toggleCondition = async (participantId, conditionId) => {
if (!db) return; if (!db) return;
const participant = participants.find(p => p.id === participantId);
if (!participant) return;
const wasActive = (participant.conditions || []).includes(conditionId);
const updatedParticipants = participants.map(p => { const updatedParticipants = participants.map(p => {
if (p.id !== participantId) return p; if (p.id !== participantId) return p;
const current = p.conditions || []; const current = p.conditions || [];
const next = current.includes(conditionId) const next = wasActive
? current.filter(c => c !== conditionId) ? current.filter(c => c !== conditionId)
: [...current, conditionId]; : [...current, conditionId];
return { ...p, conditions: next }; return { ...p, conditions: next };
}); });
try { try {
await updateDoc(doc(db, encounterPath), { participants: updatedParticipants }); await updateDoc(doc(db, encounterPath), { participants: updatedParticipants });
const cond = CONDITIONS.find(c => c.id === conditionId);
const condLabel = cond ? `${cond.label} ${cond.emoji}` : conditionId;
logAction(`${participant.name} ${wasActive ? 'lost' : 'gained'} ${condLabel}`, { encounterName: encounter.name });
} catch (err) { } catch (err) {
console.error("Error updating conditions:", err); console.error("Error updating conditions:", err);
} }
@@ -1586,6 +1615,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
activeEncounterId: encounter.id activeEncounterId: encounter.id
}, { merge: true }); }, { merge: true });
logAction(`Combat started: "${encounter.name}" — ${sortedParticipants[0].name}'s turn (Round 1)`, { encounterName: encounter.name });
console.log("Encounter started and set as active display."); console.log("Encounter started and set as active display.");
} catch (err) { } catch (err) {
console.error("Error starting encounter:", err); console.error("Error starting encounter:", err);
@@ -1610,6 +1640,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
isPaused: newPausedState, isPaused: newPausedState,
turnOrderIds: newTurnOrderIds turnOrderIds: newTurnOrderIds
}); });
logAction(`Combat ${newPausedState ? 'paused' : 'resumed'}: "${encounter.name}"`, { encounterName: encounter.name });
} catch (err) { } catch (err) {
console.error("Error toggling pause state:", err); console.error("Error toggling pause state:", err);
} }
@@ -1656,6 +1687,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
currentTurnParticipantId: activePsInOrder[nextIndex].id, currentTurnParticipantId: activePsInOrder[nextIndex].id,
round: nextRound round: nextRound
}); });
logAction(`${activePsInOrder[nextIndex].name}'s turn (Round ${nextRound})`, { encounterName: encounter.name });
} catch (err) { } catch (err) {
console.error("Error advancing turn:", err); console.error("Error advancing turn:", err);
} }
@@ -1678,6 +1710,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
activeEncounterId: null activeEncounterId: null
}, { merge: true }); }, { merge: true });
logAction(`Combat ended: "${encounter.name}"`, { encounterName: encounter.name });
console.log("Encounter ended and deactivated from Player Display."); console.log("Encounter ended and deactivated from Player Display.");
} catch (err) { } catch (err) {
console.error("Error ending encounter:", err); console.error("Error ending encounter:", err);
@@ -2504,6 +2537,88 @@ function DisplayView() {
); );
} }
// ============================================================================
// LOGS VIEW COMPONENT
// ============================================================================
function LogsView() {
const { data: logs, isLoading } = useFirestoreCollection(getPath.logs(), LOG_QUERY);
const [showClearConfirm, setShowClearConfirm] = useState(false);
const handleClearLogs = async () => {
if (!db) return;
try {
const snapshot = await getDocs(collection(db, getPath.logs()));
if (!snapshot.empty) {
const batch = writeBatch(db);
snapshot.docs.forEach(d => batch.delete(d.ref));
await batch.commit();
}
} catch (err) {
console.error('Error clearing logs:', err);
}
setShowClearConfirm(false);
};
return (
<div className="min-h-screen bg-stone-950 text-stone-100 font-garamond">
<header className="bg-stone-950 p-4 shadow-lg border-b border-amber-900">
<div className="container mx-auto flex justify-between items-center">
<h1 className="text-3xl font-bold text-amber-400 font-cinzel tracking-wide">Combat Log</h1>
<div className="flex gap-2">
<a
href="/"
className="px-4 py-2 rounded-md text-sm font-medium bg-stone-700 hover:bg-stone-600 text-white transition-colors"
>
Back to Tracker
</a>
<button
onClick={() => setShowClearConfirm(true)}
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
</button>
</div>
</div>
</header>
<main className="container mx-auto p-4 md:p-8">
{isLoading ? (
<LoadingSpinner message="Loading logs..." />
) : logs.length === 0 ? (
<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="space-y-1 max-w-5xl">
{logs.map(entry => (
<div key={entry.id} className="flex gap-3 p-3 bg-stone-900 rounded-md border border-stone-800 text-sm">
<span className="text-stone-500 whitespace-nowrap font-mono shrink-0">
{new Date(entry.timestamp).toLocaleString()}
</span>
{entry.encounterName && (
<span className="text-amber-600 whitespace-nowrap shrink-0">[{entry.encounterName}]</span>
)}
<span className="text-stone-100">{entry.message}</span>
</div>
))}
</div>
</>
)}
</main>
<ConfirmationModal
isOpen={showClearConfirm}
onClose={() => setShowClearConfirm(false)}
onConfirm={handleClearLogs}
title="Clear All Logs?"
message="This will permanently delete all log entries and cannot be undone."
/>
</div>
);
}
// ============================================================================ // ============================================================================
// MAIN APP COMPONENT // MAIN APP COMPONENT
// ============================================================================ // ============================================================================
@@ -2514,12 +2629,16 @@ function App() {
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [isPlayerViewOnlyMode, setIsPlayerViewOnlyMode] = useState(false); const [isPlayerViewOnlyMode, setIsPlayerViewOnlyMode] = useState(false);
const [isLogsMode, setIsLogsMode] = useState(false);
useEffect(() => { useEffect(() => {
const queryParams = new URLSearchParams(window.location.search); const queryParams = new URLSearchParams(window.location.search);
if (queryParams.get('playerView') === 'true' || window.location.pathname === '/display') { if (queryParams.get('playerView') === 'true' || window.location.pathname === '/display') {
setIsPlayerViewOnlyMode(true); setIsPlayerViewOnlyMode(true);
} }
if (window.location.pathname === '/logs') {
setIsLogsMode(true);
}
if (!auth) { if (!auth) {
setError("Firebase Auth not initialized. Check your Firebase configuration."); setError("Firebase Auth not initialized. Check your Firebase configuration.");
@@ -2584,17 +2703,31 @@ function App() {
); );
} }
if (isLogsMode) {
return isAuthReady ? <LogsView /> : <LoadingSpinner message="Authenticating..." />;
}
return ( return (
<div className="min-h-screen bg-stone-950 text-stone-100 font-garamond"> <div className="min-h-screen bg-stone-950 text-stone-100 font-garamond">
<header className="bg-stone-950 p-4 shadow-lg border-b border-amber-900"> <header className="bg-stone-950 p-4 shadow-lg border-b border-amber-900">
<div className="container mx-auto flex justify-between items-center"> <div className="container mx-auto flex justify-between items-center">
<h1 className="text-3xl font-bold text-amber-400 font-cinzel tracking-wide">TTRPG Initiative Tracker</h1> <h1 className="text-3xl font-bold text-amber-400 font-cinzel tracking-wide">TTRPG Initiative Tracker</h1>
<button <div className="flex gap-2">
onClick={openPlayerWindow} <a
className="px-4 py-2 rounded-md text-sm font-medium transition-colors bg-amber-700 hover:bg-amber-800 text-white flex items-center" href="/logs"
> target="_blank"
<ExternalLink size={16} className="mr-2" /> Open Player Window rel="noopener noreferrer"
</button> className="px-4 py-2 rounded-md text-sm font-medium transition-colors bg-stone-700 hover:bg-stone-600 text-white flex items-center"
>
<ScrollText size={16} className="mr-2" /> View Logs
</a>
<button
onClick={openPlayerWindow}
className="px-4 py-2 rounded-md text-sm font-medium transition-colors bg-amber-700 hover:bg-amber-800 text-white flex items-center"
>
<ExternalLink size={16} className="mr-2" /> Open Player Window
</button>
</div>
</div> </div>
</header> </header>