Add Planner tab: localStorage plan CRUD and basic UI shell

Steps 1+2 of the planner roadmap: data model, create/rename/copy/delete
plans, read-only mechanic timeline, two-column layout mirroring the
analysis tab style.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
xziino 2026-05-22 08:26:33 +02:00
parent 9ff8139c81
commit ea00268227
6 changed files with 592 additions and 0 deletions

220
css/planner.css Normal file
View File

@ -0,0 +1,220 @@
/* ── Planner Layout ──────────────────────────────────────────────────────────── */
.planner-layout {
display: grid;
grid-template-columns: 280px 1fr;
gap: 16px;
align-items: start;
}
/* ── Plan Sidebar ────────────────────────────────────────────────────────────── */
.plan-sidebar {
background: var(--bgcard);
border: 1px solid var(--border);
border-radius: var(--rl);
padding: 18px;
position: sticky;
top: 74px;
}
.plan-sidebar-header {
display: flex;
align-items: center;
margin-bottom: 14px;
}
.plan-sidebar-header .card-title { margin-bottom: 0; flex: 1; }
.plan-new-form {
background: var(--bg2);
border: 1px solid var(--borderem);
border-radius: var(--r);
padding: 10px;
margin-bottom: 12px;
}
.plan-new-form input {
margin-bottom: 8px;
font-size: 14px;
padding: 6px 10px;
}
.plan-new-actions {
display: flex;
gap: 6px;
}
.plan-list-empty {
font-size: 13px;
color: var(--t3);
text-align: center;
padding: 24px 0;
}
/* ── Plan Item ───────────────────────────────────────────────────────────────── */
.plan-item {
display: flex;
align-items: center;
gap: 8px;
padding: 9px 10px;
border-radius: var(--r);
border: 1px solid transparent;
cursor: pointer;
transition: all 0.12s;
margin-bottom: 4px;
}
.plan-item:hover { background: var(--bg2); border-color: var(--border); }
.plan-item.active { background: var(--bg2); border-color: var(--gold); }
.plan-item-info { flex: 1; min-width: 0; }
.plan-item-name {
font-size: 14px;
color: var(--t1);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.plan-item.active .plan-item-name { color: var(--gold); }
.plan-item-meta {
font-size: 12px;
color: var(--t3);
margin-top: 2px;
}
.plan-item-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
opacity: 0;
transition: opacity 0.12s;
}
.plan-item:hover .plan-item-actions { opacity: 1; }
.plan-btn {
background: none;
border: 1px solid var(--borderem);
border-radius: var(--r);
color: var(--t2);
font-size: 13px;
padding: 2px 7px;
cursor: pointer;
transition: all 0.12s;
line-height: 1.6;
}
.plan-btn:hover { background: var(--bg3); color: var(--t1); }
.plan-btn.plan-btn-danger { color: var(--red); border-color: rgba(224,92,92,0.3); }
.plan-btn.plan-btn-danger:hover { background: var(--redbg); }
/* ── Plan Detail ─────────────────────────────────────────────────────────────── */
.plan-detail-header {
display: flex;
flex-direction: column;
gap: 5px;
}
.plan-name-wrap {
display: flex;
align-items: center;
gap: 8px;
}
.plan-name-text {
font-family: var(--font-d);
font-size: 18px;
color: var(--gold);
letter-spacing: 0.04em;
}
.plan-name-input {
font-size: 18px !important;
font-family: var(--font-d) !important;
padding: 2px 8px !important;
width: auto !important;
min-width: 200px;
color: var(--gold) !important;
}
.plan-detail-meta {
font-size: 13px;
color: var(--t3);
}
/* ── Job Slots Placeholder ───────────────────────────────────────────────────── */
.job-slots-placeholder {
padding: 20px;
text-align: center;
color: var(--t3);
font-size: 13px;
background: var(--bg2);
border: 1px dashed var(--border);
border-radius: var(--r);
}
/* ── Mechanic Cards ──────────────────────────────────────────────────────────── */
.mechanic-card {
display: grid;
grid-template-columns: 52px 1fr;
gap: 14px;
padding: 12px 0;
border-bottom: 1px solid var(--border);
align-items: start;
}
.mechanic-card:last-child { border-bottom: none; }
.mechanic-time {
font-family: var(--font-d);
font-size: 14px;
color: var(--gold);
letter-spacing: 0.03em;
padding-top: 3px;
}
.mechanic-body {
display: flex;
flex-direction: column;
gap: 4px;
}
.mechanic-phase {
font-size: 11px;
color: var(--blue);
text-transform: uppercase;
letter-spacing: 0.07em;
}
.mechanic-name {
font-size: 15px;
color: var(--t1);
font-weight: 500;
}
.mechanic-dmg {
font-size: 13px;
color: var(--t2);
}
.mechanic-assignments {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 2px;
}
.mechanic-no-assign {
font-size: 12px;
color: var(--t3);
}
.badge-assign {
background: var(--bg2);
border: 1px solid var(--borderem);
border-radius: var(--r);
padding: 2px 8px;
font-size: 12px;
color: var(--t2);
}
.mechanic-notes {
font-size: 12px;
color: var(--t3);
font-style: italic;
margin-top: 2px;
}

