// ── Storage ─────────────────────────────────────────────────────────────────── const PLANNER_KEY = 'ff14-planner-plans'; const PLANNER_ACTIVE_KEY = 'ff14-planner-active-plan'; function loadPlans() { try { return JSON.parse(localStorage.getItem(PLANNER_KEY) || '[]'); } catch { return []; } } function savePlans(plans) { localStorage.setItem(PLANNER_KEY, JSON.stringify(plans)); } // ── CRUD ────────────────────────────────────────────────────────────────────── function createPlan(name) { const plan = { id: crypto.randomUUID(), name: name.trim() || 'Unbenannter Plan', createdAt: Date.now(), updatedAt: Date.now(), source: null, mitigationNames: {}, jobComposition: Array(8).fill(''), mechanics: [] }; const all = loadPlans(); all.push(plan); savePlans(all); return plan; } function getPlan(id) { return loadPlans().find(p => p.id === id) ?? null; } function updatePlan(id, changes) { const all = loadPlans(); const idx = all.findIndex(p => p.id === id); if (idx === -1) return null; all[idx] = { ...all[idx], ...changes, updatedAt: Date.now() }; savePlans(all); return all[idx]; } function deletePlan(id) { savePlans(loadPlans().filter(p => p.id !== id)); } function copyPlan(id) { const all = loadPlans(); const orig = all.find(p => p.id === id); if (!orig) return null; const copy = { ...JSON.parse(JSON.stringify(orig)), id: crypto.randomUUID(), name: orig.name + ' (Kopie)', createdAt: Date.now(), updatedAt: Date.now() }; all.push(copy); savePlans(all); return copy; } // ── State ───────────────────────────────────────────────────────────────────── let activePlanId = null; // ── Helpers ─────────────────────────────────────────────────────────────────── function escHtml(str) { return String(str ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function fmtDate(ts) { return new Date(ts).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }); } function fmtTimestamp(ms) { const s = Math.floor(ms / 1000); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; } function fmtNumber(n) { return Number(n).toLocaleString('de-DE'); } function assignmentAbilityName(assignment, plan = null) { const key = assignment?.ability ?? ''; return assignment?.abilityName ?? plan?.mitigationNames?.[key] ?? key; } function plannerLanguage() { return window.App?.language || localStorage.getItem('ff14-mitigator-language') || 'en'; } function sameMechanic(existing, incoming, source) { const fightStart = source?.fightStart ?? 0; const incomingRel = incoming.timestamp - fightStart; if (existing.abilityId && incoming.abilityId && existing.abilityId === incoming.abilityId) { return Math.abs(existing.timestamp - incomingRel) < 1500; } return Math.abs(existing.timestamp - incomingRel) < 1500; } // ── Rendering: Plan List ────────────────────────────────────────────────────── function renderPlanList() { const el = document.getElementById('plan-list'); if (!el) return; const plans = loadPlans(); if (plans.length === 0) { el.innerHTML = '
Noch keine Pläne vorhanden
'; return; } el.innerHTML = plans.map(p => `
${escHtml(p.name)}
${p.mechanics.length} Mechaniken · ${fmtDate(p.updatedAt)}
`).join(''); el.querySelectorAll('.plan-item').forEach(row => { row.addEventListener('click', e => { if (e.target.closest('.plan-item-actions')) return; openPlan(row.dataset.id); }); }); el.querySelectorAll('.plan-btn-copy').forEach(btn => { btn.addEventListener('click', e => { e.stopPropagation(); const copy = copyPlan(btn.dataset.id); if (copy) { renderPlanList(); openPlan(copy.id); } }); }); el.querySelectorAll('.plan-btn-delete').forEach(btn => { btn.addEventListener('click', e => { e.stopPropagation(); const plan = getPlan(btn.dataset.id); if (!plan) return; if (!confirm(`Plan "${plan.name}" löschen?`)) return; deletePlan(btn.dataset.id); if (activePlanId === btn.dataset.id) { activePlanId = null; renderPlanDetail(null); } renderPlanList(); }); }); } // ── Rendering: Plan Detail ──────────────────────────────────────────────────── function renderPlanDetail(plan) { const noplan = document.getElementById('planner-no-plan'); const content = document.getElementById('plan-content'); if (!noplan || !content) return; if (!plan) { noplan.style.display = ''; content.style.display = 'none'; return; } noplan.style.display = 'none'; content.style.display = ''; content.innerHTML = `
${escHtml(plan.name)}
Erstellt ${fmtDate(plan.createdAt)} · ${plan.mechanics.length} Mechaniken
Jobaufstellung
${renderJobSlotsHtml(plan)}
Mechaniken
${renderMechanicListHtml(plan)}
`; document.getElementById('plan-name-edit-btn')?.addEventListener('click', () => { startRename(plan.id, plan.name); }); initJobSlots(plan.id); initMechanicClicks(plan.id); } function renderMechanicListHtml(plan) { if (plan.mechanics.length === 0) { return `
📋

