Author SHA1 Message Date
david raistrick 3d0dc79206 feat(UI): hide NPC/monster HP toggle (player display)
Mirrors hidePlayerHp. New hideNpcHp flag on activeDisplay doc.
- AdminView: 'Hide NPC/monster HP' switch in Player Display settings.
- DisplayView: HP bar gated by !(hideNpcHp && p.type !== 'character').
  Covers monsters + NPCs (all non-player).
- Default false (show NPC HP) — opposite of player HP default true.

updateDoc patch (BUG-4 pattern). No clobber.

Test: HideHpToggle selector scoped to 'hide player hp' (now 2 switches).
2026-07-02 11:29:02 -04:00
david raistrick 4b8b8bccfb feat(UI): collapsible Campaigns section
Campaigns heading -> clickable button. ChevronDown expanded,
ChevronRight collapsed. Toggles grid visibility. Shows count
(e.g. 'Campaigns (61)'). Preserves Create Campaign button.

State: campaignsCollapsed in AdminView. aria-expanded/controls for a11y.
2026-07-02 11:17:04 -04:00
david raistrick c0998da0a7 fix(BUG-4): updateDoc patch on activeDisplay (not setDoc replace)
All 5 storage.setDoc(activeDisplay, {...}, {merge:true}) →
storage.updateDoc(activeDisplay, {...}).

setDoc merge:true worked in prod (firebase honors merge) but ws adapter
+ mock ignore opts arg entirely → clobbers doc. updateDoc uses PATCH
across all adapters (firebase real updateDoc, ws PATCH endpoint, mock
merge). Consistent, no clobber.

Sites fixed:
- hidePlayerHp toggle
- startEncounter (set active ids)
- endEncounter (null active ids)
- deactivate active display
- activate new display

TDD: HideHpToggle.test RED first (assert updateDoc patch, impl still
setDoc → 0 calls found). GREEN after switch.

Char tests updated: Encounter.characterization (2) + Combat.characterization
(2) assert updateDoc on activeDisplay, not setDoc.

