From d792d5b718f533b1a894de21bedeeca7179895e3 Mon Sep 17 00:00:00 2001 From: xziino Date: Wed, 20 May 2026 10:42:38 +0200 Subject: [PATCH] Initial commit: FFLogs mitigation analyzer Two-tab app: report viewer + analysis tab with AoE timeline, per-player mitigation icons (local XIVAPI PNGs), and fight-wide buff/debuff window tracking. Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 158 +++++++- api/analysis.php | 342 ++++++++++++++++++ assets/icons/mitigation/addle.png | Bin 0 -> 13578 bytes .../mitigation/collective-unconscious.png | Bin 0 -> 13612 bytes assets/icons/mitigation/dark-missionary.png | Bin 0 -> 16697 bytes assets/icons/mitigation/divine-veil.png | Bin 0 -> 14342 bytes assets/icons/mitigation/expedient.png | Bin 0 -> 15349 bytes assets/icons/mitigation/feint.png | Bin 0 -> 11729 bytes assets/icons/mitigation/fey-illumination.png | Bin 0 -> 13812 bytes assets/icons/mitigation/heart-of-light.png | Bin 0 -> 13077 bytes assets/icons/mitigation/holos.png | Bin 0 -> 16159 bytes assets/icons/mitigation/kerachole.png | Bin 0 -> 13299 bytes assets/icons/mitigation/panhaima.png | Bin 0 -> 15674 bytes assets/icons/mitigation/passage-of-arms.png | Bin 0 -> 17923 bytes assets/icons/mitigation/reprisal.png | Bin 0 -> 11296 bytes assets/icons/mitigation/sacred-soil.png | Bin 0 -> 12513 bytes assets/icons/mitigation/shake-it-off.png | Bin 0 -> 18459 bytes assets/icons/mitigation/shield-samba.png | Bin 0 -> 15930 bytes assets/icons/mitigation/tactician.png | Bin 0 -> 15218 bytes assets/icons/mitigation/temperance.png | Bin 0 -> 15865 bytes assets/icons/mitigation/troubadour.png | Bin 0 -> 18187 bytes css/analysis.css | 127 +++++++ css/base.css | 148 ++++++++ css/components.css | 189 ++++++++++ css/layout.css | 170 +++++++++ index.php | 197 +--------- js/analysis.js | 156 ++++++++ js/app.js | 57 ++- js/tabs.js | 19 + templates/fight-select.php | 6 + templates/login.php | 33 ++ templates/output-card.php | 9 + templates/page.php | 39 ++ templates/report-form.php | 20 + templates/tab-analysis.php | 23 ++ templates/tab-report.php | 3 + templates/topbar.php | 8 + 37 files changed, 1477 insertions(+), 227 deletions(-) create mode 100644 api/analysis.php create mode 100644 assets/icons/mitigation/addle.png create mode 100644 assets/icons/mitigation/collective-unconscious.png create mode 100644 assets/icons/mitigation/dark-missionary.png create mode 100644 assets/icons/mitigation/divine-veil.png create mode 100644 assets/icons/mitigation/expedient.png create mode 100644 assets/icons/mitigation/feint.png create mode 100644 assets/icons/mitigation/fey-illumination.png create mode 100644 assets/icons/mitigation/heart-of-light.png create mode 100644 assets/icons/mitigation/holos.png create mode 100644 assets/icons/mitigation/kerachole.png create mode 100644 assets/icons/mitigation/panhaima.png create mode 100644 assets/icons/mitigation/passage-of-arms.png create mode 100644 assets/icons/mitigation/reprisal.png create mode 100644 assets/icons/mitigation/sacred-soil.png create mode 100644 assets/icons/mitigation/shake-it-off.png create mode 100644 assets/icons/mitigation/shield-samba.png create mode 100644 assets/icons/mitigation/tactician.png create mode 100644 assets/icons/mitigation/temperance.png create mode 100644 assets/icons/mitigation/troubadour.png create mode 100644 css/analysis.css create mode 100644 css/base.css create mode 100644 css/components.css create mode 100644 css/layout.css create mode 100644 js/analysis.js create mode 100644 js/tabs.js create mode 100644 templates/fight-select.php create mode 100644 templates/login.php create mode 100644 templates/output-card.php create mode 100644 templates/page.php create mode 100644 templates/report-form.php create mode 100644 templates/tab-analysis.php create mode 100644 templates/tab-report.php create mode 100644 templates/topbar.php diff --git a/CLAUDE.md b/CLAUDE.md index 262dd82..7217756 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,23 +1,145 @@ -# ff14-auth — FFLogs Report Viewer +# ff14-mitigator — FFLogs Mitigation Analyzer ## Projekt -Einfache PHP/HTML/JS-Seite zum Verbinden mit FFLogs via OAuth2 PKCE und Abrufen von Report-Daten über die GraphQL-API. Kein Framework, kein Composer, kein npm — Plain PHP für Shared Hosting. +PHP/HTML/JS-Tool zum Analysieren von FFXIV-Raidlogs via FFLogs OAuth2 PKCE + GraphQL API. +Kein Framework, kein Composer, kein npm — Plain PHP für Shared Hosting. + +Zwei Tabs: +- **Report-Tab**: Report-Code eingeben, Fight auswählen, Raw-JSON-Ausgabe +- **Analyse-Tab**: Spielerübersicht + AoE-Timeline mit Mitigation-Tracking + +## Architektur & Konventionen + +### Trennung von PHP, HTML und JS +- **PHP-Logik** gehört ausschließlich in `index.php` (und API/Auth-Endpunkte). Keine Geschäftslogik in Templates. +- **HTML** gehört in `templates/`. Jede logisch in sich geschlossene Komponente ist eine eigene Datei. +- **CSS** gehört in `css/`. Jede CSS-Datei hat einen klar abgegrenzten Scope (base, layout, components, analysis). +- **JavaScript** gehört in `js/`. Keine Inline-Scripts in Templates außer dem ` - - - - - +require __DIR__ . '/templates/page.php'; diff --git a/js/analysis.js b/js/analysis.js new file mode 100644 index 0000000..5e0e325 --- /dev/null +++ b/js/analysis.js @@ -0,0 +1,156 @@ +(function () { + const MITIG_ICONS = { + 'Passage of Arms': 'assets/icons/mitigation/passage-of-arms.png', + 'Divine Veil': 'assets/icons/mitigation/divine-veil.png', + 'Shake It Off': 'assets/icons/mitigation/shake-it-off.png', + 'Dark Missionary': 'assets/icons/mitigation/dark-missionary.png', + 'Heart of Light': 'assets/icons/mitigation/heart-of-light.png', + 'Temperance': 'assets/icons/mitigation/temperance.png', + 'Sacred Soil': 'assets/icons/mitigation/sacred-soil.png', + 'Expedient': 'assets/icons/mitigation/expedient.png', + 'Fey Illumination': 'assets/icons/mitigation/fey-illumination.png', + 'Collective Unconscious': 'assets/icons/mitigation/collective-unconscious.png', + 'Holos': 'assets/icons/mitigation/holos.png', + 'Kerachole': 'assets/icons/mitigation/kerachole.png', + 'Panhaima': 'assets/icons/mitigation/panhaima.png', + 'Troubadour': 'assets/icons/mitigation/troubadour.png', + 'Tactician': 'assets/icons/mitigation/tactician.png', + 'Shield Samba': 'assets/icons/mitigation/shield-samba.png', + 'Reprisal': 'assets/icons/mitigation/reprisal.png', + 'Feint': 'assets/icons/mitigation/feint.png', + 'Addle': 'assets/icons/mitigation/addle.png', + }; + + const JOB_ABBR = { + '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', 'BlueMage': 'BLU', + }; + + function abbr(type) { + return JOB_ABBR[type] ?? type.slice(0, 3).toUpperCase(); + } + + function fmtTime(ms, start) { + const rel = ms - start; + const min = Math.floor(rel / 60000); + const sec = String(Math.floor((rel % 60000) / 1000)).padStart(2, '0'); + return `${min}:${sec}`; + } + + function fmtDmg(n) { + if (n >= 1_000_000) return (n / 1_000_000).toFixed(2) + 'M'; + if (n >= 1_000) return Math.round(n / 1_000) + 'k'; + return String(n); + } + + function renderPlayers(players) { + const grid = document.getElementById('player-grid'); + const order = { tank: 0, healer: 1, dps: 2 }; + players.sort((a, b) => (order[a.role] ?? 2) - (order[b.role] ?? 2)); + + grid.innerHTML = players.map(p => ` +
+
${abbr(p.type)}
+
+
${p.name}
+
${p.type}
+
+
+ `).join(''); + } + + function renderTimeline(events, fightStart) { + const el = document.getElementById('aoe-timeline'); + + if (!events.length) { + el.innerHTML = '

Keine AoE-Events gefunden

'; + return; + } + + el.innerHTML = events.map(ev => { + const mitigIcons = (ev.mitigations ?? []).map(m => { + const iconSrc = MITIG_ICONS[m.name]; + if (!iconSrc) return ''; + const dr = m.dr > 0 ? ` −${m.dr}%` : ''; + return `${m.name}`; + }).join(''); + + const targets = ev.targets.map(t => ` +
+
+ ${abbr(t.type)} + ${t.name} + ${fmtDmg(t.amount)} +
+ ${mitigIcons ? `
${mitigIcons}
` : ''} +
+ `).join(''); + + return ` +
+
${fmtTime(ev.timestamp, fightStart)}
+
+
+ ${ev.abilityName} + — ${fmtDmg(ev.totalDamage)} total +
+
${targets}
+
+
+ `; + }).join(''); + } + + function setEmpty(msg) { + document.getElementById('analysis-loading').style.display = 'none'; + document.getElementById('analysis-content').style.display = 'none'; + document.getElementById('analysis-empty').style.display = 'block'; + document.getElementById('analysis-empty-msg').textContent = msg; + } + + let lastFightId = null; + + async function load() { + const { reportCode, fightId, fightStart, fightEnd } = window.App ?? {}; + if (!reportCode || !fightId) return; + if (lastFightId === fightId) return; + + document.getElementById('analysis-loading').style.display = 'flex'; + document.getElementById('analysis-empty').style.display = 'none'; + document.getElementById('analysis-content').style.display = 'none'; + + let json; + try { + const res = await fetch('api/analysis.php', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ report_code: reportCode, fight_id: fightId, start_time: fightStart, end_time: fightEnd }), + }); + json = await res.json(); + } catch (err) { + setEmpty('Netzwerkfehler: ' + err.message); + return; + } + + if (json.reauth) { window.location.href = 'auth/start.php'; return; } + if (json.error) { setEmpty('Fehler: ' + json.error); return; } + + lastFightId = fightId; + renderPlayers(json.players ?? []); + renderTimeline(json.aoe_events ?? [], json.fight_start ?? fightStart); + + document.getElementById('analysis-loading').style.display = 'none'; + document.getElementById('analysis-content').style.display = 'block'; + } + + window.analysisTab = { + onFightSelected: load, + onTabOpen: load, + reset() { lastFightId = null; }, + }; +})(); diff --git a/js/app.js b/js/app.js index f980089..405a580 100644 --- a/js/app.js +++ b/js/app.js @@ -1,7 +1,11 @@ document.addEventListener('DOMContentLoaded', () => { + window.App = { reportCode: null, fightId: null, fightStart: 0, fightEnd: 0 }; + const form = document.getElementById('report-form'); const output = document.getElementById('output'); - const fightSelectRow = document.getElementById('fight-select-row'); + const outputCard = document.getElementById('output-card'); + const initialHint = document.getElementById('initial-hint'); + const fightSelectCard = document.getElementById('fight-select-card'); const fightSelect = document.getElementById('fight-select'); let allFights = []; @@ -21,32 +25,47 @@ document.addEventListener('DOMContentLoaded', () => { } function displayFight(fight) { - output.textContent = JSON.stringify(fight, null, 2); + output.textContent = JSON.stringify(fight, null, 2); + outputCard.style.display = 'block'; } fightSelect.addEventListener('change', () => { - const id = parseInt(fightSelect.value, 10); + if (!fightSelect.value) return; + const id = parseInt(fightSelect.value, 10); const fight = allFights.find(f => f.id === id); - if (fight) displayFight(fight); + if (!fight) return; + + window.App.fightId = id; + window.App.fightStart = fight.startTime; + window.App.fightEnd = fight.endTime; + + displayFight(fight); + window.analysisTab?.onFightSelected?.(); }); form.addEventListener('submit', async (e) => { e.preventDefault(); - output.textContent = '// fetching...'; - fightSelectRow.style.display = 'none'; - fightSelect.innerHTML = ''; + + initialHint.style.display = 'none'; + outputCard.style.display = 'block'; + output.textContent = '// fetching...'; + fightSelectCard.style.display = 'none'; + fightSelect.innerHTML = ''; allFights = []; - const params = new URLSearchParams({ - report_code: form.elements['report_code'].value.trim(), - }); + const reportCode = form.elements['report_code'].value.trim(); + window.App.reportCode = reportCode; + window.App.fightId = null; + window.App.fightStart = 0; + window.App.fightEnd = 0; + window.analysisTab?.reset?.(); let response, json; try { response = await fetch('api/fight.php', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: params.toString(), + body: new URLSearchParams({ report_code: reportCode }), }); json = await response.json(); } catch (err) { @@ -78,18 +97,16 @@ document.addEventListener('DOMContentLoaded', () => { return; } - // populate dropdown allFights.forEach(fight => { - const duration = formatDuration(fight.endTime - fight.startTime); - const hp = formatBossHp(fight); - const label = `${fight.name} — ${duration} — ${hp}`; - const opt = document.createElement('option'); - opt.value = fight.id; - opt.textContent = label; + const duration = formatDuration(fight.endTime - fight.startTime); + const hp = formatBossHp(fight); + const opt = document.createElement('option'); + opt.value = fight.id; + opt.textContent = `${fight.name} — ${duration} — ${hp}`; fightSelect.appendChild(opt); }); - fightSelectRow.style.display = 'flex'; - output.textContent = '// Fight auswählen ↑'; + fightSelectCard.style.display = 'block'; + output.textContent = '// Fight auswählen ↑'; }); }); diff --git a/js/tabs.js b/js/tabs.js new file mode 100644 index 0000000..0f889ce --- /dev/null +++ b/js/tabs.js @@ -0,0 +1,19 @@ +document.addEventListener('DOMContentLoaded', () => { + const tabs = document.querySelectorAll('.tabs .tab'); + const contents = document.querySelectorAll('.tab-content'); + + function showTab(name) { + contents.forEach(el => el.style.display = 'none'); + tabs.forEach(btn => btn.classList.remove('active')); + + const content = document.getElementById('tab-' + name); + const btn = document.querySelector(`.tabs .tab[data-tab="${name}"]`); + + if (content) content.style.display = 'block'; + if (btn) btn.classList.add('active'); + + if (name === 'analysis') window.analysisTab?.onTabOpen?.(); + } + + tabs.forEach(btn => btn.addEventListener('click', () => showTab(btn.dataset.tab))); +}); diff --git a/templates/fight-select.php b/templates/fight-select.php new file mode 100644 index 0000000..da53a17 --- /dev/null +++ b/templates/fight-select.php @@ -0,0 +1,6 @@ + diff --git a/templates/login.php b/templates/login.php new file mode 100644 index 0000000..ef804a3 --- /dev/null +++ b/templates/login.php @@ -0,0 +1,33 @@ +
+ +
diff --git a/templates/output-card.php b/templates/output-card.php new file mode 100644 index 0000000..452d769 --- /dev/null +++ b/templates/output-card.php @@ -0,0 +1,9 @@ + + +
+
+

Report Code eingeben und Fetch klicken

+
diff --git a/templates/page.php b/templates/page.php new file mode 100644 index 0000000..883cf8e --- /dev/null +++ b/templates/page.php @@ -0,0 +1,39 @@ + + + + + + FFLogs Report Viewer + + + + + + + + + + +
+ +
+ +
+ + +
+ +
+ + +
+
+ + + + + + + diff --git a/templates/report-form.php b/templates/report-form.php new file mode 100644 index 0000000..e6a9711 --- /dev/null +++ b/templates/report-form.php @@ -0,0 +1,20 @@ +
+
Report laden
+
+
+
+ + +
+ + Reconnect +
+
+
diff --git a/templates/tab-analysis.php b/templates/tab-analysis.php new file mode 100644 index 0000000..657bdc9 --- /dev/null +++ b/templates/tab-analysis.php @@ -0,0 +1,23 @@ + + +
+
📊
+

Bitte zuerst einen Fight im Report-Tab auswählen

+
+ + diff --git a/templates/tab-report.php b/templates/tab-report.php new file mode 100644 index 0000000..0c2a796 --- /dev/null +++ b/templates/tab-report.php @@ -0,0 +1,3 @@ + + + diff --git a/templates/topbar.php b/templates/topbar.php new file mode 100644 index 0000000..a67b7f7 --- /dev/null +++ b/templates/topbar.php @@ -0,0 +1,8 @@ +
+ + +
Token gültig bis:
+