diff --git a/css/planner.css b/css/planner.css
index 33f27bb..c517d57 100644
--- a/css/planner.css
+++ b/css/planner.css
@@ -211,6 +211,9 @@
font-size: 12px;
color: var(--t2);
}
+.badge-assign-buff { background: rgba(200,168,75,.08); border-color: rgba(200,168,75,.4); color: var(--gold); }
+.badge-assign-debuff { background: rgba(224,92,92,.08); border-color: rgba(224,92,92,.4); color: var(--red); }
+.badge-assign-shield { background: rgba(74,158,255,.08); border-color: rgba(74,158,255,.4); color: var(--blue); }
.mechanic-notes {
font-size: 12px;
@@ -218,3 +221,91 @@
font-style: italic;
margin-top: 2px;
}
+
+/* ── Import Modal ────────────────────────────────────────────────────────────── */
+.modal-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.72);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 200;
+ padding: 20px;
+}
+
+.modal-box {
+ background: var(--bgcard);
+ border: 1px solid var(--borderem);
+ border-radius: var(--rl);
+ padding: 28px 32px;
+ max-width: 480px;
+ width: 100%;
+}
+
+.modal-title {
+ font-family: var(--font-d);
+ font-size: 15px;
+ color: var(--gold);
+ letter-spacing: 0.07em;
+ text-transform: uppercase;
+ margin-bottom: 22px;
+}
+
+.modal-section {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ margin-bottom: 20px;
+}
+
+.modal-label {
+ font-size: 12px;
+ color: var(--t2);
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ margin-bottom: 2px;
+}
+
+.modal-radio-label {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ cursor: pointer;
+ font-size: 14px;
+ color: var(--t1);
+ user-select: none;
+}
+/* Override global input styles that break radio button layout */
+.modal-radio-label input[type="radio"] {
+ width: auto;
+ min-width: 0;
+ padding: 0;
+ background: none;
+ border: none;
+ border-radius: 0;
+ flex-shrink: 0;
+}
+
+.modal-subsection {
+ margin-left: 24px;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+.modal-subsection input,
+.modal-subsection select {
+ font-size: 14px;
+ padding: 6px 10px;
+}
+
+.modal-hint {
+ font-size: 12px;
+ color: var(--t3);
+}
+
+.modal-actions {
+ display: flex;
+ gap: 8px;
+ margin-top: 6px;
+}
diff --git a/js/analysis.js b/js/analysis.js
index 258cda5..a252c0e 100644
--- a/js/analysis.js
+++ b/js/analysis.js
@@ -687,6 +687,9 @@
document.getElementById('analysis-loading').style.display = 'none';
document.getElementById('analysis-content').style.display = 'block';
+
+ const exportBtn = document.getElementById('export-to-planner-btn');
+ if (exportBtn) exportBtn.style.display = '';
}
window.analysisTab = {
@@ -705,6 +708,17 @@
refFightSelect.dispatchEvent(new Event('change'));
}
},
+ exportForPlanner() {
+ const fight = (window.App?.fights ?? []).find(f => f.id === window.App?.fightId);
+ return {
+ aoeEvents: lastEvents,
+ fightStart: lastFightStart,
+ phases: window.App?.phases ?? [],
+ players: currentPlayers,
+ fightName: fight?.name ?? `Fight ${window.App?.fightId ?? '?'}`,
+ reportCode: window.App?.reportCode ?? '',
+ };
+ },
reset() {
lastFightId = null;
refEvents = [];
@@ -720,6 +734,12 @@
refFflogsLink.style.display = 'none';
refFflogsLink.href = '#';
refExtPanel.style.display = 'none';
+ const exportBtn = document.getElementById('export-to-planner-btn');
+ if (exportBtn) exportBtn.style.display = 'none';
},
};
+
+ document.getElementById('export-to-planner-btn')?.addEventListener('click', () => {
+ window.plannerTab?.showImportModal(window.analysisTab.exportForPlanner());
+ });
})();
diff --git a/js/planner.js b/js/planner.js
index c7549e5..deca3bd 100644
--- a/js/planner.js
+++ b/js/planner.js
@@ -219,9 +219,14 @@ function renderMechanicList(plan) {
${m.assignments.length === 0
? 'Keine Zuweisung'
- : m.assignments.map(a =>
- `${escHtml(a.job)} · ${escHtml(a.ability)}`
- ).join('')
+ : 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 label = a.job ? `${escHtml(a.job)} · ${escHtml(a.ability)}` : escHtml(a.ability);
+ return `${label}`;
+ }).join('')
}
${m.notes ? `${escHtml(m.notes)}
` : ''}
@@ -306,6 +311,222 @@ function initNewPlanForm() {
});
}
+// ── 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 '';
+}
+
+// ── 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 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 = {
@@ -318,16 +539,20 @@ window.plannerTab = {
}
},
+ showImportModal(data) {
+ showImportModal(data);
+ },
+
importFromAnalysis(aoeEvents, _refEvents, _options) {
- // Schritt 3 — noch nicht implementiert
- console.log('[Planner] importFromAnalysis — not yet implemented', aoeEvents);
- }
+ console.log('[Planner] importFromAnalysis — use showImportModal instead', aoeEvents);
+ },
};
// ── Init ──────────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
initNewPlanForm();
+ initImportModal();
renderPlanList();
renderPlanDetail(null);
});
diff --git a/js/tabs.js b/js/tabs.js
index 1f0dd88..6388c61 100644
--- a/js/tabs.js
+++ b/js/tabs.js
@@ -17,4 +17,5 @@ document.addEventListener('DOMContentLoaded', () => {
}
tabs.forEach(btn => btn.addEventListener('click', () => showTab(btn.dataset.tab)));
+ window.showTab = showTab;
});
diff --git a/templates/page.php b/templates/page.php
index 5210e42..1f2eadb 100644
--- a/templates/page.php
+++ b/templates/page.php
@@ -35,6 +35,51 @@
+
+
+
+
In Planer exportieren
+
+
+
+
+
Wohin?
+
+
+
+
+
+
+
+
Neue Mechaniken werden hinzugefügt, bestehende Assignments bleiben erhalten.
+
+
+
+
+
+
+
+
+
+
diff --git a/templates/tab-analysis.php b/templates/tab-analysis.php
index 54f1254..e1ad5ff 100644
--- a/templates/tab-analysis.php
+++ b/templates/tab-analysis.php
@@ -40,6 +40,7 @@
AoE Timeline
+