BUG-4: prod was already fixed (merge:true), test was RED due to mock
ignoring opts. Now all 3 adapters consistent via updateDoc.
2026-07-01 23:32:23 -04:00
5 changed files with 85 additions and 40 deletions
+8 -1
View File
@@ -90,7 +90,7 @@ REWORK_PLAN.md.
### bug-3 was a halucination has been removed ### bug-3 was a halucination has been removed
### BUG-4: hide-player-HP breaks display view (preexisting) ### BUG-4: hide-player-HP breaks display view (preexisting) --- PROD FIXED, TEST RED (mock bug)
- **Broader than hide-HP**: ALL 5 `storage.setDoc(getPath.activeDisplay(), ...)` calls - **Broader than hide-HP**: ALL 5 `storage.setDoc(getPath.activeDisplay(), ...)` calls
use `{merge:true}` which is IGNORED (setDoc = replace per contract). use `{merge:true}` which is IGNORED (setDoc = replace per contract).
Each write clobbers other fields on activeDisplay/status doc. Each write clobbers other fields on activeDisplay/status doc.
@@ -108,6 +108,13 @@ REWORK_PLAN.md.
activeEncounterId with null (setDoc replace vs updateDoc patch). activeEncounterId with null (setDoc replace vs updateDoc patch).
- Fix: use updateDoc (patch) not setDoc (replace); or include all existing - Fix: use updateDoc (patch) not setDoc (replace); or include all existing
fields when writing. fields when writing.
- Status update (2026-07): all 5 sites now use `{merge:true}`. Real firebase
adapter honors merge → production works. BUT jsdom test still RED because
`src/__mocks__/firebase/firestore.js` setDoc records call, IGNORES opts
(no actual merge). Mock must simulate firebase merge semantics for test
to pass. Fix = mock setDoc: if opts.merge, MOCK_DB.merge(path,data) else
replace. OR change App.js setDoc(merge) → updateDoc (cleaner, ws adapter
uses PATCH). Decide which.
- Test: render App + DisplayView, toggle hide-HP, assert display still shows - Test: render App + DisplayView, toggle hide-HP, assert display still shows
encounter (not paused). encounter (not paused).
+56 -18
View File
@@ -8,7 +8,7 @@ import {
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, ScrollText, StopCircle as StopCircleIcon, Users2, Dices, ChevronUp, ChevronDown, ScrollText,
Maximize2, Minimize2, Moon, Coffee, Clock Maximize2, Minimize2, Moon, Coffee, Clock, ChevronRight
} from 'lucide-react'; } from 'lucide-react';
// Custom CSS for death animation (player view only) // Custom CSS for death animation (player view only)
@@ -1658,16 +1658,26 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
const [showEndConfirm, setShowEndConfirm] = useState(false); const [showEndConfirm, setShowEndConfirm] = useState(false);
const { data: activeDisplayData } = useFirestoreDocument(getPath.activeDisplay()); const { data: activeDisplayData } = useFirestoreDocument(getPath.activeDisplay());
const hidePlayerHp = activeDisplayData?.hidePlayerHp ?? true; const hidePlayerHp = activeDisplayData?.hidePlayerHp ?? true;
const hideNpcHp = activeDisplayData?.hideNpcHp ?? false;
const handleToggleHidePlayerHp = async () => { const handleToggleHidePlayerHp = async () => {
if (!db) return; if (!db) return;
try { try {
await storage.setDoc(getPath.activeDisplay(), { hidePlayerHp: !hidePlayerHp }, { merge: true }); await storage.updateDoc(getPath.activeDisplay(), { hidePlayerHp: !hidePlayerHp });
} catch (err) { } catch (err) {
console.error("Error toggling hidePlayerHp:", err); console.error("Error toggling hidePlayerHp:", err);
} }
}; };
const handleToggleHideNpcHp = async () => {
if (!db) return;
try {
await storage.updateDoc(getPath.activeDisplay(), { hideNpcHp: !hideNpcHp });
} catch (err) {
console.error("Error toggling hideNpcHp:", err);
}
};
const handleStartEncounter = async () => { const handleStartEncounter = async () => {
if (!db || !encounter.participants || encounter.participants.length === 0) { if (!db || !encounter.participants || encounter.participants.length === 0) {
alert("Add participants first."); alert("Add participants first.");
@@ -1695,10 +1705,10 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
turnOrderIds: sortedParticipants.map(p => p.id) turnOrderIds: sortedParticipants.map(p => p.id)
}); });
await storage.setDoc(getPath.activeDisplay(), { await storage.updateDoc(getPath.activeDisplay(), {
activeCampaignId: campaignId, activeCampaignId: campaignId,
activeEncounterId: encounter.id activeEncounterId: encounter.id
}, { merge: true }); });
logAction(`Combat started: "${encounter.name}" — ${sortedParticipants[0].name}'s turn (Round 1)`, { encounterName: encounter.name }, { logAction(`Combat started: "${encounter.name}" — ${sortedParticipants[0].name}'s turn (Round 1)`, { encounterName: encounter.name }, {
encounterPath, encounterPath,
@@ -1826,10 +1836,10 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
turnOrderIds: [] turnOrderIds: []
}); });
await storage.setDoc(getPath.activeDisplay(), { await storage.updateDoc(getPath.activeDisplay(), {
activeCampaignId: null, activeCampaignId: null,
activeEncounterId: null activeEncounterId: null
}, { merge: true }); });
logAction(`Combat ended: "${encounter.name}"`, { encounterName: encounter.name }, { logAction(`Combat ended: "${encounter.name}"`, { encounterName: encounter.name }, {
encounterPath, encounterPath,
@@ -1915,6 +1925,17 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${hidePlayerHp ? 'translate-x-4' : 'translate-x-0'}`} /> <span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${hidePlayerHp ? 'translate-x-4' : 'translate-x-0'}`} />
</button> </button>
</label> </label>
<label className="flex items-center justify-between cursor-pointer gap-2 mt-2">
<span className="text-sm text-stone-300">Hide NPC/monster HP</span>
<button
role="switch"
aria-checked={hideNpcHp}
onClick={handleToggleHideNpcHp}
className={`relative inline-flex h-5 w-9 flex-shrink-0 rounded-full border-2 border-transparent transition-colors focus:outline-none ${hideNpcHp ? 'bg-amber-600' : 'bg-stone-600'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${hideNpcHp ? 'translate-x-4' : 'translate-x-0'}`} />
</button>
</label>
</div> </div>
</div> </div>
@@ -2044,15 +2065,15 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
const currentActiveEncounter = activeDisplayInfo?.activeEncounterId; const currentActiveEncounter = activeDisplayInfo?.activeEncounterId;
if (currentActiveCampaign === campaignId && currentActiveEncounter === encounterId) { if (currentActiveCampaign === campaignId && currentActiveEncounter === encounterId) {
await storage.setDoc(getPath.activeDisplay(), { await storage.updateDoc(getPath.activeDisplay(), {
activeCampaignId: null, activeCampaignId: null,
activeEncounterId: null, activeEncounterId: null,
}, { merge: true }); });
} else { } else {
await storage.setDoc(getPath.activeDisplay(), { await storage.updateDoc(getPath.activeDisplay(), {
activeCampaignId: campaignId, activeCampaignId: campaignId,
activeEncounterId: encounterId, activeEncounterId: encounterId,
}, { merge: true }); });
} }
} catch (err) { } catch (err) {
console.error("Error toggling Player Display:", err); console.error("Error toggling Player Display:", err);
@@ -2195,6 +2216,7 @@ function AdminView({ userId }) {
const [showCreateModal, setShowCreateModal] = useState(false); const [showCreateModal, setShowCreateModal] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [itemToDelete, setItemToDelete] = useState(null); const [itemToDelete, setItemToDelete] = useState(null);
const [campaignsCollapsed, setCampaignsCollapsed] = useState(false);
useEffect(() => { useEffect(() => {
if (campaignsData && db) { if (campaignsData && db) {
@@ -2322,7 +2344,18 @@ function AdminView({ userId }) {
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<div className="flex justify-between items-center mb-4"> <div className="flex justify-between items-center mb-4">
<h2 className="text-2xl font-semibold text-amber-300 font-cinzel tracking-wide">Campaigns</h2> <button
onClick={() => setCampaignsCollapsed(c => !c)}
className="flex items-center gap-2 text-2xl font-semibold text-amber-300 font-cinzel tracking-wide hover:text-amber-200 transition-colors"
aria-expanded={!campaignsCollapsed}
aria-controls="campaigns-grid"
>
{campaignsCollapsed
? <ChevronRight size={24} />
: <ChevronDown size={24} />}
Campaigns
<span className="text-sm font-normal text-stone-400">({campaignsWithDetails.length})</span>
</button>
<button <button
onClick={() => setShowCreateModal(true)} onClick={() => setShowCreateModal(true)}
className="bg-red-700 hover:bg-red-800 text-white font-bold py-2 px-4 rounded-lg flex items-center transition-colors" className="bg-red-700 hover:bg-red-800 text-white font-bold py-2 px-4 rounded-lg flex items-center transition-colors"
@@ -2331,12 +2364,14 @@ function AdminView({ userId }) {
</button> </button>
</div> </div>
{campaignsWithDetails.length === 0 && !isLoadingCampaigns && ( {!campaignsCollapsed && (
<p className="text-stone-400">No campaigns yet. Create one to get started!</p> <>
)} {campaignsWithDetails.length === 0 && !isLoadingCampaigns && (
<p className="text-stone-400">No campaigns yet. Create one to get started!</p>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div id="campaigns-grid" className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{campaignsWithDetails.map(campaign => { {campaignsWithDetails.map(campaign => {
const cardStyle = campaign.playerDisplayBackgroundUrl const cardStyle = campaign.playerDisplayBackgroundUrl
? { backgroundImage: `url(${campaign.playerDisplayBackgroundUrl})` } ? { backgroundImage: `url(${campaign.playerDisplayBackgroundUrl})` }
: {}; : {};
@@ -2382,7 +2417,9 @@ function AdminView({ userId }) {
</div> </div>
); );
})} })}
</div> </div>
</>
)}
</div> </div>
{showCreateModal && ( {showCreateModal && (
@@ -2576,6 +2613,7 @@ function DisplayView() {
const { name, participants, round, currentTurnParticipantId, isStarted, isPaused } = activeEncounterData; const { name, participants, round, currentTurnParticipantId, isStarted, isPaused } = activeEncounterData;
const hidePlayerHp = activeDisplayData?.hidePlayerHp ?? true; const hidePlayerHp = activeDisplayData?.hidePlayerHp ?? true;
const hideNpcHp = activeDisplayData?.hideNpcHp ?? false;
let participantsToRender = []; let participantsToRender = [];
if (participants) { if (participants) {
@@ -2677,7 +2715,7 @@ function DisplayView() {
</span> </span>
</div> </div>
{!(hidePlayerHp && p.type === 'character') && ( {!(hidePlayerHp && p.type === 'character') && !(hideNpcHp && p.type !== 'character') && (
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div className="w-full bg-stone-700 rounded-full h-6 md:h-8 relative overflow-hidden border-2 border-stone-600"> <div className="w-full bg-stone-700 rounded-full h-6 md:h-8 relative overflow-hidden border-2 border-stone-600">
<div <div
+6 -6
View File
@@ -41,7 +41,7 @@ describe('Combat -> Firebase', () => {
test('startEncounter: also sets activeDisplay to this encounter', async () => { test('startEncounter: also sets activeDisplay to this encounter', async () => {
await setupWithMonsters(); await setupWithMonsters();
await startCombatViaUI(); await startCombatViaUI();
const adCalls = findCallActiveDisplay('setDoc'); const adCalls = findCallActiveDisplay('updateDoc');
const last = adCalls[adCalls.length - 1]; const last = adCalls[adCalls.length - 1];
expect(last.data.activeCampaignId).toBeTruthy(); expect(last.data.activeCampaignId).toBeTruthy();
expect(last.data.activeEncounterId).toBeTruthy(); expect(last.data.activeEncounterId).toBeTruthy();
@@ -111,26 +111,26 @@ describe('Combat -> Firebase', () => {
fireEvent.click(screen.getByRole('button', { name: /End Combat/i })); fireEvent.click(screen.getByRole('button', { name: /End Combat/i }));
fireEvent.click(await screen.findByRole('button', { name: /Confirm/i })); fireEvent.click(await screen.findByRole('button', { name: /Confirm/i }));
await waitFor(() => { await waitFor(() => {
const adCalls = findCallActiveDisplay('setDoc'); const adCalls = findCallActiveDisplay('updateDoc');
const last = adCalls[adCalls.length - 1]; const last = adCalls[adCalls.length - 1];
return last && last.data.activeCampaignId === null; return last && last.data.activeCampaignId === null;
}); });
const adCalls = findCallActiveDisplay('setDoc'); const adCalls = findCallActiveDisplay('updateDoc');
const last = adCalls[adCalls.length - 1]; const last = adCalls[adCalls.length - 1];
expect(last.data).toMatchObject({ activeCampaignId: null, activeEncounterId: null }); expect(last.data).toMatchObject({ activeCampaignId: null, activeEncounterId: null });
}); });
test('toggleHidePlayerHp: setDoc merge on activeDisplay/status', async () => { test('toggleHidePlayerHp: updateDoc patch on activeDisplay/status', async () => {
await setupWithMonsters(); await setupWithMonsters();
await startCombatViaUI(); await startCombatViaUI();
const switchBtn = screen.getByRole('switch'); const switchBtn = screen.getByRole('switch');
fireEvent.click(switchBtn); fireEvent.click(switchBtn);
await waitFor(() => { await waitFor(() => {
const adCalls = findCallActiveDisplay('setDoc'); const adCalls = findCallActiveDisplay('updateDoc');
const last = adCalls[adCalls.length - 1]; const last = adCalls[adCalls.length - 1];
return last && 'hidePlayerHp' in last.data; return last && 'hidePlayerHp' in last.data;
}); });
const adCalls = findCallActiveDisplay('setDoc'); const adCalls = findCallActiveDisplay('updateDoc');
const last = adCalls[adCalls.length - 1]; const last = adCalls[adCalls.length - 1];
expect(last.data).toHaveProperty('hidePlayerHp'); expect(last.data).toHaveProperty('hidePlayerHp');
}); });
+9 -9
View File
@@ -42,7 +42,7 @@ describe('Encounter -> Firebase', () => {
expect(call.path).toMatch(/campaigns\/[^/]+\/encounters\//); expect(call.path).toMatch(/campaigns\/[^/]+\/encounters\//);
}); });
test('togglePlayerDisplay: setDoc merge on activeDisplay/status', async () => { test('togglePlayerDisplay: updateDoc patch on activeDisplay/status', async () => {
await setupCampaignAndEncounter('Camp D', 'Enc D'); await setupCampaignAndEncounter('Camp D', 'Enc D');
await selectEncounterByName('Enc D'); await selectEncounterByName('Enc D');
@@ -50,33 +50,33 @@ describe('Encounter -> Firebase', () => {
const eyeBtn = await screen.findByTitle('Activate for Player Display'); const eyeBtn = await screen.findByTitle('Activate for Player Display');
fireEvent.click(eyeBtn); fireEvent.click(eyeBtn);
await waitFor(() => findCall('setDoc', 'activeDisplay/status')); await waitFor(() => findCall('updateDoc', 'activeDisplay/status'));
const call = findCall('setDoc', 'activeDisplay/status'); const call = findCall('updateDoc', 'activeDisplay/status');
// activeDisplay/status setDoc is called with merge option in App // BUG-4 fix: updateDoc patch, not setDoc replace (was clobbering fields)
expect(call.data).toMatchObject({ expect(call.data).toMatchObject({
activeCampaignId: expect.any(String), activeCampaignId: expect.any(String),
activeEncounterId: expect.any(String), activeEncounterId: expect.any(String),
}); });
}); });
test('togglePlayerDisplay off: setDoc nulls active ids', async () => { test('togglePlayerDisplay off: updateDoc nulls active ids', async () => {
await setupCampaignAndEncounter('Camp O', 'Enc O'); await setupCampaignAndEncounter('Camp O', 'Enc O');
await selectEncounterByName('Enc O'); await selectEncounterByName('Enc O');
// turn ON // turn ON
const onBtn = await screen.findByTitle('Activate for Player Display'); const onBtn = await screen.findByTitle('Activate for Player Display');
fireEvent.click(onBtn); fireEvent.click(onBtn);
await waitFor(() => findCall('setDoc', 'activeDisplay/status')); await waitFor(() => findCall('updateDoc', 'activeDisplay/status'));
// turn OFF // turn OFF
const offBtn = await screen.findByTitle('Deactivate for Player Display'); const offBtn = await screen.findByTitle('Deactivate for Player Display');
fireEvent.click(offBtn); fireEvent.click(offBtn);
await waitFor(() => { await waitFor(() => {
const calls = findCalls('setDoc', 'activeDisplay/status'); const calls = findCalls('updateDoc', 'activeDisplay/status');
const last = calls[calls.length - 1]; const last = calls[calls.length - 1];
return last.data.activeCampaignId === null; return last.data.activeCampaignId === null;
}); });
const calls = findCalls('setDoc', 'activeDisplay/status'); const calls = findCalls('updateDoc', 'activeDisplay/status');
const last = calls[calls.length - 1]; const last = calls[calls.length - 1];
expect(last.data).toMatchObject({ activeCampaignId: null, activeEncounterId: null }); expect(last.data).toMatchObject({ activeCampaignId: null, activeEncounterId: null });
}); });
@@ -103,7 +103,7 @@ describe('Encounter -> Firebase', () => {
// activate display first // activate display first
const onBtn = await screen.findByTitle('Activate for Player Display'); const onBtn = await screen.findByTitle('Activate for Player Display');
fireEvent.click(onBtn); fireEvent.click(onBtn);
await waitFor(() => findCall('setDoc', 'activeDisplay/status')); await waitFor(() => findCall('updateDoc', 'activeDisplay/status'));
// delete the active encounter // delete the active encounter
const trashBtn = screen.getAllByTitle('Delete Encounter')[0]; const trashBtn = screen.getAllByTitle('Delete Encounter')[0];
+6 -6
View File
@@ -43,21 +43,21 @@ describe('BUG-4: hide-player-HP toggle preserves activeDisplay', () => {
await selectCampaignByName('Camp'); await selectCampaignByName('Camp');
// find the hide-player-HP toggle (role switch) // find the hide-player-HP toggle (role switch)
const toggle = await screen.findByRole('switch', { name: /hide/i }, { timeout: 3000 }); const toggle = await screen.findByRole('switch', { name: /hide player hp/i }, { timeout: 3000 });
// toggle ON // toggle ON
fireEvent.click(toggle); fireEvent.click(toggle);
await waitFor(() => { await waitFor(() => {
const writes = getAdapterCalls().filter( const writes = getAdapterCalls().filter(
c => c.fn === 'setDoc' && c.path.includes('activeDisplay/status') c => c.fn === 'updateDoc' && c.path.includes('activeDisplay/status')
); );
expect(writes.length).toBeGreaterThan(0); expect(writes.length).toBeGreaterThan(0);
const last = writes[writes.length - 1]; const last = writes[writes.length - 1];
// data written must include activeCampaignId AND activeEncounterId // patch must NOT clobber activeCampaignId/activeEncounterId.
// BUG: writes only {hidePlayerHp:true}, clobbering them. // BUG: setDoc replace writes only {hidePlayerHp:true} clobbers.
expect(last.data.activeCampaignId).toBe('c1'); // Fix: updateDoc patch — other fields untouched.
expect(last.data.activeEncounterId).toBe('e1'); expect(last.patch.hidePlayerHp).toBe(true);
}, { timeout: 3000 }); }, { timeout: 3000 });
}); });
}); });