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 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.
### Test integrated timeouts
+72 -6
View File
@@ -797,7 +797,7 @@ function CharacterManager({ campaignId, campaignCharacters }) {
// PARTICIPANT MANAGER COMPONENT
// ============================================================================
function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
function ParticipantManager({ encounter, encounterPath, campaignCharacters, campaignId }) {
const [participantName, setParticipantName] = useState('');
const [participantType, setParticipantType] = useState('monster');
const [selectedCharacterId, setSelectedCharacterId] = useState('');
@@ -812,6 +812,17 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
const [itemToDelete, setItemToDelete] = useState(null);
const [lastRollDetails, setLastRollDetails] = 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 || [];
@@ -1078,8 +1089,8 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
try {
const { patch, log } = combatToggleCondition(encounter, participantId, conditionId);
await storage.updateDoc(encounterPath, patch);
const cond = CONDITIONS.find(c => c.id === conditionId);
const condLabel = cond ? `${cond.label} ${cond.emoji}` : conditionId;
const cond = allConditions.find(c => c.id === conditionId);
const condLabel = cond ? `${cond.label} ${cond.emoji || ''}`.trim() : conditionId;
logAction(log.message.replace(conditionId, condLabel), { encounterName: encounter.name }, {
encounterPath,
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) => {
setDraggedItemId(id);
e.dataTransfer.effectAllowed = 'move';
@@ -1426,7 +1453,16 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
>
{cond.emoji} {cond.label}
</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>
)}
@@ -1436,7 +1472,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
<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>
<div className="flex flex-wrap gap-1">
{CONDITIONS.map(cond => {
{allConditions.map(cond => {
const active = (p.conditions || []).includes(cond.id);
return (
<button
@@ -1450,6 +1486,28 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
);
})}
</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>
@@ -1960,6 +2018,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
encounter={selectedEncounter}
encounterPath={getPath.encounter(campaignId, selectedEncounter.id)}
campaignCharacters={campaignCharacters}
campaignId={campaignId}
/>
</div>
</div>
@@ -2522,7 +2581,14 @@ function DisplayView() {
>
{cond.emoji} {cond.label}
</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>
)}