Compare commits

..

7 Commits

Author SHA1 Message Date
Akurosia Kamo
e2eed52d07 Merge remote-tracking branch 'origin/master' into akus_schabernack2 2026-05-22 09:40:11 +02:00
xziino
d73dd340c2 Planner import modal: default to Mechaniken + Mitigation, rename option
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 09:15:41 +02:00
xziino
8f832e1a0a Adjust planner badge colors: buffs=gold, shields=blue, debuffs=red
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 08:48:51 +02:00
Akurosia Kamo
ed4b72654b Merge remote-tracking branch 'origin/master' into akus_schabernack2 2026-05-22 08:46:48 +02:00
xziino
4107779e2a Add Planner import flow (Schritt 3): export from Analysis tab
- Export button in AoE Timeline card title row (replaces bottom bar)
- Import modal with mechanic-only vs with-mitigations choice,
  new plan vs merge into existing plan
- Merge logic: match by abilityName + timestamp ±5s, keep existing assignments
- Color-coded assignment badges: blue=buff, red=debuff, gold=shield
- buffType stored in assignments for color rendering
- Modal radio button layout fix (override global input width:100%)
- Auto-switch to Planner tab after import
- window.showTab exposed from tabs.js

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 08:46:37 +02:00
xziino
ea00268227 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>
2026-05-22 08:26:33 +02:00
xziino
9ff8139c81 Update CLAUDE.md: Planer-Tab concept and implementation roadmap
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 08:11:44 +02:00
9 changed files with 1040 additions and 28 deletions

View File