Noch keine Mechaniken

Importiere einen Log aus dem Analyse-Tab

`; } const activeJobSet = new Set(plan.jobComposition.filter(j => j)); return plan.mechanics.map(m => { const sorted = sortedAssignments(m.assignments); const assignHtml = sorted.length === 0 ? 'Keine Zuweisung' : sorted.map(a => { const cls = a.buffType === 'debuff' ? 'badge-assign-debuff' : a.buffType === 'shield' ? 'badge-assign-shield' : 'badge-assign-buff'; const isMissing = !!a.job && !activeJobSet.has(a.job); const icon = MITIG_ICONS[a.ability] ?? ''; const ability = assignmentAbilityName(a, plan); const label = a.job ? `${escHtml(a.job)} · ${escHtml(ability)}` : escHtml(ability); const title = isMissing ? `${escHtml(a.job)} nicht in Jobaufstellung` : ''; return ` ${icon ? `` : ''} ${label} `; }).join(''); return `
${escHtml(fmtTimestamp(m.timestamp))}
${m.phase ? `
${escHtml(m.phase)}
` : ''}
${escHtml(m.name)}
${m.unmitigatedDamage ? `
${fmtNumber(m.unmitigatedDamage)} unmitigiert
` : '' }
${assignHtml}
${m.notes ? `
${escHtml(m.notes)}
` : ''}
Klicken zum Bearbeiten
`; }).join(''); } // ── Job Slots ───────────────────────────────────────────────────────────────── function renderJobSlotsHtml(plan) { return Array.from({ length: 8 }, (_, i) => { const job = plan.jobComposition[i] ?? ''; const role = JOB_ROLE[job] ?? ''; return `
`; }).join(''); } function initJobSlots(planId) { const grid = document.getElementById('job-slots-grid'); if (!grid) return; grid.addEventListener('change', e => { const sel = e.target.closest('.job-slot-select'); if (!sel) return; const plan = getPlan(planId); if (!plan) return; const comp = [...plan.jobComposition]; comp[parseInt(sel.dataset.idx, 10)] = sel.value; updatePlan(planId, { jobComposition: comp }); const slot = sel.closest('.job-slot'); if (slot) { const role = JOB_ROLE[sel.value] ?? ''; slot.className = 'job-slot' + (role ? ` job-slot--${role}` : ''); } refreshMechanicList(planId); }); } // ── Mechanic list helpers ───────────────────────────────────────────────────── function refreshMechanicList(planId) { const plan = getPlan(planId); if (!plan) return; const el = document.getElementById('mechanic-list'); if (el) el.innerHTML = renderMechanicListHtml(plan); } function removeAssignment(planId, mechanicId, abilityName) { const plan = getPlan(planId); if (!plan) return; const mechanic = plan.mechanics.find(m => m.id === mechanicId); if (!mechanic) return; mechanic.assignments = mechanic.assignments.filter(a => a.ability !== abilityName); updatePlan(planId, { mechanics: plan.mechanics }); refreshMechanicList(planId); if (abilityModalMechanicId === mechanicId) renderAbilityModalContent(); } function deleteMechanic(planId, mechanicId) { const plan = getPlan(planId); if (!plan) return; plan.mechanics = plan.mechanics.filter(m => m.id !== mechanicId); updatePlan(planId, { mechanics: plan.mechanics }); refreshMechanicList(planId); renderPlanList(); } function initMechanicClicks(planId) { const list = document.getElementById('mechanic-list'); if (!list) return; list.addEventListener('click', e => { const removeBtn = e.target.closest('.badge-remove'); if (removeBtn) { e.stopPropagation(); removeAssignment(planId, removeBtn.dataset.mechanicId, removeBtn.dataset.ability); return; } const deleteBtn = e.target.closest('.mechanic-delete-btn'); if (deleteBtn) { e.stopPropagation(); deleteMechanic(planId, deleteBtn.dataset.mechanicId); return; } const card = e.target.closest('.mechanic-card'); if (!card) return; showAbilityModal(planId, card.dataset.mechanicId); }); } // ── Rename ──────────────────────────────────────────────────────────────────── function startRename(id, currentName) { const display = document.getElementById('plan-name-display'); const editBtn = document.getElementById('plan-name-edit-btn'); if (!display) return; display.innerHTML = ``; const input = document.getElementById('plan-name-input'); input.focus(); input.select(); if (editBtn) editBtn.style.display = 'none'; let saved = false; const save = () => { if (saved) return; saved = true; const newName = input.value.trim() || currentName; updatePlan(id, { name: newName }); renderPlanList(); openPlan(id); }; input.addEventListener('keydown', e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') { saved = true; display.innerHTML = `${escHtml(currentName)}`; if (editBtn) editBtn.style.display = ''; } }); input.addEventListener('blur', save); } // ── Open plan ───────────────────────────────────────────────────────────────── function openPlan(id) { activePlanId = id; localStorage.setItem(PLANNER_ACTIVE_KEY, id); renderPlanList(); renderPlanDetail(getPlan(id)); refreshPlanLanguage(id); } // ── New plan form ───────────────────────────────────────────────────────────── function initNewPlanForm() { const btn = document.getElementById('planner-new-btn'); const form = document.getElementById('planner-new-form'); const input = document.getElementById('planner-new-name'); const save = document.getElementById('planner-new-save'); const cancel = document.getElementById('planner-new-cancel'); if (!btn) return; btn.addEventListener('click', () => { form.style.display = ''; input.value = ''; input.focus(); }); cancel.addEventListener('click', () => { form.style.display = 'none'; }); const doCreate = () => { const name = input.value.trim(); if (!name) { input.focus(); return; } const plan = createPlan(name); form.style.display = 'none'; renderPlanList(); openPlan(plan.id); }; save.addEventListener('click', doCreate); input.addEventListener('keydown', e => { if (e.key === 'Enter') doCreate(); if (e.key === 'Escape') form.style.display = 'none'; }); } // ── Ability → Job mapping ───────────────────────────────────────────────────── const ABILITY_JOB_MAP = { 'Passage of Arms': 'PLD', 'Divine Veil': 'PLD', 'Guardian': 'PLD', 'Shake It Off': 'WAR', 'Bloodwhetting': 'WAR', 'Dark Missionary': 'DRK', 'Heart of Light': 'GNB', 'Temperance': 'WHM', 'Divine Benison': 'WHM', 'Divine Caress': 'WHM', 'Sacred Soil': 'SCH', 'Expedient': 'SCH', 'Fey Illumination': 'SCH', 'Galvanize': 'SCH', 'Seraphic Veil': 'SCH', 'Catalyze': 'SCH', 'Collective Unconscious': 'AST', 'Neutral Sect': 'AST', 'Intersection': 'AST', 'the Spire': 'AST', 'Kerachole': 'SGE', 'Holos': 'SGE', 'Holosakos': 'SGE', 'Panhaima': 'SGE', 'Haima': 'SGE', 'Eukrasian Prognosis': 'SGE', 'Eukrasian Prognosis II': 'SGE', 'Eukrasian Diagnosis': 'SGE', 'Differential Diagnosis': 'SGE', 'Troubadour': 'BRD', 'Tactician': 'MCH', 'Shield Samba': 'DNC', 'Improvised Finish': 'DNC', 'Radiant Aegis': 'SMN', 'Magick Barrier': 'RDM', 'Tempera Coat': 'PCT', 'Tempera Grassa': 'PCT', }; const JOB_FROM_TYPE = { 'Paladin': 'PLD', 'Warrior': 'WAR', 'DarkKnight': 'DRK', 'Gunbreaker': 'GNB', 'WhiteMage': 'WHM', 'Scholar': 'SCH', 'Astrologian': 'AST', 'Sage': 'SGE', 'Monk': 'MNK', 'Dragoon': 'DRG', 'Ninja': 'NIN', 'Samurai': 'SAM', 'Reaper': 'RPR', 'Viper': 'VPR', 'Bard': 'BRD', 'Machinist': 'MCH', 'Dancer': 'DNC', 'BlackMage': 'BLM', 'Summoner': 'SMN', 'RedMage': 'RDM', 'Pictomancer': 'PCT', }; const TANK_JOBS = new Set(['PLD', 'WAR', 'DRK', 'GNB']); const MELEE_JOBS = new Set(['MNK', 'DRG', 'NIN', 'SAM', 'RPR', 'VPR']); const CASTER_JOBS = new Set(['BLM', 'SMN', 'RDM', 'PCT']); const JOB_ROLE = { 'PLD': 'tank', 'WAR': 'tank', 'DRK': 'tank', 'GNB': 'tank', 'WHM': 'healer', 'SCH': 'healer', 'AST': 'healer', 'SGE': 'healer', 'MNK': 'dps', 'DRG': 'dps', 'NIN': 'dps', 'SAM': 'dps', 'RPR': 'dps', 'VPR': 'dps', 'BRD': 'dps', 'MCH': 'dps', 'DNC': 'dps', 'BLM': 'dps', 'SMN': 'dps', 'RDM': 'dps', 'PCT': 'dps', }; const ALL_JOBS = [ { group: 'Tank', jobs: ['PLD', 'WAR', 'DRK', 'GNB'] }, { group: 'Healer', jobs: ['WHM', 'SCH', 'AST', 'SGE'] }, { group: 'Melee', jobs: ['MNK', 'DRG', 'NIN', 'SAM', 'RPR', 'VPR'] }, { group: 'Ranged', jobs: ['BRD', 'MCH', 'DNC'] }, { group: 'Caster', jobs: ['BLM', 'SMN', 'RDM', 'PCT'] }, ]; const JOB_ABILITIES = { 'PLD': [ { name: 'Passage of Arms', buffType: 'buff' }, { name: 'Divine Veil', buffType: 'shield' }, { name: 'Guardian', buffType: 'shield' }, { name: 'Reprisal', buffType: 'debuff' }, ], 'WAR': [ { name: 'Shake It Off', buffType: 'shield' }, { name: 'Bloodwhetting', buffType: 'shield' }, { name: 'Reprisal', buffType: 'debuff' }, ], 'DRK': [ { name: 'Dark Missionary', buffType: 'buff' }, { name: 'Reprisal', buffType: 'debuff' }, ], 'GNB': [ { name: 'Heart of Light', buffType: 'buff' }, { name: 'Reprisal', buffType: 'debuff' }, ], 'WHM': [ { name: 'Temperance', buffType: 'buff' }, { name: 'Divine Benison', buffType: 'shield' }, { name: 'Divine Caress', buffType: 'shield' }, ], 'SCH': [ { name: 'Sacred Soil', buffType: 'buff' }, { name: 'Expedient', buffType: 'buff' }, { name: 'Fey Illumination', buffType: 'buff' }, { name: 'Galvanize', buffType: 'shield' }, { name: 'Seraphic Veil', buffType: 'shield' }, { name: 'Catalyze', buffType: 'shield' }, { name: 'Addle', buffType: 'debuff' }, ], 'AST': [ { name: 'Collective Unconscious', buffType: 'buff' }, { name: 'Neutral Sect', buffType: 'shield' }, { name: 'Intersection', buffType: 'shield' }, { name: 'the Spire', buffType: 'shield' }, ], 'SGE': [ { name: 'Kerachole', buffType: 'buff' }, { name: 'Holos', buffType: 'buff' }, { name: 'Holosakos', buffType: 'shield' }, { name: 'Panhaima', buffType: 'shield' }, { name: 'Eukrasian Prognosis', buffType: 'shield' }, { name: 'Eukrasian Prognosis II', buffType: 'shield' }, { name: 'Eukrasian Diagnosis', buffType: 'shield' }, { name: 'Differential Diagnosis', buffType: 'shield' }, { name: 'Haima', buffType: 'shield' }, { name: 'Addle', buffType: 'debuff' }, ], 'BRD': [{ name: 'Troubadour', buffType: 'buff' }], 'MCH': [{ name: 'Tactician', buffType: 'buff' }], 'DNC': [ { name: 'Shield Samba', buffType: 'buff' }, { name: 'Improvised Finish', buffType: 'shield' }, ], 'MNK': [{ name: 'Feint', buffType: 'debuff' }], 'DRG': [{ name: 'Feint', buffType: 'debuff' }], 'NIN': [{ name: 'Feint', buffType: 'debuff' }], 'SAM': [{ name: 'Feint', buffType: 'debuff' }], 'RPR': [{ name: 'Feint', buffType: 'debuff' }], 'VPR': [{ name: 'Feint', buffType: 'debuff' }], 'BLM': [{ name: 'Addle', buffType: 'debuff' }], 'SMN': [ { name: 'Addle', buffType: 'debuff' }, { name: 'Radiant Aegis', buffType: 'shield' }, ], 'RDM': [ { name: 'Addle', buffType: 'debuff' }, { name: 'Magick Barrier', buffType: 'buff' }, ], 'PCT': [ { name: 'Addle', buffType: 'debuff' }, { name: 'Tempera Coat', buffType: 'shield' }, { name: 'Tempera Grassa', buffType: 'shield' }, ], }; const MITIG_ICONS = { 'Passage of Arms': 'assets/icons/mitigation/passage-of-arms.png', 'Dark Missionary': 'assets/icons/mitigation/dark-missionary.png', 'Heart of Light': 'assets/icons/mitigation/heart-of-light.png', 'Temperance': 'assets/icons/mitigation/temperance.png', 'Sacred Soil': 'assets/icons/mitigation/sacred-soil.png', 'Expedient': 'assets/icons/mitigation/expedient.png', 'Fey Illumination': 'assets/icons/mitigation/fey-illumination.png', 'Collective Unconscious': 'assets/icons/mitigation/collective-unconscious.png', 'Holos': 'assets/icons/mitigation/holos.png', 'Kerachole': 'assets/icons/mitigation/kerachole.png', 'Troubadour': 'assets/icons/mitigation/troubadour.png', 'Tactician': 'assets/icons/mitigation/tactician.png', 'Shield Samba': 'assets/icons/mitigation/shield-samba.png', 'Magick Barrier': 'assets/icons/mitigation/magick-barrier.png', 'Reprisal': 'assets/icons/mitigation/reprisal.png', 'Feint': 'assets/icons/mitigation/feint.png', 'Addle': 'assets/icons/mitigation/addle.png', 'Divine Veil': 'assets/icons/mitigation/divine-veil.png', 'Guardian': 'assets/icons/mitigation/guardian.png', 'Shake It Off': 'assets/icons/mitigation/shake-it-off.png', 'Bloodwhetting': 'assets/icons/mitigation/bloodwhetting.png', 'Divine Benison': 'assets/icons/mitigation/divine-benison.png', 'Divine Caress': 'assets/icons/mitigation/divine-caress.png', 'Intersection': 'assets/icons/mitigation/intersection.png', 'Neutral Sect': 'assets/icons/mitigation/neutral-sect.png', 'the Spire': 'assets/icons/mitigation/the-spire.png', 'Panhaima': 'assets/icons/mitigation/panhaima.png', 'Holosakos': 'assets/icons/mitigation/holos.png', 'Eukrasian Prognosis': 'assets/icons/mitigation/eukrasian-prognosis.png', 'Eukrasian Prognosis II': 'assets/icons/mitigation/eukrasian-prognosis-ii.png', 'Eukrasian Diagnosis': 'assets/icons/mitigation/eukrasian-diagnosis.png', 'Differential Diagnosis': 'assets/icons/mitigation/eukrasian-diagnosis.png', 'Haima': 'assets/icons/mitigation/haima.png', 'Galvanize': 'assets/icons/mitigation/galvanize.png', 'Seraphic Veil': 'assets/icons/mitigation/seraphic-veil.png', 'Radiant Aegis': 'assets/icons/mitigation/radiant-aegis.png', 'Tempera Coat': 'assets/icons/mitigation/tempera-coat.png', 'Tempera Grassa': 'assets/icons/mitigation/tempera-grassa.png', 'Improvised Finish': 'assets/icons/mitigation/improvised-finish.png', }; const ASSIGN_ORDER = { debuff: 0, buff: 1, shield: 2 }; function sortedAssignments(assignments) { return [...assignments].sort((a, b) => (ASSIGN_ORDER[a.buffType] ?? 1) - (ASSIGN_ORDER[b.buffType] ?? 1) ); } function guessJob(abilityName, players) { if (ABILITY_JOB_MAP[abilityName]) return ABILITY_JOB_MAP[abilityName]; const jobs = (players ?? []).map(p => JOB_FROM_TYPE[p.type] ?? ''); if (abilityName === 'Reprisal') { const tanks = jobs.filter(j => TANK_JOBS.has(j)); return tanks.length === 1 ? tanks[0] : ''; } if (abilityName === 'Feint') { const melees = jobs.filter(j => MELEE_JOBS.has(j)); return melees.length === 1 ? melees[0] : ''; } if (abilityName === 'Addle') { const casters = jobs.filter(j => CASTER_JOBS.has(j)); return casters.length === 1 ? casters[0] : ''; } return ''; } function mitigationDisplayName(mitigation) { return mitigation?.name ?? mitigation?.key ?? ''; } function mitigationKey(mitigation) { return mitigation?.key ?? mitigation?.name ?? ''; } // ── AoE Events → Plan Mechanics ─────────────────────────────────────────────── function aoeEventsToMechanics(aoeEvents, fightStart, phases, players, withMitigations, mitigationNames = {}) { return aoeEvents.map(ev => { const relTs = ev.timestamp - fightStart; const phase = (phases ?? []).filter(p => p.id !== 0).find(p => ev.timestamp >= p.startTime && ev.timestamp < p.endTime ); const nonTankTargets = ev.targets.filter(t => t.role !== 'tank' && (t.unmitigatedAmount ?? 0) > 0); const fallbackTargets = ev.targets.filter(t => (t.unmitigatedAmount ?? 0) > 0); const relevantTargets = nonTankTargets.length > 0 ? nonTankTargets : fallbackTargets; const avgUnmit = relevantTargets.length > 0 ? Math.round(relevantTargets.reduce((s, t) => s + t.unmitigatedAmount, 0) / relevantTargets.length) : 0; let assignments = []; if (withMitigations) { const seen = new Set(); for (const t of ev.targets) { for (const m of (t.mitigations ?? [])) { const key = mitigationKey(m); if (!seen.has(key)) { seen.add(key); assignments.push({ ability: key, abilityName: mitigationDisplayName(m) || mitigationNames[key], job: guessJob(key, players), buffType: m.buffType ?? '', }); } } } } return { id: crypto.randomUUID(), name: ev.abilityName, abilityId: ev.abilityId, timestamp: relTs, phase: phase?.name ?? '', unmitigatedDamage: avgUnmit, notes: '', assignments, }; }); } // ── Merge + Create plan from import ────────────────────────────────────────── function extractJobComp(players) { const order = { tank: 0, healer: 1, dps: 2 }; const sorted = [...(players ?? [])] .filter(p => JOB_FROM_TYPE[p.type]) .sort((a, b) => { const roleCmp = (order[a.role] ?? 2) - (order[b.role] ?? 2); return roleCmp !== 0 ? roleCmp : a.name.localeCompare(b.name); }); const comp = sorted.map(p => JOB_FROM_TYPE[p.type] ?? '').slice(0, 8); while (comp.length < 8) comp.push(''); return comp; } function doImport(data, withMitigations, whereMode, mergeId, newName) { const { aoeEvents, fightStart, fightEnd, phases, players, fightName, reportCode, fightId, mitigationNames = {} } = data; const mechanics = aoeEventsToMechanics(aoeEvents, fightStart, phases, players, withMitigations, mitigationNames); const source = { reportCode, fightId, fightName, fightStart, fightEnd, language: plannerLanguage() }; if (whereMode === 'new') { const plan = createPlan(newName || fightName || 'Importierter Plan'); return updatePlan(plan.id, { mechanics, source, mitigationNames, jobComposition: extractJobComp(players), }); } // Merge into existing plan const plan = getPlan(mergeId); if (!plan) return null; const merged = [...plan.mechanics]; for (const newM of mechanics) { const exists = plan.mechanics.some(m => m.name === newM.name && Math.abs(m.timestamp - newM.timestamp) < 5000 ); if (!exists) merged.push(newM); } merged.sort((a, b) => a.timestamp - b.timestamp); return updatePlan(mergeId, { mechanics: merged, source: { ...(plan.source ?? {}), ...source }, mitigationNames: { ...(plan.mitigationNames ?? {}), ...mitigationNames }, }); } const refreshingPlans = new Set(); async function refreshPlanLanguage(planId) { const plan = getPlan(planId); const source = plan?.source ?? {}; const language = plannerLanguage(); if (!plan || refreshingPlans.has(planId)) return; if (!source.reportCode || !source.fightId || !source.fightStart || !source.fightEnd) return; if (source.language === language && plan.mitigationNames && Object.keys(plan.mitigationNames).length) return; refreshingPlans.add(planId); try { const params = new URLSearchParams({ report_code: source.reportCode, fight_id: source.fightId, start_time: source.fightStart, end_time: source.fightEnd, language, }); const res = await fetch('api/analysis.php', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params, }); const json = await res.json(); if (json.reauth) { window.location.href = window.App?.authStartUrl?.() ?? 'auth/start.php'; return; } if (json.error) return; const refreshed = (json.aoe_events ?? []); const mechanics = plan.mechanics.map(mechanic => { const match = refreshed.find(ev => sameMechanic(mechanic, ev, source)); if (!match) return mechanic; const assignments = (mechanic.assignments ?? []).map(a => ({ ...a, abilityName: json.mitigation_names?.[a.ability] ?? a.abilityName, })); return { ...mechanic, name: match.abilityName ?? mechanic.name, abilityId: match.abilityId ?? mechanic.abilityId, assignments, }; }); let fightName = source.fightName; try { const fightRes = await fetch('api/fight.php', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ report_code: source.reportCode, language }), }); const fightJson = await fightRes.json(); const fight = (fightJson?.data?.reportData?.report?.fights ?? []).find(f => f.id === source.fightId); if (fight?.name) fightName = fight.name; } catch { } const nextSource = { ...source, fightName, language }; const nextName = plan.name === source.fightName && fightName ? fightName : plan.name; const updated = updatePlan(planId, { name: nextName, mechanics, source: nextSource, mitigationNames: json.mitigation_names ?? plan.mitigationNames ?? {}, }); if (updated && activePlanId === planId) { renderPlanList(); renderPlanDetail(updated); } } catch { } finally { refreshingPlans.delete(planId); } } // ── Ability Assignment Modal ────────────────────────────────────────────────── let abilityModalPlanId = null; let abilityModalMechanicId = null; function showAbilityModal(planId, mechanicId) { abilityModalPlanId = planId; abilityModalMechanicId = mechanicId; renderAbilityModalContent(); document.getElementById('planner-ability-modal').style.display = 'flex'; } function hideAbilityModal() { document.getElementById('planner-ability-modal').style.display = 'none'; abilityModalPlanId = null; abilityModalMechanicId = null; } function renderAbilityModalContent() { const plan = getPlan(abilityModalPlanId); if (!plan) return; const mechanic = plan.mechanics.find(m => m.id === abilityModalMechanicId); if (!mechanic) return; document.getElementById('ability-modal-title').textContent = mechanic.name; const activeJobs = [...new Set(plan.jobComposition.filter(j => j))]; const content = document.getElementById('ability-modal-content'); if (activeJobs.length === 0) { content.innerHTML = `