333
js/planner.js Normal file
View File

@ -0,0 +1,333 @@
// ── Storage ───────────────────────────────────────────────────────────────────
const PLANNER_KEY = 'ff14-planner-plans';
function loadPlans() {
try { return JSON.parse(localStorage.getItem(PLANNER_KEY) || '[]'); }
catch { return []; }
}
function savePlans(plans) {
localStorage.setItem(PLANNER_KEY, JSON.stringify(plans));
}
// ── CRUD ──────────────────────────────────────────────────────────────────────
function createPlan(name) {
const plan = {
id: crypto.randomUUID(),
name: name.trim() || 'Unbenannter Plan',
createdAt: Date.now(),
updatedAt: Date.now(),
source: null,
jobComposition: Array(8).fill(''),
mechanics: []
};
const all = loadPlans();
all.push(plan);
savePlans(all);
return plan;
}
function getPlan(id) {
return loadPlans().find(p => p.id === id) ?? null;
}
function updatePlan(id, changes) {
const all = loadPlans();
const idx = all.findIndex(p => p.id === id);
if (idx === -1) return null;
all[idx] = { ...all[idx], ...changes, updatedAt: Date.now() };
savePlans(all);
return all[idx];
}
function deletePlan(id) {
savePlans(loadPlans().filter(p => p.id !== id));
}
function copyPlan(id) {
const all = loadPlans();
const orig = all.find(p => p.id === id);
if (!orig) return null;
const copy = {
...JSON.parse(JSON.stringify(orig)),
id: crypto.randomUUID(),
name: orig.name + ' (Kopie)',
createdAt: Date.now(),
updatedAt: Date.now()
};
all.push(copy);
savePlans(all);
return copy;
}
// ── State ─────────────────────────────────────────────────────────────────────
let activePlanId = null;
// ── Helpers ───────────────────────────────────────────────────────────────────
function escHtml(str) {
return String(str ?? '')
.replace(/&/g, '&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');
}
// ── Rendering: Plan List ──────────────────────────────────────────────────────
function renderPlanList() {
const el = document.getElementById('plan-list');
if (!el) return;
const plans = loadPlans();
if (plans.length === 0) {
el.innerHTML = '<div class="plan-list-empty">Noch keine Pläne vorhanden</div>';
return;
}
el.innerHTML = plans.map(p => `
<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-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>
`).join('');
el.querySelectorAll('.plan-item').forEach(row => {
row.addEventListener('click', e => {
if (e.target.closest('.plan-item-actions')) return;
openPlan(row.dataset.id);
});
});
el.querySelectorAll('.plan-btn-copy').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
const copy = copyPlan(btn.dataset.id);
if (copy) { renderPlanList(); openPlan(copy.id); }
});
});
el.querySelectorAll('.plan-btn-delete').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
const plan = getPlan(btn.dataset.id);
if (!plan) return;
if (!confirm(`Plan "${plan.name}" löschen?`)) return;
deletePlan(btn.dataset.id);
if (activePlanId === btn.dataset.id) {
activePlanId = null;
renderPlanDetail(null);
}
renderPlanList();
});
});
}
// ── Rendering: Plan Detail ────────────────────────────────────────────────────
function renderPlanDetail(plan) {
const noplan = document.getElementById('planner-no-plan');
const content = document.getElementById('plan-content');
if (!noplan || !content) return;
if (!plan) {
noplan.style.display = '';
content.style.display = 'none';
return;
}
noplan.style.display = 'none';
content.style.display = '';
content.innerHTML = `
<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-placeholder">
Jobaufstellung wird in einem späteren Schritt konfigurierbar
</div>
</div>
<div class="card">
<div class="card-title-row">
<div class="card-title">Mechaniken</div>
</div>
${renderMechanicList(plan)}
</div>
`;
document.getElementById('plan-name-edit-btn')?.addEventListener('click', () => {
startRename(plan.id, plan.name);
});
}
function renderMechanicList(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>
`;
}
return plan.mechanics.map(m => `
<div class="mechanic-card">
<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">
${m.assignments.length === 0
? '<span class="mechanic-no-assign">Keine Zuweisung</span>'
: m.assignments.map(a =>
`<span class="badge badge-assign">${escHtml(a.job)} · ${escHtml(a.ability)}</span>`
).join('')
}
</div>
${m.notes ? `<div class="mechanic-notes">${escHtml(m.notes)}</div>` : ''}
</div>
</div>
`).join('');
}
// ── Rename ────────────────────────────────────────────────────────────────────
function startRename(id, currentName) {
const display = document.getElementById('plan-name-display');
const editBtn = document.getElementById('plan-name-edit-btn');
if (!display) return;
display.innerHTML = `<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;
renderPlanList();
renderPlanDetail(getPlan(id));
}
// ── New plan form ─────────────────────────────────────────────────────────────
function initNewPlanForm() {
const btn = document.getElementById('planner-new-btn');
const form = document.getElementById('planner-new-form');
const input = document.getElementById('planner-new-name');
const save = document.getElementById('planner-new-save');
const cancel = document.getElementById('planner-new-cancel');
if (!btn) return;
btn.addEventListener('click', () => {
form.style.display = '';
input.value = '';
input.focus();
});
cancel.addEventListener('click', () => { form.style.display = 'none'; });
const doCreate = () => {
const name = input.value.trim();
if (!name) { input.focus(); return; }
const plan = createPlan(name);
form.style.display = 'none';
renderPlanList();
openPlan(plan.id);
};
save.addEventListener('click', doCreate);
input.addEventListener('keydown', e => {
if (e.key === 'Enter') doCreate();
if (e.key === 'Escape') form.style.display = 'none';
});
}
// ── window.plannerTab (hooks for other tabs) ──────────────────────────────────
window.plannerTab = {
onTabOpen() {
renderPlanList();
if (activePlanId) {
openPlan(activePlanId);
} else {
renderPlanDetail(null);
}
},
importFromAnalysis(aoeEvents, _refEvents, _options) {
// Schritt 3 — noch nicht implementiert
console.log('[Planner] importFromAnalysis — not yet implemented', aoeEvents);
}
};
// ── Init ──────────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
initNewPlanForm();
renderPlanList();
renderPlanDetail(null);
});

