From 349645f4cb96025cfd9700abcea40bc71e1b03e0 Mon Sep 17 00:00:00 2001 From: xziino Date: Fri, 22 May 2026 22:20:37 +0200 Subject: [PATCH 1/7] Analyse-Tab: Export-Auswahl zwischen aktuellem und Referenz-Fight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wenn eine Referenz aktiv ist, öffnet der Export-Button ein kleines Dropdown mit den Optionen "Aktueller Fight" und "Referenz-Fight". Co-Authored-By: Claude Sonnet 4.6 --- css/components.css | 24 ++++++++++++++ js/analysis.js | 78 ++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 100 insertions(+), 2 deletions(-) diff --git a/css/components.css b/css/components.css index 02bbfe4..55ac8a4 100644 --- a/css/components.css +++ b/css/components.css @@ -80,6 +80,30 @@ select option { background: var(--bg2); } .btn-sm { padding: 5px 13px; font-size: 13px; } +/* ── Export choice dropdown ─────────────────────────────────────────────────── */ +.export-choice-menu { + position: fixed; + z-index: 1000; + background: var(--bg2); + border: 1px solid var(--bg3); + border-radius: var(--r); + overflow: hidden; + box-shadow: 0 4px 16px rgba(0,0,0,0.5); +} +.export-choice-item { + display: block; + width: 100%; + padding: 9px 18px; + background: transparent; + border: none; + color: var(--t1); + font-size: 13px; + text-align: left; + cursor: pointer; + white-space: nowrap; +} +.export-choice-item:hover { background: var(--bg3); color: var(--gold); } + /* ── Stats row ──────────────────────────────────────────────────────────────── */ .stats-row { display: flex; diff --git a/js/analysis.js b/js/analysis.js index 5f160eb..ffc0de5 100644 --- a/js/analysis.js +++ b/js/analysis.js @@ -724,6 +724,42 @@ mitigationNames, }; }, + exportRefForPlanner() { + const sameReportId = parseInt(refFightSelect.value, 10); + const extId = parseInt(refExtFightSelect.value, 10); + let fight = null, reportCode = '', fightId = 0; + if (sameReportId) { + fight = allSameReportFights.find(f => f.id === sameReportId); + reportCode = window.App?.reportCode ?? ''; + fightId = sameReportId; + } else if (extId) { + fight = extFights.find(f => f.id === extId); + reportCode = extReportCode; + fightId = extId; + } + const transitions = fight?.phaseTransitions ?? []; + const phases = transitions.length === 0 ? [] : [ + { id: 0, name: 'Ganzer Fight', startTime: fight.startTime, endTime: fight.endTime }, + ...transitions.map((t, i) => ({ + id: t.id, + name: `Phase ${t.id}`, + startTime: t.startTime, + endTime: transitions[i + 1]?.startTime ?? fight.endTime, + })), + ]; + return { + aoeEvents: refEvents, + fightStart: refFightStart, + phases, + players: refPlayers, + fightName: fight?.name ?? 'Referenz-Fight', + reportCode, + fightId, + fightEnd: fight?.endTime ?? 0, + mitigationNames, + }; + }, + hasRefExport() { return refEvents.length > 0; }, reset() { lastFightId = null; refEvents = []; @@ -745,7 +781,45 @@ }, }; - document.getElementById('export-to-planner-btn')?.addEventListener('click', () => { - window.plannerTab?.showImportModal(window.analysisTab.exportForPlanner()); + document.getElementById('export-to-planner-btn')?.addEventListener('click', (e) => { + if (!refEvents.length) { + window.plannerTab?.showImportModal(window.analysisTab.exportForPlanner()); + return; + } + showExportChoiceMenu(e.currentTarget); }); + + function showExportChoiceMenu(anchor) { + document.getElementById('export-choice-menu')?.remove(); + const menu = document.createElement('div'); + menu.id = 'export-choice-menu'; + menu.className = 'export-choice-menu'; + + [ + { label: 'Aktueller Fight', fn: () => window.analysisTab.exportForPlanner() }, + { label: 'Referenz-Fight', fn: () => window.analysisTab.exportRefForPlanner() }, + ].forEach(({ label, fn }) => { + const btn = document.createElement('button'); + btn.className = 'export-choice-item'; + btn.textContent = label; + btn.addEventListener('click', () => { + menu.remove(); + window.plannerTab?.showImportModal(fn()); + }); + menu.appendChild(btn); + }); + + document.body.appendChild(menu); + const rect = anchor.getBoundingClientRect(); + menu.style.top = (rect.bottom + 4) + 'px'; + menu.style.right = (window.innerWidth - rect.right) + 'px'; + + const close = (ev) => { + if (!menu.contains(ev.target) && ev.target !== anchor) { + menu.remove(); + document.removeEventListener('click', close, true); + } + }; + setTimeout(() => document.addEventListener('click', close, true), 0); + } })(); From 07a140442fac94b6839acaead2ae738ff3c5ddfc Mon Sep 17 00:00:00 2001 From: xziino Date: Fri, 22 May 2026 22:24:44 +0200 Subject: [PATCH 2/7] Analyse-Tab: Ref-Vergleich nach Job statt Spielername MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fehlende Mitigations werden nun anhand des Job-Typs (z.B. SGE, PLD) zugeordnet statt per Spielername — funktioniert auch wenn der Referenz- Fight mit einer anderen Gruppe gespielt wurde. Co-Authored-By: Claude Sonnet 4.6 --- js/analysis.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/js/analysis.js b/js/analysis.js index ffc0de5..96d262a 100644 --- a/js/analysis.js +++ b/js/analysis.js @@ -508,7 +508,7 @@ })() : ''; const currentMitigKeys = new Set((t.mitigations ?? []).map(m => m.key ?? m.name)); - const refTarget = refEv?.targets?.find(rt => rt.name === t.name); + const refTarget = refEv?.targets?.find(rt => t.type ? rt.type === t.type : rt.name === t.name); const missingMitigs = refTarget ? (refTarget.mitigations ?? []).filter(m => m.buffType === 'buff' && !currentMitigKeys.has(m.key ?? m.name)) : []; @@ -561,8 +561,8 @@ (!playerFilter || t.name.toLowerCase().includes(playerFilter)) ); if (refVisible.length) { - const currentByName = {}; - ev.targets.forEach(t => { currentByName[t.name] = t; }); + const currentByType = {}; + ev.targets.forEach(t => { currentByType[t.type || t.name] = t; }); const seenRefDebuffKeys = new Set(); const refDebuffIconsHtml = refVisible.flatMap(t => (t.mitigations ?? [])) @@ -575,7 +575,7 @@ }).join(''); const refCards = refVisible.map(t => { - const curr = currentByName[t.name]; + const curr = currentByType[t.type || t.name]; const diff = curr ? curr.amount - t.amount : 0; const dead = t.hp === 0 && t.maxHp > 0; From 565dedc56863b2b0530dc15931c790aea1487e27 Mon Sep 17 00:00:00 2001 From: xziino Date: Fri, 22 May 2026 22:53:01 +0200 Subject: [PATCH 3/7] Analyse-Tab: Job-basierter Mitigation-Vergleich statt Namens-Match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ref-Vergleich prüft jetzt ob die aktuelle Gruppe den passenden Job hat (via ABILITY_JOBS-Map), statt Spielernamen zu matchen. Fehlende Mitigations werden nur noch in der REF-Zeile hervorgehoben — der aktive Pull zeigt ausschließlich tatsächlich genutzte Mitigations. Co-Authored-By: Claude Sonnet 4.6 --- js/analysis.js | 109 ++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 86 insertions(+), 23 deletions(-) diff --git a/js/analysis.js b/js/analysis.js index 96d262a..6fdede0 100644 --- a/js/analysis.js +++ b/js/analysis.js @@ -54,6 +54,63 @@ 'Pictomancer': 'PCT', 'BlueMage': 'BLU', }; + // ability name → jobs that can provide it (for job-based ref comparison) + const ABILITY_JOBS = { + 'Passage of Arms': ['PLD'], + 'Divine Veil': ['PLD'], + 'Guardian': ['PLD'], + 'Reprisal': ['PLD', 'WAR', 'DRK', 'GNB'], + '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'], + 'Eukrasian Prognosis': ['SGE'], + 'Eukrasian Prognosis II': ['SGE'], + 'Eukrasian Diagnosis': ['SGE'], + 'Differential Diagnosis': ['SGE'], + 'Haima': ['SGE'], + 'Troubadour': ['BRD'], + 'Tactician': ['MCH'], + 'Shield Samba': ['DNC'], + 'Improvised Finish': ['DNC'], + 'Feint': ['MNK', 'DRG', 'NIN', 'SAM', 'RPR', 'VPR'], + 'Addle': ['SCH', 'SGE', 'BLM', 'SMN', 'RDM', 'PCT'], + 'Radiant Aegis': ['SMN'], + 'Magick Barrier': ['RDM'], + 'Tempera Coat': ['PCT'], + 'Tempera Grassa': ['PCT'], + }; + + // Deduplicated list of all mitigations across all targets of a ref event + function collectRefMitigs(refEvent) { + if (!refEvent) return []; + const seen = new Set(), result = []; + for (const t of refEvent.targets ?? []) { + for (const m of (t.mitigations ?? [])) { + const k = m.key ?? m.name; + if (!seen.has(k)) { seen.add(k); result.push(m); } + } + } + return result; + } + function abbr(type) { return JOB_ABBR[type] ?? type.slice(0, 3).toUpperCase(); } @@ -441,6 +498,8 @@ return; } + const currentFightJobSet = new Set(currentPlayers.map(p => JOB_ABBR[p.type]).filter(Boolean)); + // Build reference index: abilityName → [events in order] const refIndex = {}; for (const ev of refEvents) { @@ -455,6 +514,11 @@ const occ = abilityOccurrence[ev.abilityName] ?? 0; abilityOccurrence[ev.abilityName] = occ + 1; const refEv = refEvents.length ? (refIndex[ev.abilityName]?.[occ] ?? null) : null; + const allRefMitigs = collectRefMitigs(refEv); + const currentEventMitigKeys = new Set(); + for (const t of ev.targets) { + for (const m of (t.mitigations ?? [])) currentEventMitigKeys.add(m.key ?? m.name); + } const visibleTargets = ev.targets.filter(t => !hiddenPlayers.has(t.id) && @@ -476,7 +540,11 @@ } } const eventMissingDebuffs = refEv - ? (refEv.targets[0]?.mitigations ?? []).filter(m => m.buffType === 'debuff' && !seenDebuffKeys.has(m.key ?? m.name)) + ? allRefMitigs.filter(m => { + if (m.buffType !== 'debuff' || seenDebuffKeys.has(m.key ?? m.name)) return false; + const jobs = ABILITY_JOBS[m.key] ?? ABILITY_JOBS[m.name]; + return jobs ? jobs.some(j => currentFightJobSet.has(j)) : false; + }) : []; const debuffIconsHtml = [ ...eventDebuffs.map(m => ({ ...m, missing: false })), @@ -507,12 +575,6 @@ `; })() : ''; - const currentMitigKeys = new Set((t.mitigations ?? []).map(m => m.key ?? m.name)); - const refTarget = refEv?.targets?.find(rt => t.type ? rt.type === t.type : rt.name === t.name); - const missingMitigs = refTarget - ? (refTarget.mitigations ?? []).filter(m => m.buffType === 'buff' && !currentMitigKeys.has(m.key ?? m.name)) - : []; - // DR buff icons (shown below player box) const mitigIcons = (t.mitigations ?? []).filter(m => m.buffType === 'buff').map(m => { const iconSrc = MITIG_ICONS[m.key] ?? MITIG_ICONS[m.name]; @@ -522,14 +584,8 @@ }).join(''); // Shield tooltip on absorbed value - const activeShields = (t.mitigations ?? []).filter(m => m.buffType === 'shield'); - const missingShields = refTarget - ? (refTarget.mitigations ?? []).filter(m => m.buffType === 'shield' && !currentMitigKeys.has(m.key ?? m.name)) - : []; - const shieldLines = [ - ...activeShields.map(s => s.name), - ...missingShields.map(s => `[fehlt: ${s.name}]`), - ]; + const activeShields = (t.mitigations ?? []).filter(m => m.buffType === 'shield'); + const shieldLines = activeShields.map(s => s.name); const shieldTitle = shieldLines.length ? shieldLines.join('\n') : null; const dead = t.hp === 0 && t.maxHp > 0; @@ -561,8 +617,8 @@ (!playerFilter || t.name.toLowerCase().includes(playerFilter)) ); if (refVisible.length) { - const currentByType = {}; - ev.targets.forEach(t => { currentByType[t.type || t.name] = t; }); + const currentByName = {}; + ev.targets.forEach(t => { currentByName[t.name] = t; }); const seenRefDebuffKeys = new Set(); const refDebuffIconsHtml = refVisible.flatMap(t => (t.mitigations ?? [])) @@ -575,7 +631,7 @@ }).join(''); const refCards = refVisible.map(t => { - const curr = currentByType[t.type || t.name]; + const curr = currentByName[t.name]; const diff = curr ? curr.amount - t.amount : 0; const dead = t.hp === 0 && t.maxHp > 0; @@ -583,13 +639,14 @@ ? `${diff > 0 ? '+' : '-'}${fmtDmg(Math.abs(diff))}` : ''; - const currMitigKeys = new Set((curr?.mitigations ?? []).map(m => m.key ?? m.name)); - const refMitigIcons = (t.mitigations ?? []).filter(m => m.buffType === 'buff').map(m => { const iconSrc = MITIG_ICONS[m.key] ?? MITIG_ICONS[m.name]; if (!iconSrc) return ''; - const dr = m.dr > 0 ? ` −${m.dr}%` : ''; - const missing = !currMitigKeys.has(m.key ?? m.name); + const dr = m.dr > 0 ? ` −${m.dr}%` : ''; + const k = m.key ?? m.name; + const jobs = ABILITY_JOBS[k] ?? ABILITY_JOBS[m.name]; + const currentGroupHasJob = jobs ? jobs.some(j => currentFightJobSet.has(j)) : false; + const missing = currentGroupHasJob && !currentEventMitigKeys.has(k); const cls = missing ? ' aoe-buff-ref-unique' : ''; const titleSufx = missing ? ' (fehlt im aktuellen Pull)' : ''; return `${m.name}`; @@ -597,7 +654,13 @@ const refShields = (t.mitigations ?? []).filter(m => m.buffType === 'shield'); const refShieldTitle = refShields.length - ? refShields.map(s => currMitigKeys.has(s.key ?? s.name) ? s.name : `${s.name} [fehlt im aktuellen Pull]`).join('\n') + ? refShields.map(s => { + const k = s.key ?? s.name; + const jobs = ABILITY_JOBS[k]; + const currentGroupHasJob = jobs ? jobs.some(j => currentFightJobSet.has(j)) : false; + const isMissing = currentGroupHasJob && !currentEventMitigKeys.has(k); + return isMissing ? `${s.name} [fehlt im aktuellen Pull]` : s.name; + }).join('\n') : null; return ` From 6024560e61e41782400f00c4723fc4f921746ce0 Mon Sep 17 00:00:00 2001 From: xziino Date: Sat, 23 May 2026 08:08:22 +0200 Subject: [PATCH 4/7] Planner: Namen + Job Import aus beliebigem Report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Neues api/players.php: playerDetails + maxHp aus DamageTaken in einem GQL-Query - Import-Modal übernimmt Jobs UND Namen (Heiler → DPS → Tank → Alphabet) - buildPlayerRoster/extractJobComp: einheitliche Sortierreihenfolge - Ref-Fight Export überträgt jetzt auch die Jobaufstellung - Job-Slots zeigen importierte Spielernamen an Co-Authored-By: Claude Sonnet 4.6 --- api/players.php | 95 ++++++++++++++++++ css/planner.css | 57 +++++++++++ js/analysis.js | 2 +- js/planner.js | 236 +++++++++++++++++++++++++++++++++++++++++++-- templates/page.php | 29 ++++++ 5 files changed, 412 insertions(+), 7 deletions(-) create mode 100644 api/players.php diff --git a/api/players.php b/api/players.php new file mode 100644 index 0000000..263ce13 --- /dev/null +++ b/api/players.php @@ -0,0 +1,95 @@ + 'Method not allowed']); exit; } +if (empty($_SESSION['access_token'])) { echo json_encode(['reauth' => true]); exit; } +if (($_SESSION['token_expires'] ?? 0) <= time()) { echo json_encode(['reauth' => true]); exit; } + +$reportCode = preg_replace('/[^a-zA-Z0-9]/', '', $_POST['report_code'] ?? ''); +$fightId = (int)($_POST['fight_id'] ?? 0); +$startTime = (float)($_POST['start_time'] ?? 0); +$endTime = (float)($_POST['end_time'] ?? 0); + +if (!$reportCode || !$fightId || !$endTime) { + http_response_code(400); + echo json_encode(['error' => 'Missing params']); + exit; +} + +$token = $_SESSION['access_token']; + +function pl_gql(string $query): array { + global $token; + $ch = curl_init(GRAPHQL_URI); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => json_encode(['query' => $query]), + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Authorization: Bearer ' . $token], + CURLOPT_SSL_VERIFYPEER => !DEV_MODE, + ]); + $body = curl_exec($ch); + $err = curl_error($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + if ($err) return ['_curl_error' => $err]; + if ($code === 401) return ['_reauth' => true]; + return json_decode($body, true) ?? ['_parse_error' => true]; +} + +// Single query: playerDetails + first page of DamageTaken with resources to get maxHp +$result = pl_gql(<< true]); exit; } +if (isset($result['_curl_error'])) { http_response_code(502); echo json_encode(['error' => $result['_curl_error']]); exit; } + +// Parse player details: id → {name, type, role, maxHp} +$pdRaw = $result['data']['reportData']['report']['playerDetails'] ?? null; +$pdParsed = is_string($pdRaw) ? json_decode($pdRaw, true) : $pdRaw; +$pdGroups = $pdParsed['data']['playerDetails'] ?? []; + +$players = []; +$roleMap = ['tanks' => 'tank', 'healers' => 'healer', 'dps' => 'dps']; +foreach ($roleMap as $group => $role) { + foreach ($pdGroups[$group] ?? [] as $p) { + $players[(int)$p['id']] = [ + 'name' => $p['name'], + 'type' => $p['type'] ?? '', + 'role' => $role, + 'maxHp' => 0, + ]; + } +} + +// Extract maxHp from first damage events (skip DoT ticks — they may lack resources) +foreach ($result['data']['reportData']['report']['events']['data'] ?? [] as $ev) { + if ($ev['tick'] ?? false) continue; + $tid = (int)($ev['targetID'] ?? 0); + $maxHp = (int)($ev['targetResources']['maxHitPoints'] ?? 0); + if (isset($players[$tid]) && $players[$tid]['maxHp'] === 0 && $maxHp > 0) { + $players[$tid]['maxHp'] = $maxHp; + } +} + +echo json_encode(['players' => array_values($players)]); diff --git a/css/planner.css b/css/planner.css index a21f166..dd7f516 100644 --- a/css/planner.css +++ b/css/planner.css @@ -159,6 +159,63 @@ .job-slot--healer select { border-left-color: var(--green); } .job-slot--dps select { border-left-color: rgba(200,168,75,.5); } +/* ── Job Slot Player Names ───────────────────────────────────────────────────── */ +.job-slot-name { + font-size: 11px; + color: var(--t2); + text-align: center; + margin-top: 3px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} + +/* ── Name Import Modal ───────────────────────────────────────────────────────── */ +.name-import-input-row { + display: flex; + gap: 8px; +} +.name-import-input-row input { flex: 1; } + +.name-import-preview { + background: var(--bg2); + border: 1px solid var(--border); + border-radius: var(--r); + overflow: hidden; + margin-bottom: 4px; + max-height: 300px; + overflow-y: auto; +} + +.name-import-row { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 12px; + border-bottom: 1px solid var(--border); +} +.name-import-row:last-child { border-bottom: none; } + +.name-import-name { + font-size: 14px; + color: var(--t1); + flex: 1; +} + +.name-import-name--none { + font-size: 13px; + color: var(--t3); + font-style: italic; + flex: 1; +} + +.name-import-disambig { + flex: 1; + font-size: 13px; + padding: 3px 6px; +} + /* ── Mechanic Cards ──────────────────────────────────────────────────────────── */ .mechanic-card { display: grid; diff --git a/js/analysis.js b/js/analysis.js index 6fdede0..4f54dc4 100644 --- a/js/analysis.js +++ b/js/analysis.js @@ -321,7 +321,7 @@ if (!json.error && !json.reauth) { refEvents = json.aoe_events ?? []; refFightStart = json.fight_start ?? fight.startTime; - refPlayers = []; + refPlayers = json.players ?? []; window.App.setUrlState?.({ compareReportCode: '', compareFightId: refId, diff --git a/js/planner.js b/js/planner.js index 6acb9ad..d3a11a4 100644 --- a/js/planner.js +++ b/js/planner.js @@ -325,7 +325,10 @@ function renderPlanDetail(plan) {
-
Jobaufstellung
+
+
Jobaufstellung
+ +
${renderJobSlotsHtml(plan)}
@@ -345,6 +348,9 @@ function renderPlanDetail(plan) { startRename(plan.id, plan.name); }); initJobSlots(plan.id); + document.getElementById('name-import-open-btn')?.addEventListener('click', () => { + showNameImportModal(plan.id); + }); initMechanicClicks(plan.id); renderInfoPanel(plan); } @@ -417,9 +423,11 @@ function renderMechanicListHtml(plan) { // ── Job Slots ───────────────────────────────────────────────────────────────── function renderJobSlotsHtml(plan) { + const roster = plan.playerRoster ?? []; return Array.from({ length: 8 }, (_, i) => { - const job = plan.jobComposition[i] ?? ''; - const role = JOB_ROLE[job] ?? ''; + const job = plan.jobComposition[i] ?? ''; + const role = JOB_ROLE[job] ?? ''; + const playerName = roster[i]?.name ?? ''; return `
+ ${playerName ? `
${escHtml(playerName)}
` : ''}
`; }).join(''); } @@ -1009,12 +1018,36 @@ function aoeEventsToMechanics(aoeEvents, fightStart, phases, players, withMitiga // ── Merge + Create plan from import ────────────────────────────────────────── -function extractJobComp(players) { - const order = { tank: 0, healer: 1, dps: 2 }; +function buildPlayerRoster(players, aoeEvents) { + const maxHpByName = {}; + for (const ev of aoeEvents ?? []) { + for (const t of ev.targets ?? []) { + if (t.name && t.maxHp > 0 && !(t.name in maxHpByName)) { + maxHpByName[t.name] = t.maxHp; + } + } + } + const order = { healer: 0, dps: 1, tank: 2 }; const sorted = [...(players ?? [])] .filter(p => JOB_FROM_TYPE[p.type]) .sort((a, b) => { - const roleCmp = (order[a.role] ?? 2) - (order[b.role] ?? 2); + const roleCmp = (order[a.role] ?? 1) - (order[b.role] ?? 1); + return roleCmp !== 0 ? roleCmp : a.name.localeCompare(b.name); + }); + const roster = sorted.slice(0, 8).map(p => ({ + name: p.name, + maxHp: maxHpByName[p.name] ?? 0, + })); + while (roster.length < 8) roster.push({ name: '', maxHp: 0 }); + return roster; +} + +function extractJobComp(players) { + const order = { healer: 0, dps: 1, tank: 2 }; + const sorted = [...(players ?? [])] + .filter(p => JOB_FROM_TYPE[p.type]) + .sort((a, b) => { + const roleCmp = (order[a.role] ?? 1) - (order[b.role] ?? 1); return roleCmp !== 0 ? roleCmp : a.name.localeCompare(b.name); }); const comp = sorted.map(p => JOB_FROM_TYPE[p.type] ?? '').slice(0, 8); @@ -1034,6 +1067,7 @@ function doImport(data, withMitigations, whereMode, mergeId, newName) { source, mitigationNames, jobComposition: extractJobComp(players), + playerRoster: buildPlayerRoster(players, aoeEvents), }); } @@ -1334,6 +1368,195 @@ function initImportModal() { }); } +// ── Name Import Modal ───────────────────────────────────────────────────────── + +let nameImportPlanId = null; +let nameImportFights = []; +let nameImportReportCode = ''; +let nameImportMatchData = []; // length-8 array: null | {job, matched:[{name,type,role,maxHp}], selected:0} + +function showNameImportModal(planId) { + nameImportPlanId = planId; + nameImportFights = []; + nameImportReportCode = ''; + nameImportMatchData = []; + + document.getElementById('name-import-report-input').value = ''; + document.getElementById('name-import-fight-section').style.display = 'none'; + document.getElementById('name-import-fight-select').innerHTML = ''; + document.getElementById('name-import-preview').style.display = 'none'; + document.getElementById('name-import-preview').innerHTML = ''; + document.getElementById('name-import-confirm-btn').style.display = 'none'; + + document.getElementById('planner-name-import-modal').style.display = 'flex'; + document.getElementById('name-import-report-input').focus(); +} + +function hideNameImportModal() { + document.getElementById('planner-name-import-modal').style.display = 'none'; + nameImportPlanId = null; +} + +function refreshJobSlots(planId) { + const plan = getPlan(planId); + if (!plan) return; + const grid = document.getElementById('job-slots-grid'); + if (grid) grid.innerHTML = renderJobSlotsHtml(plan); +} + +function renderNameImportPreview(_plan, fetchedPlayers) { + const order = { healer: 0, dps: 1, tank: 2 }; + const sorted = [...fetchedPlayers] + .filter(p => JOB_FROM_TYPE[p.type]) + .sort((a, b) => { + const roleCmp = (order[a.role] ?? 1) - (order[b.role] ?? 1); + return roleCmp !== 0 ? roleCmp : a.name.localeCompare(b.name); + }); + + nameImportMatchData = sorted.slice(0, 8); + + const preview = document.getElementById('name-import-preview'); + + const rows = Array.from({ length: 8 }, (_, i) => { + const p = sorted[i]; + if (!p) { + return ` +
+ + Leer +
`; + } + const job = JOB_FROM_TYPE[p.type] ?? p.type; + const role = JOB_ROLE[job] ?? 'dps'; + return ` +
+ ${escHtml(job)} + ${escHtml(p.name)} +
`; + }).join(''); + + preview.innerHTML = rows; + preview.style.display = ''; +} + +function initNameImportModal() { + const modal = document.getElementById('planner-name-import-modal'); + const reportInput = document.getElementById('name-import-report-input'); + const loadBtn = document.getElementById('name-import-load-btn'); + const fightSection = document.getElementById('name-import-fight-section'); + const fightSelect = document.getElementById('name-import-fight-select'); + const preview = document.getElementById('name-import-preview'); + const confirmBtn = document.getElementById('name-import-confirm-btn'); + const cancelBtn = document.getElementById('name-import-cancel-btn'); + if (!modal) return; + + cancelBtn.addEventListener('click', hideNameImportModal); + modal.addEventListener('click', e => { if (e.target === modal) hideNameImportModal(); }); + document.addEventListener('keydown', e => { + if (e.key === 'Escape' && nameImportPlanId) hideNameImportModal(); + }); + + reportInput.addEventListener('input', () => { + const match = reportInput.value.match(/fflogs\.com\/reports\/([A-Za-z0-9]+)/); + if (match) reportInput.value = match[1]; + }); + + loadBtn.addEventListener('click', async () => { + const code = reportInput.value.trim(); + if (!code) return; + + loadBtn.disabled = true; + loadBtn.textContent = 'Lädt…'; + fightSection.style.display = 'none'; + preview.style.display = 'none'; + confirmBtn.style.display = 'none'; + + try { + const res = await fetch('api/fight.php', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ report_code: code, language: 'en' }), + }); + const json = await res.json(); + if (json.reauth) { window.location.href = window.App?.authStartUrl?.() ?? 'auth/start.php'; return; } + + nameImportFights = json?.data?.reportData?.report?.fights ?? []; + nameImportReportCode = code; + + fightSelect.innerHTML = ''; + nameImportFights.forEach(f => { + const ms = f.endTime - f.startTime; + const dur = `${Math.floor(ms / 60000)}:${String(Math.floor((ms % 60000) / 1000)).padStart(2, '0')}`; + const hp = f.kill ? 'Kill' : (f.fightPercentage != null ? f.fightPercentage.toFixed(2) + '%' : '?'); + const opt = document.createElement('option'); + opt.value = f.id; + opt.textContent = `${f.name} — ${dur} — ${hp}`; + fightSelect.appendChild(opt); + }); + fightSection.style.display = ''; + } catch { } + + loadBtn.disabled = false; + loadBtn.textContent = 'Laden'; + }); + + fightSelect.addEventListener('change', async () => { + const fightId = parseInt(fightSelect.value, 10); + if (!fightId) { + preview.style.display = 'none'; + confirmBtn.style.display = 'none'; + return; + } + const fight = nameImportFights.find(f => f.id === fightId); + if (!fight) return; + + fightSelect.disabled = true; + preview.innerHTML = '
Lädt…
'; + preview.style.display = ''; + confirmBtn.style.display = 'none'; + + try { + const res = await fetch('api/players.php', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + report_code: nameImportReportCode, + fight_id: fightId, + start_time: fight.startTime, + end_time: fight.endTime, + }), + }); + const json = await res.json(); + if (json.reauth) { window.location.href = window.App?.authStartUrl?.() ?? 'auth/start.php'; return; } + if (json.error) { preview.innerHTML = `
${escHtml(json.error)}
`; return; } + + const plan = getPlan(nameImportPlanId); + if (!plan) return; + renderNameImportPreview(plan, json.players ?? []); + confirmBtn.style.display = ''; + } catch { } + + fightSelect.disabled = false; + }); + + confirmBtn.addEventListener('click', () => { + if (!nameImportPlanId) return; + + const jobComposition = Array.from({ length: 8 }, (_, i) => { + const p = nameImportMatchData[i]; + return p ? (JOB_FROM_TYPE[p.type] ?? '') : ''; + }); + const playerRoster = Array.from({ length: 8 }, (_, i) => { + const p = nameImportMatchData[i]; + return p ? { name: p.name, maxHp: p.maxHp ?? 0 } : { name: '', maxHp: 0 }; + }); + + updatePlan(nameImportPlanId, { jobComposition, playerRoster }); + openPlan(nameImportPlanId); + hideNameImportModal(); + }); +} + // ── window.plannerTab (hooks for other tabs) ────────────────────────────────── window.plannerTab = { @@ -1362,6 +1585,7 @@ document.addEventListener('DOMContentLoaded', () => { initNewFolderForm(); initImportModal(); initAbilityModal(); + initNameImportModal(); activePlanId = localStorage.getItem(PLANNER_ACTIVE_KEY); renderPlanList(); if (activePlanId && getPlan(activePlanId)) { diff --git a/templates/page.php b/templates/page.php index 17e3bc9..249c768 100644 --- a/templates/page.php +++ b/templates/page.php @@ -80,6 +80,35 @@
+ + + +
+ + +
+