Bitte zuerst die Jobaufstellung konfigurieren.

`; return; } content.innerHTML = activeJobs.map(job => { const abilities = JOB_ABILITIES[job] ?? []; if (!abilities.length) return ''; const role = JOB_ROLE[job] ?? 'dps'; const chips = abilities.map(ab => { const assigned = mechanic.assignments.find(a => a.ability === ab.name); const isActive = !!assigned; const byOtherJob = isActive && assigned.job !== job; const cls = ab.buffType === 'debuff' ? 'badge-assign-debuff' : ab.buffType === 'shield' ? 'badge-assign-shield' : 'badge-assign-buff'; const activeClass = isActive ? ' ability-chip--active' : ''; const otherClass = byOtherJob ? ' ability-chip--other-job' : ''; const title = byOtherJob ? `Bereits von ${escHtml(assigned.job)} zugewiesen` : ''; const icon = MITIG_ICONS[ab.name] ?? ''; const assignedName = assigned ? assignmentAbilityName(assigned, plan) : ''; const label = assignedName || plan.mitigationNames?.[ab.name] || ab.name; return ``; }).join(''); return `
${escHtml(job)}
${chips}
`; }).join(''); } function toggleAbilityAssignment(abilityName, job, buffType) { const plan = getPlan(abilityModalPlanId); if (!plan) return; const mechanic = plan.mechanics.find(m => m.id === abilityModalMechanicId); if (!mechanic) return; const idx = mechanic.assignments.findIndex(a => a.ability === abilityName); if (idx !== -1) { if (mechanic.assignments[idx].job === job) { mechanic.assignments.splice(idx, 1); } else { mechanic.assignments[idx].job = job; } } else { mechanic.assignments.push({ ability: abilityName, abilityName: plan.mitigationNames?.[abilityName], job, buffType }); } updatePlan(abilityModalPlanId, { mechanics: plan.mechanics }); refreshMechanicList(abilityModalPlanId); renderAbilityModalContent(); } function initAbilityModal() { const overlay = document.getElementById('planner-ability-modal'); if (!overlay) return; document.getElementById('ability-modal-close')?.addEventListener('click', hideAbilityModal); overlay.addEventListener('click', e => { if (e.target === overlay) hideAbilityModal(); }); document.addEventListener('keydown', e => { if (e.key === 'Escape' && abilityModalPlanId) hideAbilityModal(); }); document.getElementById('ability-modal-content')?.addEventListener('click', e => { const btn = e.target.closest('.ability-chip'); if (!btn) return; toggleAbilityAssignment(btn.dataset.ability, btn.dataset.job, btn.dataset.buffType); }); } // ── Import Modal ────────────────────────────────────────────────────────────── let pendingImportData = null; function showImportModal(data) { pendingImportData = data; // Pre-fill name const nameInput = document.getElementById('import-plan-name'); if (nameInput) { nameInput.value = data.fightName || ''; } // Populate merge dropdown const planSelect = document.getElementById('import-plan-select'); if (planSelect) { const plans = loadPlans(); planSelect.innerHTML = ''; plans.forEach(p => { const opt = document.createElement('option'); opt.value = p.id; opt.textContent = p.name; planSelect.appendChild(opt); }); const mergeLabel = document.getElementById('import-merge-label'); if (mergeLabel) mergeLabel.style.opacity = plans.length === 0 ? '0.4' : ''; const mergeRadio = document.querySelector('input[name="import-where"][value="merge"]'); if (mergeRadio) mergeRadio.disabled = plans.length === 0; } // Reset to defaults document.querySelectorAll('input[name="import-what"]').forEach(r => { r.checked = r.value === 'with-mitigations'; }); document.querySelectorAll('input[name="import-where"]').forEach(r => { r.checked = r.value === 'new'; }); document.getElementById('import-new-section').style.display = ''; document.getElementById('import-merge-section').style.display = 'none'; document.getElementById('planner-import-modal').style.display = 'flex'; nameInput?.focus(); nameInput?.select(); } function hideImportModal() { document.getElementById('planner-import-modal').style.display = 'none'; pendingImportData = null; } function initImportModal() { document.querySelectorAll('input[name="import-where"]').forEach(radio => { radio.addEventListener('change', () => { document.getElementById('import-new-section').style.display = radio.value === 'new' ? '' : 'none'; document.getElementById('import-merge-section').style.display = radio.value === 'merge' ? '' : 'none'; }); }); document.getElementById('import-cancel-btn')?.addEventListener('click', hideImportModal); document.getElementById('planner-import-modal')?.addEventListener('click', e => { if (e.target === e.currentTarget) hideImportModal(); }); document.addEventListener('keydown', e => { if (e.key === 'Escape' && pendingImportData) hideImportModal(); }); document.getElementById('import-confirm-btn')?.addEventListener('click', () => { if (!pendingImportData) return; const withMitigations = document.querySelector('input[name="import-what"]:checked')?.value === 'with-mitigations'; const whereMode = document.querySelector('input[name="import-where"]:checked')?.value ?? 'new'; const newName = document.getElementById('import-plan-name')?.value.trim(); const mergeId = document.getElementById('import-plan-select')?.value; if (whereMode === 'new' && !newName) { document.getElementById('import-plan-name')?.focus(); return; } if (whereMode === 'merge' && !mergeId) { document.getElementById('import-plan-select')?.focus(); return; } const plan = doImport(pendingImportData, withMitigations, whereMode, mergeId, newName); if (!plan) return; hideImportModal(); renderPlanList(); openPlan(plan.id); window.showTab?.('planner'); }); } // ── window.plannerTab (hooks for other tabs) ────────────────────────────────── window.plannerTab = { onTabOpen() { renderPlanList(); if (activePlanId) { openPlan(activePlanId); } else { renderPlanDetail(null); } }, showImportModal(data) { showImportModal(data); }, importFromAnalysis(aoeEvents, _refEvents, _options) { console.log('[Planner] importFromAnalysis — use showImportModal instead', aoeEvents); }, }; // ── Init ────────────────────────────────────────────────────────────────────── document.addEventListener('DOMContentLoaded', () => { initNewPlanForm(); initImportModal(); initAbilityModal(); activePlanId = localStorage.getItem(PLANNER_ACTIVE_KEY); renderPlanList(); if (activePlanId && getPlan(activePlanId)) { renderPlanDetail(getPlan(activePlanId)); } else { activePlanId = null; renderPlanDetail(null); } }); window.addEventListener('ff14-language-change', () => { if (!activePlanId) return; refreshPlanLanguage(activePlanId); });