ff14-mitigator/js/planner.js
xziino 4801148a8c Merge akus_schabernack3: Lokalisierung + Tab-Persistenz
- Lokalisierte Ability-Namen in Badges und Modal
- Aktiver Plan wird per localStorage über Reload hinweg gespeichert
- Aktiver Tab wird per localStorage gespeichert
- refreshPlanLanguage() aktualisiert Namen beim Sprachwechsel
- api/analysis.php gibt mitigation_names zurück
- ff14-language-change Event in app.js

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 15:58:32 +02:00

1379 lines
56 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── Storage ───────────────────────────────────────────────────────────────────
const PLANNER_KEY = 'ff14-planner-plans';
const PLANNER_ACTIVE_KEY = 'ff14-planner-active-plan';
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,
mitigationNames: {},
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: uniquePlanName(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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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 planItemHtml(p) {
return `
<div class="plan-item${p.id === activePlanId ? ' active' : ''}" data-id="${escHtml(p.id)}">
<div class="plan-item-info">
<div class="plan-item-name">${escHtml(p.name)}</div>
<div class="plan-item-meta">${p.mechanics.length} Mechaniken &middot; ${fmtDate(p.updatedAt)}</div>
</div>
<div class="plan-item-actions">
<button class="plan-btn plan-btn-move" data-id="${escHtml(p.id)}" title="In Ordner verschieben">📁</button>
<button class="plan-btn plan-btn-copy" data-id="${escHtml(p.id)}" title="Kopieren">⎘</button>
<button class="plan-btn plan-btn-danger plan-btn-delete" data-id="${escHtml(p.id)}" title="Löschen">✕</button>
</div>
</div>`;
}
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 = '<div class="plan-list-empty">Noch keine Pläne vorhanden</div>';
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 `
<div class="folder-section" data-folder-id="${escHtml(folder.id)}">
<div class="folder-row" data-folder-id="${escHtml(folder.id)}">
<span class="folder-chevron${collapsed ? '' : ' open'}">▶</span>
<span class="folder-name-text">${escHtml(folder.name)}</span>
<span class="folder-count">${folderPlans.length}</span>
<div class="folder-actions">
<button class="plan-btn folder-rename-btn" data-id="${escHtml(folder.id)}" title="Umbenennen">✎</button>
<button class="plan-btn plan-btn-danger folder-delete-btn" data-id="${escHtml(folder.id)}" title="Ordner löschen">✕</button>
</div>
</div>
<div class="folder-plans${collapsed ? ' collapsed' : ''}">
${folderPlans.map(p => planItemHtml(p)).join('') || '<div class="folder-empty">Leer</div>'}
</div>
</div>`;
}).join('') + ungrouped.map(p => planItemHtml(p)).join('');
el.innerHTML = html || '<div class="plan-list-empty">Noch keine Pläne vorhanden</div>';
// 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';
renderInfoPanel(null);
return;
}
noplan.style.display = 'none';
content.style.display = '';
content.innerHTML = `
<div class="card section-gap">
<div class="plan-detail-header">
<div class="plan-name-wrap">
<span id="plan-name-display" class="plan-name-text">${escHtml(plan.name)}</span>
<button id="plan-name-edit-btn" class="plan-btn" title="Umbenennen">✎</button>
</div>
<div class="plan-detail-meta">Erstellt ${fmtDate(plan.createdAt)} &middot; ${plan.mechanics.length} Mechaniken</div>
</div>
</div>
<div class="card section-gap">
<div class="card-title">Jobaufstellung</div>
<div class="job-slots-grid" id="job-slots-grid">
${renderJobSlotsHtml(plan)}
</div>
</div>
<div class="card">
<div class="card-title-row">
<div class="card-title">Mechaniken</div>
</div>
<div id="mechanic-list">
${renderMechanicListHtml(plan)}
</div>
</div>
`;
document.getElementById('plan-name-edit-btn')?.addEventListener('click', () => {
startRename(plan.id, plan.name);
});
initJobSlots(plan.id);
initMechanicClicks(plan.id);
renderInfoPanel(plan);
}
function renderMechanicListHtml(plan) {
if (plan.mechanics.length === 0) {
return `
<div class="empty" style="padding:30px 0">
<div class="empty-icon" style="font-size:26px">📋</div>
<h3>Noch keine Mechaniken</h3>
<p style="font-size:13px;color:var(--t3);margin-top:6px">
Importiere einen Log aus dem Analyse-Tab
</p>
</div>
`;
}
const activeJobSet = new Set(plan.jobComposition.filter(j => j));
return plan.mechanics.map(m => {
const sorted = sortedAssignments(m.assignments);
const assignHtml = sorted.length === 0
? '<span class="mechanic-no-assign">Keine Zuweisung</span>'
: 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` : '';
const suggestions = isMissing ? findEquivSuggestions(a.ability, activeJobSet) : [];
const badgeHtml = `<span class="badge badge-assign ${cls}${isMissing ? ' badge-assign--missing-job' : ''}"${title ? ` title="${title}"` : ''}>
${icon ? `<img class="badge-icon" src="${escHtml(icon)}" alt="">` : ''}
${label}
<button class="badge-remove" data-mechanic-id="${escHtml(m.id)}" data-ability="${escHtml(a.ability)}" title="Entfernen">×</button>
</span>`;
const hintHtml = suggestions.map(s =>
`<span class="badge-equiv-hint">→ ${escHtml(s.ability)} (${escHtml(s.job)})?</span>`
).join('');
const noEquivHint = isMissing && suggestions.length === 0
? `<span class="badge-no-equiv-hint">→ Kein Äquivalent!</span>` : '';
const jobHint = !a.job ? `<span class="badge-job-hint">→ Job zuordnen</span>` : '';
const needsWrap = suggestions.length > 0 || !!noEquivHint || !!jobHint;
return needsWrap
? `<span class="badge-with-hint">${badgeHtml}${hintHtml}${noEquivHint}${jobHint}</span>`
: badgeHtml;
}).join('');
return `
<div class="mechanic-card" data-mechanic-id="${escHtml(m.id)}">
<div class="mechanic-time">${escHtml(fmtTimestamp(m.timestamp))}</div>
<div class="mechanic-body">
${m.phase ? `<div class="mechanic-phase">${escHtml(m.phase)}</div>` : ''}
<div class="mechanic-name">${escHtml(m.name)}</div>
${m.unmitigatedDamage
? `<div class="mechanic-dmg">${fmtNumber(m.unmitigatedDamage)} unmitigiert</div>`
: ''
}
<div class="mechanic-assignments">${assignHtml}</div>
${m.notes ? `<div class="mechanic-notes">${escHtml(m.notes)}</div>` : ''}
<div class="mechanic-edit-hint">Klicken zum Bearbeiten</div>
</div>
<button class="mechanic-delete-btn plan-btn plan-btn-danger" data-mechanic-id="${escHtml(m.id)}" title="Mechanik löschen">✕</button>
</div>`;
}).join('');
}
// ── Job Slots ─────────────────────────────────────────────────────────────────
function renderJobSlotsHtml(plan) {
return Array.from({ length: 8 }, (_, i) => {
const job = plan.jobComposition[i] ?? '';
const role = JOB_ROLE[job] ?? '';
return `
<div class="job-slot${role ? ` job-slot--${role}` : ''}">
<select class="job-slot-select" data-idx="${i}">
<option value="">—</option>
${ALL_JOBS.map(g =>
`<optgroup label="${escHtml(g.group)}">${g.jobs.map(j =>
`<option value="${j}"${j === job ? ' selected' : ''}>${j}</option>`
).join('')}</optgroup>`
).join('')}
</select>
</div>`;
}).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);
renderInfoPanel(plan);
}
function renderInfoPanel(plan) {
const el = document.getElementById('planner-info-panel');
if (!el) return;
const legendHtml = `
<div class="info-section">
<div class="info-section-title">Farbschema</div>
<div class="info-legend-items">
<div class="info-legend-row"><span class="info-legend-dot info-legend-dot--buff"></span><span class="info-legend-label">Mitigation</span></div>
<div class="info-legend-row"><span class="info-legend-dot info-legend-dot--debuff"></span><span class="info-legend-label">Debuff</span></div>
<div class="info-legend-row"><span class="info-legend-dot info-legend-dot--shield"></span><span class="info-legend-label">Schild</span></div>
</div>
</div>`;
if (!plan) { el.innerHTML = legendHtml; return; }
const activeJobSet = new Set(plan.jobComposition.filter(j => j));
const noJobAbilities = new Set();
const missingJobPairs = new Set();
for (const m of plan.mechanics) {
for (const a of m.assignments) {
if (!a.job) {
noJobAbilities.add(a.ability);
} else if (!activeJobSet.has(a.job)) {
missingJobPairs.add(`${a.job} · ${a.ability}`);
}
}
}
let warningsHtml = '';
if (noJobAbilities.size > 0 || missingJobPairs.size > 0) {
warningsHtml = `<div class="info-section info-section--warnings"><div class="info-section-title">Hinweise</div>`;
if (noJobAbilities.size > 0) {
warningsHtml += `<div class="info-warning info-warning--job">Fähigkeiten ohne Job-Zuordnung</div>`;
}
if (missingJobPairs.size > 0) {
warningsHtml += `<div class="info-warning info-warning--missing">Fähigkeiten mit fehlendem Job</div>`;
}
warningsHtml += '</div>';
}
el.innerHTML = legendHtml + warningsHtml;
}
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 = `<input id="plan-name-input" class="plan-name-input" type="text" value="${escHtml(currentName)}">`;
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 = `<span class="plan-name-text">${escHtml(currentName)}</span>`;
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 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 => `
<div class="folder-picker-option${(plan.folderId ?? null) === f.id ? ' active' : ''}" data-fid="${escHtml(f.id ?? '')}">
${f.id === null ? '— ' : '📁 '}${escHtml(f.name)}
</div>`).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',
};
// Groups of abilities that are functionally equivalent across different jobs.
// Used to suggest replacements when a job is missing from the composition.
const ABILITY_EQUIVALENTS = [
['Troubadour', 'Tactician', 'Shield Samba'],
['Sacred Soil', 'Kerachole'],
];
function findEquivSuggestions(abilityName, activeJobSet) {
const group = ABILITY_EQUIVALENTS.find(g => g.includes(abilityName));
if (!group) return [];
const suggestions = [];
for (const equiv of group) {
if (equiv === abilityName) continue;
for (const job of activeJobSet) {
if ((JOB_ABILITIES[job] ?? []).some(a => a.name === equiv)) {
suggestions.push({ ability: equiv, job });
break;
}
}
}
return suggestions;
}
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(uniquePlanName(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 = `
<p style="color:var(--t3);font-size:13px;padding:8px 0">
Bitte zuerst die Jobaufstellung konfigurieren.
</p>`;
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 `<button class="ability-chip ${cls}${activeClass}${otherClass}"
data-ability="${escHtml(ab.name)}"
data-job="${escHtml(job)}"
data-buff-type="${escHtml(ab.buffType)}"
${title ? `title="${title}"` : ''}
>${icon ? `<img class="badge-icon" src="${escHtml(icon)}" alt="">` : ''}${escHtml(label)}</button>`;
}).join('');
return `
<div class="ability-job-group">
<div class="ability-job-label">
<span class="aoe-target-job role-${role}">${escHtml(job)}</span>
</div>
<div class="ability-chips">${chips}</div>
</div>`;
}).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 — 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 = '<option value="">— Plan auswählen —</option>';
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();
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);
});