@ -31,6 +31,10 @@ window.App = { reportCode, fightId, fightStart, fightEnd, phases: [], fights: []
- `onFightsLoaded(fights)` — wird von `app.js` aufgerufen nach Report-Load (befüllt Vergleichs-Dropdown) - `onFightsLoaded(fights)` — wird von `app.js` aufgerufen nach Report-Load (befüllt Vergleichs-Dropdown)
- `reset()` — wird von `app.js` aufgerufen wenn ein neuer Report geladen wird - `reset()` — wird von `app.js` aufgerufen wenn ein neuer Report geladen wird
`window.plannerTab` (definiert in `planner.js`) stellt Hooks bereit:
- `onTabOpen()` — wird von `tabs.js` aufgerufen wenn der Planer-Tab geöffnet wird
- `importFromAnalysis(aoeEvents, refEvents, options)` — wird vom Analyse-Tab aufgerufen beim Export
## Dateistruktur ## Dateistruktur
``` ```
index.php — PHP-Logik: Auth-Check, Variablen, require page.php index.php — PHP-Logik: Auth-Check, Variablen, require page.php
@ -41,6 +45,7 @@ templates/
topbar.php — Topbar mit Logo + Tab-Navigation + Token-Ablaufzeit topbar.php — Topbar mit Logo + Tab-Navigation + Token-Ablaufzeit
tab-report.php — Report-Tab: includes report-form, fight-select, event-explorer, output-card tab-report.php — Report-Tab: includes report-form, fight-select, event-explorer, output-card
tab-analysis.php — Analyse-Tab: Spieler-Grid + Pull-Vergleich + AoE-Timeline HTML tab-analysis.php — Analyse-Tab: Spieler-Grid + Pull-Vergleich + AoE-Timeline HTML
tab-planner.php — Planer-Tab: Plan-Liste, Mechanik-Timeline, Job-Aufstellung (in Entwicklung)
report-form.php — Report-Code-Eingabe Card report-form.php — Report-Code-Eingabe Card
fight-select.php — Fight-Auswahl Dropdown Card fight-select.php — Fight-Auswahl Dropdown Card
event-explorer.php — Event Explorer Card (Ability/DataType/EventType/Spieler-Filter) event-explorer.php — Event Explorer Card (Ability/DataType/EventType/Spieler-Filter)
@ -50,10 +55,12 @@ css/
layout.css — App-Shell, Topbar, Tabs, Login-Overlay, Form-Helpers layout.css — App-Shell, Topbar, Tabs, Login-Overlay, Form-Helpers
components.css — Cards, Inputs, Buttons, Badges, Terminal components.css — Cards, Inputs, Buttons, Badges, Terminal
analysis.css — Spieler-Grid, AoE-Timeline, Mitigation-Icons, HP-Bar, Ref-Row analysis.css — Spieler-Grid, AoE-Timeline, Mitigation-Icons, HP-Bar, Ref-Row
planner.css — Planer-Tab: Plan-Liste, Mechanik-Cards, Job-Slots, Ability-Modal (in Entwicklung)
js/ js/
app.js — Formular, Fight-Dropdown, Fetch, window.App State, Event Explorer app.js — Formular, Fight-Dropdown, Fetch, window.App State, Event Explorer
tabs.js — Tab-Switching, ruft window.analysisTab.onTabOpen() auf tabs.js — Tab-Switching, ruft window.analysisTab.onTabOpen() auf
analysis.js — Analyse-Tab: Daten laden, Spieler rendern, Timeline rendern, Pull-Vergleich analysis.js — Analyse-Tab: Daten laden, Spieler rendern, Timeline rendern, Pull-Vergleich
planner.js — Planer-Tab: localStorage CRUD, Plan-Rendering, Ability-Zuweisung (in Entwicklung)
auth/ auth/
start.php — PKCE generieren, Session speichern, Redirect zu FFLogs start.php — PKCE generieren, Session speichern, Redirect zu FFLogs
callback.php — Code gegen Token tauschen, Token in Session speichern callback.php — Code gegen Token tauschen, Token in Session speichern
@ -64,6 +71,9 @@ api/
debug-events.php — POST-Endpunkt: Raw Events für Event Explorer (mit Filterung) debug-events.php — POST-Endpunkt: Raw Events für Event Explorer (mit Filterung)
assets/ assets/
icons/mitigation/ — Lokal gespeicherte Ability-Icons (PNG, von XIVAPI) icons/mitigation/ — Lokal gespeicherte Ability-Icons (PNG, von XIVAPI)
data/
recast-times.json — Recast-Zeiten pro Ability (in Entwicklung)
ability-equivalents.json — Funktionale Äquivalente über Jobs hinweg (in Entwicklung)
debug/ debug/
schema.php — Einmaliges Schema-Explorer Tool (nicht produktiv deployen) schema.php — Einmaliges Schema-Explorer Tool (nicht produktiv deployen)
``` ```
@ -220,7 +230,7 @@ Vollständiges Schema: siehe `debug/schema.php` oder `fflogs-schema.json`
## Planer-Tab — Konzept & Roadmap ## Planer-Tab — Konzept & Roadmap
### Ziel ### Ziel
Raid-Cooldown-Planer: Welche Mitigation-Ability wird für welche Mechanik eingesetzt? Basierend auf Log-Daten oder manuell aufgebaut. Überlebt Browser-Neustarts via localStorage. Raid-Cooldown-Planer: Welche Mitigation-Ability wird für welche Mechanik eingesetzt? Basierend auf Log-Daten oder manuell aufgebaut. Überlebt Browser-Neustarts via localStorage. Kein Server-State — alles im Browser.
### Datenmodell (Plan) ### Datenmodell (Plan)
```json ```json
@ -236,7 +246,9 @@ Raid-Cooldown-Planer: Welche Mitigation-Ability wird für welche Mechanik einges
"id": "uuid", "id": "uuid",
"name": "Fourth-Wall Fusion", "name": "Fourth-Wall Fusion",
"timestamp": 83000, "timestamp": 83000,
"phase": "Phase 1",
"unmitigatedDamage": 280000, "unmitigatedDamage": 280000,
"notes": "",
"assignments": [ "assignments": [
{ "ability": "Reprisal", "job": "PLD" }, { "ability": "Reprisal", "job": "PLD" },
{ "ability": "Shield Samba", "job": "BRD" } { "ability": "Shield Samba", "job": "BRD" }
@ -248,31 +260,42 @@ Raid-Cooldown-Planer: Welche Mitigation-Ability wird für welche Mechanik einges
Mehrere Pläne gespeichert in `localStorage` unter `ff14-planner-plans` als Array. Mehrere Pläne gespeichert in `localStorage` unter `ff14-planner-plans` als Array.
### Import-Flow (erster Meilenstein) ### Primärer Import-Flow: Export aus dem Analyse-Tab
**Ziel:** Einen bestehenden Log als saubere Mechanik-Vorlage laden — ohne vorhandene Mechaniken zu überschreiben. Der Haupteinstieg ist der Analyse-Tab — der Nutzer hat die Daten bereits geladen und sieht die Timeline.
1. Benutzer wählt Report-Code + Kampf (gleicher Flow wie im Report-Tab, eigenes Mini-Formular im Planer) 1. Button **"In Planer exportieren"** erscheint im Analyse-Tab sobald Daten geladen sind (auch für den Ref-Log separat)
2. `api/analysis.php` wird aufgerufen → liefert `aoe_events` mit Name, Timestamp, `unmitigatedAmount` 2. Dialog mit zwei Entscheidungen:
3. Jedes AoE-Event wird als Mechanik-Kandidat angezeigt (Name + Timestamp + Rohschaden) - **Was importieren?** "Nur Mechaniken" vs. "Mechaniken + erkannte Mitigations als Startpunkt"
4. Benutzer kann einzelne Events auswählen oder alle übernehmen - **Wohin?** Neuen Plan anlegen (Name eingeben) vs. bestehenden Plan überschreiben/mergen (Dropdown)
5. **Merge-Logik:** Beim Import in einen bestehenden Plan werden nur Mechaniken hinzugefügt die noch nicht vorhanden sind — Matching per `abilityName` + Timestamp-Nähe (± 5s). Bestehende Assignments bleiben erhalten. 3. Bei Merge: explizite Bestätigung — niemals implizit überschreiben
6. Neue Mechaniken werden an der richtigen Timestamp-Position eingefügt (Timeline bleibt sortiert) 4. AoE-Events werden zu Mechaniken; Phase-Information aus `phaseTransitions` wird mitübernommen
5. Weiterarbeiten im Planer-Tab
**Warum Merge statt Überschreiben:** Progress-Szenario — erster Import enthält Phase 1, späterer Import (weiter im Fight) fügt Phase 2 hinzu ohne Phase-1-Planung zu verlieren. **Merge-Logik:** Mechaniken gelten als identisch wenn `abilityName` gleich und `|timestamp_a - timestamp_b| < 5000ms`. Nur neue Mechaniken werden hinzugefügt, bestehende Assignments bleiben erhalten. Neue Mechaniken werden timestamp-sortiert eingefügt.
### Geplante Features (Übersicht) **Warum Merge statt Überschreiben:** Progress-Szenario — erster Import enthält Phase 1, späterer Import fügt Phase 2 hinzu ohne Phase-1-Planung zu verlieren.
| Prio | Feature | Beschreibung | ### Implementierungs-Reihenfolge
| Schritt | Feature | Beschreibung |
|---|---|---| |---|---|---|
| 1 | **Import-Flow** | Log → Mechanik-Timeline, Merge bei Teilimporten | | 1 | **Datenfundament** | Plan-Datenmodell + localStorage CRUD (create, read, update, delete, copy) |
| 2 | **Jobaufstellung** | 8 Slots (2 Tank, 2 Healer, 4 DPS), Auswahl bestimmt verfügbare Spells | | 2 | **Tab-Grundgerüst** | Leere Tab-Hülle wie Analyse-Tab; Plan-Liste; Mechanik-Timeline (read-only) |
| 3 | **Cooldown-Zuweisung** | Pro Mechanik Abilities zuweisen/entfernen per Klick | | 3 | **Import aus Analyse-Tab** | Export-Button + Dialog (s. oben); `window.plannerTab.importFromAnalysis()` |
| 4 | **DR-Simulation** | `simuliert = unmitigated × ∏(1 dr_i)` live berechnet beim Toggle | | 4 | **Jobaufstellung** | 8 Slots mit Job-Dropdown; bestimmt verfügbare Abilities in Schritt 5 |
| 5 | **Recast-Tracking** | Recast-Datenbank pro Ability; Konflikt-Warnung wenn CD noch läuft | | 5 | **Ability-Zuweisung** | Pro Mechanik Abilities per Modal-Picker hinzufügen/entfernen |
| 6 | **Coverage-Ansicht** | Gantt-Chart: Mechaniken auf X-Achse, Buff-Dauer als Balken | | 6 | **Recast-Konflikt** | `data/recast-times.json`; rote Warnung wenn CD zwischen zwei Mechaniken noch läuft |
| 7 | **Analyse-Overlay** | Planer-Tab: Vergleich geplanter vs. tatsächlich genutzter CDs (Job-basiertes Matching, nicht Spielername) | | 7 | **DR-Simulation** | `simuliert = unmitigated × ∏(1 dr_i)`; grün/rot ob Spieler laut Simulation überlebt |
| 8 | **Shield-Schätzung** | Empirisch aus Log-Durchschnitt (`absorbed`-Werte), nicht exakt | | 8 | **Job-Äquivalente** | `data/ability-equivalents.json`; auto-substituieren beim Job-Wechsel |
| 9 | **JSON-Export/Import** | Plan als Datei teilen mit Raidkollegen | | 9 | **Analyse-Overlay** | Vergleich geplanter vs. tatsächlich genutzter CDs im Analyse-Tab |
Schritte 13 = nutzbarer MVP. Schritte 46 = praktisch einsetzbar. 79 = Power-Features.
### UI-Paradigma
- Visuell dem Analyse-Tab ähneln (Cards, gleiche CSS-Variablen, einheitliches Look & Feel)
- Mechaniken als vertikale Timeline-Cards
- Ability-Picker als **Modal** (kein Inline-Dropdown)
- Nicht für mobile Geräte ausgelegt
### Spell-Verfügbarkeit nach Job ### Spell-Verfügbarkeit nach Job
Jobaufstellung → verfügbare Abilities (Subset von `MITIGATION_ABILITIES`): Jobaufstellung → verfügbare Abilities (Subset von `MITIGATION_ABILITIES`):
@ -301,17 +324,31 @@ Jobaufstellung → verfügbare Abilities (Subset von `MITIGATION_ABILITIES`):
| RDM | Addle, Magick Barrier | | RDM | Addle, Magick Barrier |
| PCT | Addle, Tempera Coat, Tempera Grassa | | PCT | Addle, Tempera Coat, Tempera Grassa |
### Recast-Zeiten (geplante Datenbank) ### Job-Äquivalente (`data/ability-equivalents.json`)
Wird benötigt für Konflikt-Erkennung. Beispiele: Abilities die funktional gleich sind aber unterschiedliche Namen haben — relevant beim Job-Wechsel im Slot:
| Gruppe | Abilities |
|---|---|
| 15% Party-Mitigation | Troubadour, Tactician, Shield Samba |
| 10% Ground-Barrier | Sacred Soil, Kerachole |
Reprisal, Feint und Addle sind identische Ability-Namen über Jobs hinweg — kein Mapping nötig, die gleiche Ability bleibt einfach bestehen.
**Verhalten beim Job-Wechsel:** Assignment wird auto-substituiert wenn Äquivalent für neuen Job existiert (mit Hinweis "automatisch gemappt"). Kein Äquivalent → Assignment ausgegraut (nicht gelöscht, Nutzer entscheidet).
### Recast-Zeiten (`data/recast-times.json`)
Wird für Konflikt-Erkennung benötigt (Schritt 6). Vollständige Liste wird beim Implementieren vervollständigt, Beispiele:
- Reprisal: 60s - Reprisal: 60s
- Feint / Addle: 90s - Feint / Addle: 90s
- Dark Missionary / Heart of Light: 90s
- Troubadour / Tactician / Shield Samba: 120s - Troubadour / Tactician / Shield Samba: 120s
- Temperance: 120s - Temperance: 120s
- Dark Missionary / Heart of Light: 90s
### Technische Entscheidungen ### Technische Entscheidungen
- **Persistenz:** `localStorage` — kein Backend nötig, mehrere Pläne möglich - **Persistenz:** `localStorage` unter `ff14-planner-plans` — kein Backend nötig
- **IDs:** `crypto.randomUUID()` für Plan- und Mechanik-IDs - **IDs:** `crypto.randomUUID()` für Plan- und Mechanik-IDs
- **Merge-Matching:** Mechaniken gelten als identisch wenn `abilityName` gleich und `|timestamp_a - timestamp_b| < 5000ms` - **Keine Spielernamen:** Assignments sind Job-basiert (`{ ability, job }`), damit Pläne übertragbar sind
- **Keine Spielernamen im Planer:** Assignments sind Job-basiert (`{ ability, job }`), damit Pläne übertragbar sind - **Kein Ability-Stacking:** FFXIV erlaubt keine doppelte Anwendung derselben Ability — jede Ability kommt pro Mechanik maximal einmal vor, doppelte Instanzen desselben Jobs im Static sind daher kein Sonderfall
- **Analyse-Tab Overlay (Prio 7):** Job aus tatsächlichem Pull → lookup welche Ability dieser Job im Plan hatte → Soll/Ist-Vergleich - **Analyse-Overlay (Schritt 9):** Job aus tatsächlichem Pull → lookup welche Ability dieser Job im Plan hatte → Soll/Ist-Vergleich (kein Spielername-Matching nötig)
- **Shield-Attribution:** Aktuell nicht lösbar — `absorbed` im `damage`-Event ist ein Gesamtwert ohne Aufschlüsselung per Shield. Zu untersuchen: ob `calculatedheal`-Events die Shield-Kapazität beim Auftragen mitliefern. Vorerst zurückgestellt.
- **Plan kopieren:** Duplicate-Funktion für Plan-Varianten ("Week 3 v2") ohne Original zu verlieren

311
css/planner.css Normal file
View File

@ -0,0 +1,311 @@
/* ── 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);
}
.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;
color: var(--t3);
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;
}

View File

@ -687,6 +687,9 @@
document.getElementById('analysis-loading').style.display = 'none'; document.getElementById('analysis-loading').style.display = 'none';
document.getElementById('analysis-content').style.display = 'block'; document.getElementById('analysis-content').style.display = 'block';
const exportBtn = document.getElementById('export-to-planner-btn');
if (exportBtn) exportBtn.style.display = '';
} }
window.analysisTab = { window.analysisTab = {
@ -705,6 +708,17 @@
refFightSelect.dispatchEvent(new Event('change')); 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() { reset() {
lastFightId = null; lastFightId = null;
refEvents = []; refEvents = [];
@ -720,6 +734,12 @@
refFflogsLink.style.display = 'none'; refFflogsLink.style.display = 'none';
refFflogsLink.href = '#'; refFflogsLink.href = '#';
refExtPanel.style.display = 'none'; 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());
});
})(); })();

558
js/planner.js Normal file
View File

@ -0,0 +1,558 @@
// ── 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 => {
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 `<span class="badge badge-assign ${cls}">${label}</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';
});
}
// ── 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 = '<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();
initImportModal();
renderPlanList();
renderPlanDetail(null);
});

View File

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

View File

@ -8,6 +8,7 @@
<link rel="stylesheet" href="css/layout.css"> <link rel="stylesheet" href="css/layout.css">
<link rel="stylesheet" href="css/components.css"> <link rel="stylesheet" href="css/components.css">
<link rel="stylesheet" href="css/analysis.css"> <link rel="stylesheet" href="css/analysis.css">
<link rel="stylesheet" href="css/planner.css">
</head> </head>
<body> <body>
@ -28,11 +29,61 @@
<div id="tab-analysis" class="tab-content" style="display:none"> <div id="tab-analysis" class="tab-content" style="display:none">
<?php require __DIR__ . '/tab-analysis.php'; ?> <?php require __DIR__ . '/tab-analysis.php'; ?>
</div> </div>
<div id="tab-planner" class="tab-content" style="display:none">
<?php require __DIR__ . '/tab-planner.php'; ?>
</div>
</main> </main>
</div> </div>
<!-- Planner Import Modal -->
<div id="planner-import-modal" class="modal-overlay" style="display:none">
<div class="modal-box">
<div class="modal-title">In Planer exportieren</div>
<div class="modal-section">
<div class="modal-label">Was importieren?</div>
<label class="modal-radio-label">
<input type="radio" name="import-what" value="with-mitigations" checked>
<span>Mechaniken + Mitigation</span>
</label>
<label class="modal-radio-label">
<input type="radio" name="import-what" value="mechanics">
<span>Nur Mechaniken</span>
</label>
</div>
<div class="modal-section">
<div class="modal-label">Wohin?</div>
<label class="modal-radio-label">
<input type="radio" name="import-where" value="new" checked>
<span>Neuer Plan</span>
</label>
<div id="import-new-section" class="modal-subsection">
<input type="text" id="import-plan-name" placeholder="Plan-Name…">
</div>
<label class="modal-radio-label" id="import-merge-label">
<input type="radio" name="import-where" value="merge">
<span>In bestehenden Plan mergen</span>
</label>
<div id="import-merge-section" class="modal-subsection" style="display:none">
<select id="import-plan-select">
<option value=""> Plan auswählen </option>
</select>
<div class="modal-hint">Neue Mechaniken werden hinzugefügt, bestehende Assignments bleiben erhalten.</div>
</div>
</div>
<div class="modal-actions">
<button id="import-confirm-btn" class="btn btn-gold">Importieren</button>
<button id="import-cancel-btn" class="btn">Abbrechen</button>
</div>
</div>
</div>
<script src="js/app.js"></script> <script src="js/app.js"></script>
<script src="js/tabs.js"></script> <script src="js/tabs.js"></script>
<script src="js/analysis.js"></script> <script src="js/analysis.js"></script>
<script src="js/planner.js"></script>
<?php endif; ?> <?php endif; ?>
</body> </body>

View File

@ -40,6 +40,7 @@
<div class="card-title">AoE Timeline</div> <div class="card-title">AoE Timeline</div>
<select id="phase-select" class="filter-input" style="display:none"></select> <select id="phase-select" class="filter-input" style="display:none"></select>
<input type="text" id="player-filter" class="filter-input" placeholder="Spieler filtern…"> <input type="text" id="player-filter" class="filter-input" placeholder="Spieler filtern…">
<button id="export-to-planner-btn" class="btn btn-sm btn-gold" style="display:none">📋 Exportieren</button>
</div> </div>
<div id="aoe-timeline"></div> <div id="aoe-timeline"></div>
</div> </div>

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