feat: custom conditions per campaign

DM can add freeform custom conditions alongside built-ins. Persists to
campaign doc, survives across encounters, shared with display view.

Implementation:
- ParticipantManager subscribes campaign doc via useFirestoreDocument
- customConditions[] merged with built-in CONDITIONS at render
- Input field + Add button in conditions picker (Enter or click)
- Dedup case-insensitive vs built-ins + existing custom
- maxLength 40
- addCustomCondition writes to campaigns/{id}.customConditions
- Badge render: custom conditions show raw label (no emoji)
- DisplayView: same fallback render for custom ids
- toggleCondition unchanged (already accepts any string id)
- campaignId prop passed from EncounterManager -> ParticipantManager

243 tests green. Build clean.
This commit is contained in:
david raistrick
2026-07-04 18:20:32 -04:00
parent 9c90c1500c
commit 1ad4b660a4
2 changed files with 78 additions and 7 deletions
+6 -1
View File
@@ -45,7 +45,12 @@ Backlog of bugs + long-term items. Milestones live in REWORK_PLAN.md.
Old single-counter broken (successes missing, treated saves as fails). Old single-counter broken (successes missing, treated saves as fails).
Old data lost (user OK — feature didn't work). UI: green ✓ row + red ✕ row. Old data lost (user OK — feature didn't work). UI: green ✓ row + red ✕ row.
### Custom condition field ### Custom conditions field (DONE)
- FIXED — per-campaign freeform conditions. Input field in picker → persists to
`campaigns/{id}.customConditions[]`. Merged with built-ins at render.
ParticipantManager subscribes campaign doc. DisplayView renders custom as
raw label. toggleCondition unchanged (any string id). Dedup case-insensitive.
Enter or button to add. maxLength 40.
- Conditions hardcoded list. No way for DM to add custom. - Conditions hardcoded list. No way for DM to add custom.
### Test integrated timeouts ### Test integrated timeouts
+72 -6
View File
@@ -797,7 +797,7 @@ function CharacterManager({ campaignId, campaignCharacters }) {
// PARTICIPANT MANAGER COMPONENT // PARTICIPANT MANAGER COMPONENT
// ============================================================================ // ============================================================================
function ParticipantManager({ encounter, encounterPath, campaignCharacters }) { function ParticipantManager({ encounter, encounterPath, campaignCharacters, campaignId }) {
const [participantName, setParticipantName] = useState(''); const [participantName, setParticipantName] = useState('');
const [participantType, setParticipantType] = useState('monster'); const [participantType, setParticipantType] = useState('monster');
const [selectedCharacterId, setSelectedCharacterId] = useState(''); const [selectedCharacterId, setSelectedCharacterId] = useState('');
@@ -812,6 +812,17 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
const [itemToDelete, setItemToDelete] = useState(null); const [itemToDelete, setItemToDelete] = useState(null);
const [lastRollDetails, setLastRollDetails] = useState(null); const [lastRollDetails, setLastRollDetails] = useState(null);
const [openConditionsId, setOpenConditionsId] = useState(null); const [openConditionsId, setOpenConditionsId] = useState(null);
const [customConditionInput, setCustomConditionInput] = useState('');
// Per-campaign custom conditions (DM-added freeform strings).
const { data: campaignDoc } = useFirestoreDocument(campaignId ? getPath.campaign(campaignId) : null);
const customConditions = (campaignDoc && campaignDoc.customConditions) || [];
// Merged condition list: built-ins + custom. Custom = { id: label, label, emoji: '' }.
// id may contain spaces/emoji — toggleCondition stores raw string.
const allConditions = [
...CONDITIONS,
...customConditions.map(label => ({ id: label, label, emoji: '' })),
];
const participants = encounter.participants || []; const participants = encounter.participants || [];
@@ -1078,8 +1089,8 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
try { try {
const { patch, log } = combatToggleCondition(encounter, participantId, conditionId); const { patch, log } = combatToggleCondition(encounter, participantId, conditionId);
await storage.updateDoc(encounterPath, patch); await storage.updateDoc(encounterPath, patch);
const cond = CONDITIONS.find(c => c.id === conditionId); const cond = allConditions.find(c => c.id === conditionId);
const condLabel = cond ? `${cond.label} ${cond.emoji}` : conditionId; const condLabel = cond ? `${cond.label} ${cond.emoji || ''}`.trim() : conditionId;
logAction(log.message.replace(conditionId, condLabel), { encounterName: encounter.name }, { logAction(log.message.replace(conditionId, condLabel), { encounterName: encounter.name }, {
encounterPath, encounterPath,
updates: log.undo, updates: log.undo,
@@ -1089,6 +1100,22 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
} }
}; };
// Persist new freeform condition to campaign doc. Persists across encounters.
const addCustomCondition = async (label) => {
const trimmed = (label || '').trim();
if (!trimmed || !campaignId) return;
// dedupe against built-ins + existing custom (case-insensitive)
const exists = allConditions.some(c => c.label.toLowerCase() === trimmed.toLowerCase());
if (exists) { setCustomConditionInput(''); return; }
const next = [...customConditions, trimmed];
try {
await storage.updateDoc(getPath.campaign(campaignId), { customConditions: next });
setCustomConditionInput('');
} catch (err) {
// fall through silently
}
};
const handleDragStart = (e, id) => { const handleDragStart = (e, id) => {
setDraggedItemId(id); setDraggedItemId(id);
e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.effectAllowed = 'move';
@@ -1426,7 +1453,16 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
> >
{cond.emoji} {cond.label} {cond.emoji} {cond.label}
</span> </span>
) : null; ) : (
<span
key={cId}
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-full bg-purple-900 border border-purple-600 text-xs text-purple-200 cursor-pointer hover:bg-purple-800"
title={`Remove ${cId}`}
onClick={() => toggleCondition(p.id, cId)}
>
{cId}
</span>
);
})} })}
</div> </div>
)} )}
@@ -1436,7 +1472,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
<div className="mt-2 p-2 bg-stone-900 rounded-md border border-stone-600"> <div className="mt-2 p-2 bg-stone-900 rounded-md border border-stone-600">
<p className="text-xs text-stone-400 mb-1 font-medium">Toggle Conditions</p> <p className="text-xs text-stone-400 mb-1 font-medium">Toggle Conditions</p>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{CONDITIONS.map(cond => { {allConditions.map(cond => {
const active = (p.conditions || []).includes(cond.id); const active = (p.conditions || []).includes(cond.id);
return ( return (
<button <button
@@ -1450,6 +1486,28 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
); );
})} })}
</div> </div>
{/* Add custom condition (per-campaign, freeform string) */}
<div className="mt-2 flex gap-1">
<input
type="text"
value={customConditionInput}
onChange={(e) => setCustomConditionInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); addCustomCondition(customConditionInput); }
}}
placeholder="Add custom condition..."
className="flex-grow px-2 py-1 text-xs rounded bg-stone-800 border border-stone-600 text-stone-200 placeholder-stone-500 focus:outline-none focus:border-amber-500"
maxLength={40}
/>
<button
type="button"
onClick={() => addCustomCondition(customConditionInput)}
disabled={!customConditionInput.trim()}
className="px-2 py-1 rounded text-xs bg-amber-700 border border-amber-500 text-white hover:bg-amber-600 disabled:opacity-40 disabled:cursor-not-allowed"
>
Add
</button>
</div>
</div> </div>
)} )}
</div> </div>
@@ -1960,6 +2018,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
encounter={selectedEncounter} encounter={selectedEncounter}
encounterPath={getPath.encounter(campaignId, selectedEncounter.id)} encounterPath={getPath.encounter(campaignId, selectedEncounter.id)}
campaignCharacters={campaignCharacters} campaignCharacters={campaignCharacters}
campaignId={campaignId}
/> />
</div> </div>
</div> </div>
@@ -2522,7 +2581,14 @@ function DisplayView() {
> >
{cond.emoji} {cond.label} {cond.emoji} {cond.label}
</span> </span>
) : null; ) : (
<span
key={cId}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-purple-900 border border-purple-500 text-xs md:text-sm text-purple-200 font-medium"
>
{cId}
</span>
);
})} })}
</div> </div>
)} )}