// ── Storage ─────────────────────────────────────────────────────────────────── const PLANNER_KEY = 'ff14-planner-plans'; 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, 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 currentLanguage() { return window.App?.language || localStorage.getItem('ff14-mitigator-language') || 'en'; } const ABILITY_DISPLAY_NAMES = { de: { 'Addle': 'Stumpfsinn', 'Feint': 'Zermürben', 'Reprisal': 'Reflexion', 'Passage of Arms': 'Waffengang', 'Heart of Light': 'Herz des Lichts', 'Sacred Soil': 'Geweihte Erde', 'Tactician': 'Taktiker', 'Shake It Off': 'Abschütteln', 'Shield Samba': 'Schildsamba', 'Magick Barrier': 'Magiebarriere', }, }; function localizedAbilityName(key, fallback = key) { const lang = currentLanguage(); return ABILITY_DISPLAY_NAMES[lang]?.[key] ?? fallback ?? key; } // ── 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
Jobaufstellung wird in einem späteren Schritt konfigurierbar
Mechaniken
${renderMechanicList(plan)}
`; document.getElementById('plan-name-edit-btn')?.addEventListener('click', () => { startRename(plan.id, plan.name); }); } function renderMechanicList(plan) { if (plan.mechanics.length === 0) { return `
📋

Noch keine Mechaniken

Importiere einen Log aus dem Analyse-Tab

`; } return plan.mechanics.map(m => `
${escHtml(fmtTimestamp(m.timestamp))}
${m.phase ? `
${escHtml(m.phase)}
` : ''}
${escHtml(m.name)}
${m.unmitigatedDamage ? `
${fmtNumber(m.unmitigatedDamage)} unmitigiert
` : '' }
${m.assignments.length === 0 ? 'Keine Zuweisung' : m.assignments.map(a => { const cls = a.buffType === 'debuff' ? 'badge-assign-debuff' : a.buffType === 'shield' ? 'badge-assign-shield' : a.buffType === 'buff' ? 'badge-assign-buff' : ''; const ability = localizedAbilityName(a.ability, a.abilityName ?? a.ability); const label = a.job ? `${escHtml(a.job)} · ${escHtml(ability)}` : escHtml(ability); return `${label}`; }).join('') }
${m.notes ? `
${escHtml(m.notes)}
` : ''}
`).join(''); } // ── 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; renderPlanList(); renderPlanDetail(getPlan(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']); 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) { 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), job: guessJob(key, players), buffType: m.buffType ?? '', }); } } } } return { id: crypto.randomUUID(), name: ev.abilityName, timestamp: relTs, phase: phase?.name ?? '', unmitigatedDamage: avgUnmit, notes: '', assignments, }; }); } // ── Merge + Create plan from import ────────────────────────────────────────── function doImport(data, withMitigations, whereMode, mergeId, newName) { const { aoeEvents, fightStart, phases, players, fightName, reportCode } = data; const mechanics = aoeEventsToMechanics(aoeEvents, fightStart, phases, players, withMitigations); if (whereMode === 'new') { const plan = createPlan(newName || fightName || 'Importierter Plan'); return updatePlan(plan.id, { mechanics, source: { reportCode, fightName }, }); } // 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 }); } // ── 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(); renderPlanList(); renderPlanDetail(null); }); window.addEventListener('ff14-language-change', () => { if (!activePlanId) return; renderPlanDetail(getPlan(activePlanId)); });