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
+147 -14
View File
@@ -1,12 +1,12 @@
import React, { useState, useEffect, useRef, useMemo } from 'react';
import { initializeApp } from 'firebase/app';
import { getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken } from 'firebase/auth';
import { getFirestore, doc, setDoc, getDoc, getDocs, collection, onSnapshot, updateDoc, deleteDoc, query, writeBatch } from 'firebase/firestore';
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
import { getFirestore, doc, setDoc, getDoc, getDocs, addDoc, collection, onSnapshot, updateDoc, deleteDoc, query, orderBy, limit, writeBatch } from 'firebase/firestore';
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
} from 'lucide-react';
// Custom CSS for death animation (player view only)
@@ -126,7 +126,8 @@ const getPath = {
campaign: (id) => `${PUBLIC_DATA_PATH}/campaigns/${id}`,
encounters: (campaignId) => `${PUBLIC_DATA_PATH}/campaigns/${campaignId}/encounters`,
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.
const computeTurnOrderAfterRemoval = (encounter, removedId, updatedParticipants) => {
if (!encounter.isStarted) return {};
@@ -884,6 +896,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
await updateDoc(doc(db, encounterPath), {
participants: [...participants, newParticipant]
});
logAction(`${nameToAdd} added to encounter (Initiative: ${finalInitiative})`, { encounterName: encounter.name });
setLastRollDetails({
name: nameToAdd,
@@ -983,6 +996,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
participants: updatedParticipants,
...computeTurnOrderAfterRemoval(encounter, itemToDelete.id, updatedParticipants)
});
logAction(`${itemToDelete.name} removed from encounter`, { encounterName: encounter.name });
} catch (err) {
console.error("Error deleting participant:", err);
alert("Failed to delete participant. Please try again.");
@@ -1009,6 +1023,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
try {
await updateDoc(doc(db, encounterPath), { participants: updatedParticipants, ...turnUpdates });
logAction(`${participant.name} ${newIsActive ? 'reactivated' : 'deactivated'}`, { encounterName: encounter.name });
} catch (err) {
console.error("Error toggling active state:", err);
}
@@ -1073,6 +1088,14 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
try {
await updateDoc(doc(db, encounterPath), { participants: updatedParticipants, ...turnUpdates });
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) {
console.error("Error applying HP change:", err);
}
@@ -1124,16 +1147,22 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
const toggleCondition = async (participantId, conditionId) => {
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 => {
if (p.id !== participantId) return p;
const current = p.conditions || [];
const next = current.includes(conditionId)
const next = wasActive
? current.filter(c => c !== conditionId)
: [...current, conditionId];
return { ...p, conditions: next };
});
try {
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) {
console.error("Error updating conditions:", err);
}
@@ -1586,6 +1615,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
activeEncounterId: encounter.id
}, { 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.");
} catch (err) {
console.error("Error starting encounter:", err);
@@ -1610,6 +1640,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
isPaused: newPausedState,
turnOrderIds: newTurnOrderIds
});
logAction(`Combat ${newPausedState ? 'paused' : 'resumed'}: "${encounter.name}"`, { encounterName: encounter.name });
} catch (err) {
console.error("Error toggling pause state:", err);
}
@@ -1656,6 +1687,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
currentTurnParticipantId: activePsInOrder[nextIndex].id,
round: nextRound
});
logAction(`${activePsInOrder[nextIndex].name}'s turn (Round ${nextRound})`, { encounterName: encounter.name });
} catch (err) {
console.error("Error advancing turn:", err);
}
@@ -1678,6 +1710,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
activeEncounterId: null
}, { merge: true });
logAction(`Combat ended: "${encounter.name}"`, { encounterName: encounter.name });
console.log("Encounter ended and deactivated from Player Display.");
} catch (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
// ============================================================================
@@ -2514,12 +2629,16 @@ function App() {
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [isPlayerViewOnlyMode, setIsPlayerViewOnlyMode] = useState(false);
const [isLogsMode, setIsLogsMode] = useState(false);
useEffect(() => {
const queryParams = new URLSearchParams(window.location.search);
if (queryParams.get('playerView') === 'true' || window.location.pathname === '/display') {
setIsPlayerViewOnlyMode(true);
}
if (window.location.pathname === '/logs') {
setIsLogsMode(true);
}
if (!auth) {
setError("Firebase Auth not initialized. Check your Firebase configuration.");
@@ -2584,17 +2703,31 @@ function App() {
);
}
if (isLogsMode) {
return isAuthReady ? <LogsView /> : <LoadingSpinner message="Authenticating..." />;
}
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">TTRPG Initiative Tracker</h1>
<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 className="flex gap-2">
<a
href="/logs"
target="_blank"
rel="noopener noreferrer"
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>
</header>