View File

@ -13,6 +13,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (btn) btn.classList.add('active');
if (name === 'analysis') window.analysisTab?.onTabOpen?.();
if (name === 'planner') window.plannerTab?.onTabOpen?.();
}
tabs.forEach(btn => btn.addEventListener('click', () => showTab(btn.dataset.tab)));

View File

@ -8,6 +8,7 @@
<link rel="stylesheet" href="css/layout.css">
<link rel="stylesheet" href="css/components.css">
<link rel="stylesheet" href="css/analysis.css">
<link rel="stylesheet" href="css/planner.css">
</head>
<body>
@ -28,11 +29,16 @@
<div id="tab-analysis" class="tab-content" style="display:none">
<?php require __DIR__ . '/tab-analysis.php'; ?>
</div>
<div id="tab-planner" class="tab-content" style="display:none">
<?php require __DIR__ . '/tab-planner.php'; ?>
</div>
</main>
</div>
<script src="js/app.js"></script>
<script src="js/tabs.js"></script>
<script src="js/analysis.js"></script>
<script src="js/planner.js"></script>
<?php endif; ?>
</body>

31
templates/tab-planner.php Normal file
View File

@ -0,0 +1,31 @@
<div class="planner-layout">
<!-- Left: Plan list sidebar -->
<div class="plan-sidebar">
<div class="plan-sidebar-header">
<div class="card-title">Pläne</div>
<button id="planner-new-btn" class="btn btn-sm btn-gold">+ Neu</button>
</div>
<div id="planner-new-form" class="plan-new-form" style="display:none">
<input type="text" id="planner-new-name" placeholder="Plan-Name…">
<div class="plan-new-actions">
<button id="planner-new-save" class="btn btn-sm btn-gold">Erstellen</button>
<button id="planner-new-cancel" class="btn btn-sm">Abbrechen</button>
</div>
</div>
<div id="plan-list"></div>
</div>
<!-- Right: Plan detail -->
<div id="plan-detail-panel">
<div id="planner-no-plan" class="empty">
<div class="empty-icon">📋</div>
<h3>Kein Plan ausgewählt</h3>
<p style="font-size:13px;color:var(--t3);margin-top:6px">Erstelle einen neuen Plan oder wähle einen bestehenden aus</p>
</div>
<div id="plan-content" style="display:none"></div>
</div>
</div>

View File

@ -3,6 +3,7 @@
<nav class="tabs">
<button class="tab active" data-tab="report"> Report</button>
<button class="tab" data-tab="analysis"> Analyse</button>
<button class="tab" data-tab="planner">📋 Planer</button>
</nav>
<div class="topbar-user">Token gültig bis: <?= date('Y-m-d H:i:s', $_SESSION['token_expires']) ?></div>
</header>