akus_schabernack2 #2
93
CLAUDE.md
93
CLAUDE.md
@ -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)
|
||||
- `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
|
||||
```
|
||||
index.php — PHP-Logik: Auth-Check, Variablen, require page.php
|
||||
@ -41,6 +45,7 @@ templates/
|
||||
topbar.php — Topbar mit Logo + Tab-Navigation + Token-Ablaufzeit
|
||||
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-planner.php — Planer-Tab: Plan-Liste, Mechanik-Timeline, Job-Aufstellung (in Entwicklung)
|
||||
report-form.php — Report-Code-Eingabe Card
|
||||
fight-select.php — Fight-Auswahl Dropdown Card
|
||||
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
|
||||
components.css — Cards, Inputs, Buttons, Badges, Terminal
|
||||
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/
|
||||
app.js — Formular, Fight-Dropdown, Fetch, window.App State, Event Explorer
|
||||
tabs.js — Tab-Switching, ruft window.analysisTab.onTabOpen() auf
|
||||
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/
|
||||
start.php — PKCE generieren, Session speichern, Redirect zu FFLogs
|
||||
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)
|
||||
assets/
|
||||
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/
|
||||
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
|
||||
|
||||
### 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)
|
||||
```json
|
||||
@ -236,7 +246,9 @@ Raid-Cooldown-Planer: Welche Mitigation-Ability wird für welche Mechanik einges
|
||||
"id": "uuid",
|
||||
"name": "Fourth-Wall Fusion",
|
||||
"timestamp": 83000,
|
||||
"phase": "Phase 1",
|
||||
"unmitigatedDamage": 280000,
|
||||
"notes": "",
|
||||
"assignments": [
|
||||
{ "ability": "Reprisal", "job": "PLD" },
|
||||
{ "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.
|
||||
|
||||
### Import-Flow (erster Meilenstein)
|
||||
**Ziel:** Einen bestehenden Log als saubere Mechanik-Vorlage laden — ohne vorhandene Mechaniken zu überschreiben.
|
||||
### Primärer Import-Flow: Export aus dem Analyse-Tab
|
||||
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)
|
||||
2. `api/analysis.php` wird aufgerufen → liefert `aoe_events` mit Name, Timestamp, `unmitigatedAmount`
|
||||
3. Jedes AoE-Event wird als Mechanik-Kandidat angezeigt (Name + Timestamp + Rohschaden)
|
||||
4. Benutzer kann einzelne Events auswählen oder alle übernehmen
|
||||
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.
|
||||
6. Neue Mechaniken werden an der richtigen Timestamp-Position eingefügt (Timeline bleibt sortiert)
|
||||
1. Button **"In Planer exportieren"** erscheint im Analyse-Tab sobald Daten geladen sind (auch für den Ref-Log separat)
|
||||
2. Dialog mit zwei Entscheidungen:
|
||||
- **Was importieren?** "Nur Mechaniken" vs. "Mechaniken + erkannte Mitigations als Startpunkt"
|
||||
- **Wohin?** Neuen Plan anlegen (Name eingeben) vs. bestehenden Plan überschreiben/mergen (Dropdown)
|
||||
3. Bei Merge: explizite Bestätigung — niemals implizit überschreiben
|
||||
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 |
|
||||
| 2 | **Jobaufstellung** | 8 Slots (2 Tank, 2 Healer, 4 DPS), Auswahl bestimmt verfügbare Spells |
|
||||
| 3 | **Cooldown-Zuweisung** | Pro Mechanik Abilities zuweisen/entfernen per Klick |
|
||||
| 4 | **DR-Simulation** | `simuliert = unmitigated × ∏(1 − dr_i)` live berechnet beim Toggle |
|
||||
| 5 | **Recast-Tracking** | Recast-Datenbank pro Ability; Konflikt-Warnung wenn CD noch läuft |
|
||||
| 6 | **Coverage-Ansicht** | Gantt-Chart: Mechaniken auf X-Achse, Buff-Dauer als Balken |
|
||||
| 7 | **Analyse-Overlay** | Planer-Tab: Vergleich geplanter vs. tatsächlich genutzter CDs (Job-basiertes Matching, nicht Spielername) |
|
||||
| 8 | **Shield-Schätzung** | Empirisch aus Log-Durchschnitt (`absorbed`-Werte), nicht exakt |
|
||||
| 9 | **JSON-Export/Import** | Plan als Datei teilen mit Raidkollegen |
|
||||
| 1 | **Datenfundament** | Plan-Datenmodell + localStorage CRUD (create, read, update, delete, copy) |
|
||||
| 2 | **Tab-Grundgerüst** | Leere Tab-Hülle wie Analyse-Tab; Plan-Liste; Mechanik-Timeline (read-only) |
|
||||
| 3 | **Import aus Analyse-Tab** | Export-Button + Dialog (s. oben); `window.plannerTab.importFromAnalysis()` |
|
||||
| 4 | **Jobaufstellung** | 8 Slots mit Job-Dropdown; bestimmt verfügbare Abilities in Schritt 5 |
|
||||
| 5 | **Ability-Zuweisung** | Pro Mechanik Abilities per Modal-Picker hinzufügen/entfernen |
|
||||
| 6 | **Recast-Konflikt** | `data/recast-times.json`; rote Warnung wenn CD zwischen zwei Mechaniken noch läuft |
|
||||
| 7 | **DR-Simulation** | `simuliert = unmitigated × ∏(1 − dr_i)`; grün/rot ob Spieler laut Simulation überlebt |
|
||||
| 8 | **Job-Äquivalente** | `data/ability-equivalents.json`; auto-substituieren beim Job-Wechsel |
|
||||
| 9 | **Analyse-Overlay** | Vergleich geplanter vs. tatsächlich genutzter CDs im Analyse-Tab |
|
||||
|
||||
Schritte 1–3 = nutzbarer MVP. Schritte 4–6 = praktisch einsetzbar. 7–9 = 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
|
||||
Jobaufstellung → verfügbare Abilities (Subset von `MITIGATION_ABILITIES`):
|
||||
@ -301,17 +324,31 @@ Jobaufstellung → verfügbare Abilities (Subset von `MITIGATION_ABILITIES`):
|
||||
| RDM | Addle, Magick Barrier |
|
||||
| PCT | Addle, Tempera Coat, Tempera Grassa |
|
||||
|
||||
### Recast-Zeiten (geplante Datenbank)
|
||||
Wird benötigt für Konflikt-Erkennung. Beispiele:
|
||||
### Job-Äquivalente (`data/ability-equivalents.json`)
|
||||
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
|
||||
- Feint / Addle: 90s
|
||||
- Dark Missionary / Heart of Light: 90s
|
||||
- Troubadour / Tactician / Shield Samba: 120s
|
||||
- Temperance: 120s
|
||||
- Dark Missionary / Heart of Light: 90s
|
||||
|
||||
### 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
|
||||
- **Merge-Matching:** Mechaniken gelten als identisch wenn `abilityName` gleich und `|timestamp_a - timestamp_b| < 5000ms`
|
||||
- **Keine Spielernamen im Planer:** Assignments sind Job-basiert (`{ ability, job }`), damit Pläne übertragbar sind
|
||||
- **Analyse-Tab Overlay (Prio 7):** Job aus tatsächlichem Pull → lookup welche Ability dieser Job im Plan hatte → Soll/Ist-Vergleich
|
||||
- **Keine Spielernamen:** 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-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
|
||||
|
||||
220
css/planner.css
Normal file
220
css/planner.css
Normal 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
333
js/planner.js
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function fmtDate(ts) {
|
||||
return new Date(ts).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
}
|
||||
|
||||
function fmtTimestamp(ms) {
|
||||
const s = Math.floor(ms / 1000);
|
||||
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function fmtNumber(n) {
|
||||
return Number(n).toLocaleString('de-DE');
|
||||
}
|
||||
|
||||
// ── Rendering: Plan List ──────────────────────────────────────────────────────
|
||||
|
||||
function 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 · ${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)} · ${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);
|
||||
});
|
||||
@ -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)));
|
||||
|
||||
@ -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
31
templates/tab-planner.php
Normal 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>
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user