// ── Storage ─────────────────────────────────────────────────────────────────── const PLANNER_KEY = 'ff14-planner-plans'; const FOLDERS_KEY = 'ff14-planner-folders'; function loadPlans() { try { return JSON.parse(localStorage.getItem(PLANNER_KEY) || '[]'); } catch { return []; } } function savePlans(plans) { localStorage.setItem(PLANNER_KEY, JSON.stringify(plans)); } function loadFolders() { try { return JSON.parse(localStorage.getItem(FOLDERS_KEY) || '[]'); } catch { return []; } } function saveFolders(f) { localStorage.setItem(FOLDERS_KEY, JSON.stringify(f)); } function createFolder(name) { const folder = { id: crypto.randomUUID(), name: name.trim() || 'Neuer Ordner', createdAt: Date.now(), updatedAt: Date.now() }; const all = loadFolders(); all.push(folder); saveFolders(all); return folder; } function updateFolder(id, changes) { const all = loadFolders(), idx = all.findIndex(f => f.id === id); if (idx === -1) return; all[idx] = { ...all[idx], ...changes, updatedAt: Date.now() }; saveFolders(all); } function deleteFolder(id) { saveFolders(loadFolders().filter(f => f.id !== id)); savePlans(loadPlans().map(p => p.folderId === id ? { ...p, folderId: null, updatedAt: Date.now() } : p)); } // ── CRUD ────────────────────────────────────────────────────────────────────── function createPlan(name) { const plan = { id: crypto.randomUUID(), name: name.trim() || 'Unbenannter Plan', createdAt: Date.now(), updatedAt: Date.now(), source: null, folderId: 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 uniquePlanName(baseName) { const names = new Set(loadPlans().map(p => p.name)); if (!names.has(baseName)) return baseName; let i = 2; while (names.has(`${baseName} ${i}`)) i++; return `${baseName} ${i}`; } 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; let collapsedFolders = new Set(); // ── 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'); } // ── Rendering: Plan List ────────────────────────────────────────────────────── function planItemHtml(p) { return `
${escHtml(p.name)}
${p.mechanics.length} Mechaniken · ${fmtDate(p.updatedAt)}
`; } function renderPlanList() { const el = document.getElementById('plan-list'); if (!el) return; const plans = loadPlans(); const folders = loadFolders(); if (plans.length === 0 && folders.length === 0) { el.innerHTML = '
Noch keine Pläne vorhanden
'; return; } const byFolder = new Map(); const ungrouped = []; for (const p of plans) { const fid = p.folderId ?? null; if (fid && folders.some(f => f.id === fid)) { if (!byFolder.has(fid)) byFolder.set(fid, []); byFolder.get(fid).push(p); } else { ungrouped.push(p); } } let html = folders.map(folder => { const folderPlans = byFolder.get(folder.id) ?? []; const collapsed = collapsedFolders.has(folder.id); return `
${escHtml(folder.name)} ${folderPlans.length}
${folderPlans.map(p => planItemHtml(p)).join('') || '
Leer
'}
`; }).join('') + ungrouped.map(p => planItemHtml(p)).join(''); el.innerHTML = html || '
Noch keine Pläne vorhanden
'; // Plan item: open el.querySelectorAll('.plan-item').forEach(row => { row.addEventListener('click', e => { if (e.target.closest('.plan-item-actions')) return; openPlan(row.dataset.id); }); }); // Plan item: move to folder el.querySelectorAll('.plan-btn-move').forEach(btn => { btn.addEventListener('click', e => { e.stopPropagation(); showFolderPicker(btn.dataset.id, btn); }); }); // Plan item: copy 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); } }); }); // Plan item: delete 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(); }); }); // Folder row: toggle collapse el.querySelectorAll('.folder-row').forEach(row => { row.addEventListener('click', e => { if (e.target.closest('.folder-actions')) return; const fid = row.dataset.folderId; if (collapsedFolders.has(fid)) collapsedFolders.delete(fid); else collapsedFolders.add(fid); renderPlanList(); }); }); // Folder: rename el.querySelectorAll('.folder-rename-btn').forEach(btn => { btn.addEventListener('click', e => { e.stopPropagation(); const fid = btn.dataset.id; const folder = loadFolders().find(f => f.id === fid); if (!folder) return; const span = el.querySelector(`.folder-row[data-folder-id="${fid}"] .folder-name-text`); if (!span) return; const input = document.createElement('input'); input.className = 'folder-name-input'; input.value = folder.name; span.replaceWith(input); input.focus(); input.select(); let saved = false; const save = () => { if (saved) return; saved = true; const name = input.value.trim(); if (name) updateFolder(fid, { name }); renderPlanList(); }; input.addEventListener('blur', save); input.addEventListener('keydown', ev => { if (ev.key === 'Enter') { ev.preventDefault(); save(); } if (ev.key === 'Escape') { saved = true; renderPlanList(); } }); }); }); // Folder: delete el.querySelectorAll('.folder-delete-btn').forEach(btn => { btn.addEventListener('click', e => { e.stopPropagation(); const folder = loadFolders().find(f => f.id === btn.dataset.id); if (!folder) return; const count = (byFolder.get(btn.dataset.id) ?? []).length; const msg = count > 0 ? `Ordner "${folder.name}" löschen? Die ${count} enthaltenen Pläne werden nicht gelöscht.` : `Ordner "${folder.name}" löschen?`; if (!confirm(msg)) return; deleteFolder(btn.dataset.id); 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 label = a.job ? `${escHtml(a.job)} · ${escHtml(a.ability)}` : escHtml(a.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); }); list.addEventListener('contextmenu', e => { const badge = e.target.closest('.badge-assign'); if (!badge) return; e.preventDefault(); e.stopPropagation(); const removeBtn = badge.querySelector('.badge-remove'); if (removeBtn) removeAssignment(planId, removeBtn.dataset.mechanicId, removeBtn.dataset.ability); }); } // ── 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 clearErr = () => { input.style.borderColor = ''; document.getElementById('planner-new-name-err')?.remove(); }; const doCreate = () => { const name = input.value.trim(); if (!name) { input.focus(); return; } if (loadPlans().some(p => p.name === name)) { input.style.borderColor = 'var(--red)'; let err = document.getElementById('planner-new-name-err'); if (!err) { err = document.createElement('div'); err.id = 'planner-new-name-err'; err.style.cssText = 'font-size:11px;color:var(--red);margin-top:3px'; input.insertAdjacentElement('afterend', err); } err.textContent = 'Name bereits vergeben'; input.focus(); return; } clearErr(); const plan = createPlan(name); form.style.display = 'none'; renderPlanList(); openPlan(plan.id); }; input.addEventListener('input', clearErr); save.addEventListener('click', doCreate); input.addEventListener('keydown', e => { if (e.key === 'Enter') doCreate(); if (e.key === 'Escape') { clearErr(); form.style.display = 'none'; } }); } // ── Folder Picker ───────────────────────────────────────────────────────────── let folderPickerCleanup = null; function closeFolderPicker() { document.getElementById('folder-picker')?.remove(); if (folderPickerCleanup) { folderPickerCleanup(); folderPickerCleanup = null; } } function showFolderPicker(planId, anchorEl) { closeFolderPicker(); const plan = getPlan(planId); const folders = loadFolders(); if (!plan) return; if (folders.length === 0) return; const rect = anchorEl.getBoundingClientRect(); const picker = document.createElement('div'); picker.id = 'folder-picker'; picker.className = 'folder-picker'; picker.style.cssText = `position:fixed;top:${rect.bottom + 4}px;left:${rect.left}px;z-index:9999`; picker.innerHTML = [{ id: null, name: 'Kein Ordner' }, ...folders].map(f => `
${f.id === null ? '— ' : '📁 '}${escHtml(f.name)}
`).join(''); document.body.appendChild(picker); picker.querySelectorAll('.folder-picker-option').forEach(opt => { opt.addEventListener('click', e => { e.stopPropagation(); updatePlan(planId, { folderId: opt.dataset.fid || null }); closeFolderPicker(); renderPlanList(); }); }); const onOutside = e => { if (!picker.contains(e.target)) closeFolderPicker(); }; setTimeout(() => document.addEventListener('click', onOutside), 0); folderPickerCleanup = () => document.removeEventListener('click', onOutside); } // ── New Folder Form ─────────────────────────────────────────────────────────── function initNewFolderForm() { const btn = document.getElementById('planner-new-folder-btn'); const form = document.getElementById('planner-new-folder-form'); const input = document.getElementById('planner-new-folder-name'); const save = document.getElementById('planner-new-folder-save'); const cancel = document.getElementById('planner-new-folder-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; } createFolder(name); form.style.display = 'none'; renderPlanList(); }; 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 ''; } // ── 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 = m.key ?? m.name; if (!seen.has(key)) { seen.add(key); assignments.push({ ability: key, 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 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, phases, players, fightName, reportCode } = data; const mechanics = aoeEventsToMechanics(aoeEvents, fightStart, phases, players, withMitigations); if (whereMode === 'new') { const plan = createPlan(uniquePlanName(newName || fightName || 'Importierter Plan')); return updatePlan(plan.id, { mechanics, source: { reportCode, fightName }, 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 }); } // ── 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] ?? ''; 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, 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 — auto-unique so the user sees immediately what name will be used const nameInput = document.getElementById('import-plan-name'); if (nameInput) { nameInput.value = uniquePlanName(data.fightName || 'Importierter Plan'); } // 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(); initNewFolderForm(); initImportModal(); initAbilityModal(); renderPlanList(); renderPlanDetail(null); });