From d00cc104c951b2e8753b450b045e9bda5883ece2 Mon Sep 17 00:00:00 2001
From: david raistrick <1108844+keen99@users.noreply.github.com>
Date: Wed, 1 Jul 2026 23:00:40 -0400
Subject: [PATCH] feat(FEAT-3): reslot on all participant mutation paths
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Reslot (stable sort by init desc, tie-break = original array index) now
fires on all 4 paths that can change order:
1. Add participant — sortParticipantsByInitiative([...parts, new], parts)
2. Edit modal save (handleUpdateParticipant) — reslot + syncTurnOrder
3. Drag reorder — splice move (already correct, untouched)
4. Inline init field — reslot (already committed 08c27c1)
Before: add appended (ignored init), edit modal overwrote value without
moving slot. Both caused list order to drift from init order until
startEncounter (sorts once). Now any init change immediately reslots
into correct position. Display + AdminView reflect order.
Stable sort preserves drag order within ties (tie-break = original index
= reflects prior drag). Move-one semantics: only changed element moves.
EditParticipantModal: added htmlFor/id link on Initiative label (was
missing — a11y + testable).
Tests: ReslotAllPaths.test.js (2). RED first (add appended, edit modal
no reslot), green after impl.
---
TODO.md | 1 +
src/App.js | 12 +++--
src/tests/ReslotAllPaths.test.js | 76 ++++++++++++++++++++++++++++++++
3 files changed, 86 insertions(+), 3 deletions(-)
create mode 100644 src/tests/ReslotAllPaths.test.js
diff --git a/TODO.md b/TODO.md
index cad823e..54e6164 100644
--- a/TODO.md
+++ b/TODO.md
@@ -10,6 +10,7 @@ REWORK_PLAN.md.
### feat - campaign section rollup
+### feat - add all characters to participants list
### FEAT-M6: Transactional undo (moved from REWORK_PLAN)
- Every mutating action writes event: `(type, payload, undo_payload, undone, ts)`.
diff --git a/src/App.js b/src/App.js
index da845be..97cd7a8 100644
--- a/src/App.js
+++ b/src/App.js
@@ -444,9 +444,10 @@ function EditParticipantModal({ participant, onClose, onSave }) {
/>
-
+
setInitiative(e.target.value)}
className="mt-1 block w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
@@ -857,7 +858,8 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
try {
await storage.updateDoc(encounterPath, {
- participants: [...participants, newParticipant]
+ participants: sortParticipantsByInitiative([...participants, newParticipant], participants),
+ ...syncTurnOrder([...participants, newParticipant]),
});
logAction(`${nameToAdd} added to encounter (Initiative: ${computedInitiative})`, { encounterName: encounter.name }, {
encounterPath,
@@ -939,9 +941,13 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
const updatedParticipants = participants.map(p =>
p.id === editingParticipant.id ? { ...p, ...updatedData } : p
);
+ const reslotted = sortParticipantsByInitiative(updatedParticipants, participants);
try {
- await storage.updateDoc(encounterPath, { participants: updatedParticipants });
+ await storage.updateDoc(encounterPath, {
+ participants: reslotted,
+ ...syncTurnOrder(reslotted),
+ });
setEditingParticipant(null);
} catch (err) {
console.error("Error updating participant:", err);
diff --git a/src/tests/ReslotAllPaths.test.js b/src/tests/ReslotAllPaths.test.js
new file mode 100644
index 0000000..60d9666
--- /dev/null
+++ b/src/tests/ReslotAllPaths.test.js
@@ -0,0 +1,76 @@
+// RED: reslot must fire on ALL 4 participant-mutation paths.
+// Path 1 add, path 2 edit modal, path 3 drag (already correct), path 4 inline field (already correct).
+// Tests add + edit modal reslot. Drag + inline already covered.
+import React from 'react';
+import { screen, waitFor, within, fireEvent } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, getParticipantForm, addMonsterViaUI } from './testHelpers';
+import { getCalls } from '../__mocks__/firebase/_mock-db';
+
+function lastParticipantsUpdate() {
+ const calls = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/'));
+ const last = calls[calls.length - 1];
+ return last && last.data.participants;
+}
+
+async function addOne(form, name, hp, mod, init) {
+ fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: name } });
+ fireEvent.change(form.getByLabelText(/Init Mod/i), { target: { value: String(mod) } });
+ fireEvent.change(form.getByLabelText(/Max HP/i), { target: { value: String(hp) } });
+ fireEvent.change(form.getByPlaceholderText('auto'), { target: { value: String(init) } });
+ fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
+ await waitFor(() => {
+ const parts = lastParticipantsUpdate();
+ if (!parts || !parts.some(p => p.name === name)) throw new Error('not added');
+ });
+}
+
+describe('reslot on all mutation paths', () => {
+ test('add inserts at correct init position (not append)', async () => {
+ await renderApp();
+ await createCampaignViaUI('Camp');
+ await selectCampaignByName('Camp');
+ await createEncounterViaUI('Enc');
+ await selectEncounterByName('Enc');
+
+ const form = within(getParticipantForm());
+ // add Orc(5) first, then Goblin(8) — Goblin should slot ABOVE Orc, not append below
+ await addOne(form, 'Orc', 15, 0, 5);
+ await addOne(form, 'Goblin', 7, 2, 8);
+
+ const parts = lastParticipantsUpdate();
+ expect(parts.map(p => p.name)).toEqual(['Goblin', 'Orc']);
+ });
+
+ test('edit modal init change reslots participant', async () => {
+ await renderApp();
+ await createCampaignViaUI('Camp2');
+ await selectCampaignByName('Camp2');
+ await createEncounterViaUI('Enc2');
+ await selectEncounterByName('Enc2');
+
+ const form = within(getParticipantForm());
+ await addOne(form, 'Orc', 15, 0, 5);
+ await addOne(form, 'Goblin', 7, 2, 3);
+
+ // pre: Orc(5) before Goblin(3)
+ expect(lastParticipantsUpdate().map(p => p.name)).toEqual(['Orc', 'Goblin']);
+
+ // open edit modal for Goblin, bump init to 8
+ const editBtns = screen.getAllByTitle('Edit');
+ const goblinEdit = editBtns.find(b => b.closest('li')?.textContent.includes('Goblin'));
+ fireEvent.click(goblinEdit);
+
+ await waitFor(() => screen.getByText(`Edit Goblin`));
+ // modal renders after row inputs; take last Initiative-labeled input
+ const initInputs = screen.getAllByLabelText('Initiative');
+ fireEvent.change(initInputs[initInputs.length - 1], { target: { value: '8' } });
+ const saveBtns = screen.getAllByRole('button', { name: /Save/i });
+ fireEvent.click(saveBtns[saveBtns.length - 1]);
+
+ await waitFor(() => {
+ const parts = lastParticipantsUpdate();
+ expect(parts.map(p => p.name)).toEqual(['Goblin', 'Orc']);
+ });
+ });
+});