From 415026792591eb4eb02b81b174e34da7f242c4e6 Mon Sep 17 00:00:00 2001 From: robert Date: Sat, 16 May 2026 10:25:17 -0400 Subject: [PATCH] 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 --- src/App.js | 161 ++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 147 insertions(+), 14 deletions(-) diff --git a/src/App.js b/src/App.js index 03e08f3..9824feb 100644 --- a/src/App.js +++ b/src/App.js @@ -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 ( +
+
+
+

Combat Log

+
+ + ← Back to Tracker + + +
+
+
+ +
+ {isLoading ? ( + + ) : logs.length === 0 ? ( +

No log entries yet.

+ ) : ( + <> +

{logs.length} entries — newest first

+
+ {logs.map(entry => ( +
+ + {new Date(entry.timestamp).toLocaleString()} + + {entry.encounterName && ( + [{entry.encounterName}] + )} + {entry.message} +
+ ))} +
+ + )} +
+ + setShowClearConfirm(false)} + onConfirm={handleClearLogs} + title="Clear All Logs?" + message="This will permanently delete all log entries and cannot be undone." + /> +
+ ); +} + // ============================================================================ // 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 ? : ; + } + return (

TTRPG Initiative Tracker

- +
+ + View Logs + + +