From 1ad4b660a47a00a58c4dd979e6171e89a29213de Mon Sep 17 00:00:00 2001 From: david raistrick <1108844+keen99@users.noreply.github.com> Date: Sat, 4 Jul 2026 18:20:32 -0400 Subject: [PATCH] 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. --- TODO.md | 7 ++++- src/App.js | 78 +++++++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 78 insertions(+), 7 deletions(-) diff --git a/TODO.md b/TODO.md index 337465a..e91266b 100644 --- a/TODO.md +++ b/TODO.md @@ -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 diff --git a/src/App.js b/src/App.js index 6504014..19518a4 100644 --- a/src/App.js +++ b/src/App.js @@ -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} - ) : null; + ) : ( + toggleCondition(p.id, cId)} + > + {cId} + + ); })} )} @@ -1436,7 +1472,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
Toggle Conditions