Merge akus_schabernack3: Lokalisierung + Tab-Persistenz

- Lokalisierte Ability-Namen in Badges und Modal
- Aktiver Plan wird per localStorage über Reload hinweg gespeichert
- Aktiver Tab wird per localStorage gespeichert
- refreshPlanLanguage() aktualisiert Namen beim Sprachwechsel
- api/analysis.php gibt mitigation_names zurück
- ff14-language-change Event in app.js

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
xziino 2026-05-22 15:58:32 +02:00
commit 4801148a8c
5 changed files with 198 additions and 21 deletions

View File

@ -212,11 +212,12 @@ foreach (MITIGATION_ABILITIES as $name => $meta) {
}
}
// statusId set for shield abilities — used to filter the buff timeline query
$shieldStatusIds = [];
// statusId set for tracked mitigations — used to resolve localized buff names
// from Buffs events and to build the shield fallback timeline.
$trackedStatusIds = [];
foreach (MITIGATION_ABILITIES as $meta) {
if ($meta['buffType'] === 'shield' && isset($meta['statusId'])) {
$shieldStatusIds[$meta['statusId']] = true;
if (isset($meta['statusId'])) {
$trackedStatusIds[$meta['statusId']] = true;
}
}
@ -276,8 +277,9 @@ for ($page = 0; $page < 10; $page++) {
// Builds applybuff/removebuff intervals per target so we can detect shields
// that were consumed by a hit (absent from the damage event's buffs snapshot).
$shieldTimeline = []; // targetId → statusId → [[apply, remove|null], ...]
$statusNames = []; // statusId → localized display name from Buffs events
if (!empty($shieldStatusIds)) {
if (!empty($trackedStatusIds)) {
$nextPage = $startTime;
for ($page = 0; $page < 10; $page++) {
$bfResult = fflogs_gql(<<<GQL
@ -304,11 +306,19 @@ if (!empty($shieldStatusIds)) {
$bfEv = $bfResult['data']['reportData']['report']['events'] ?? [];
foreach ($bfEv['data'] ?? [] as $ev) {
$abId = (int)($ev['abilityGameID'] ?? 0);
if (!isset($shieldStatusIds[$abId])) continue;
if (!isset($trackedStatusIds[$abId])) continue;
$evName = $ev['ability']['name'] ?? null;
if (is_string($evName) && $evName !== '') {
$statusNames[$abId] = $evName;
}
$tgtId = (int)($ev['targetID'] ?? 0);
$ts = (float)($ev['timestamp'] ?? 0);
$type = $ev['type'] ?? '';
$meta = $mitigIdMap[$abId] ?? null;
if (($meta['buffType'] ?? null) !== 'shield') continue;
if ($type === 'applybuff') {
$shieldTimeline[$tgtId][$abId][] = ['apply' => $ts, 'remove' => null];
@ -329,6 +339,20 @@ if (!empty($shieldStatusIds)) {
}
}
foreach ($statusNames as $statusId => $displayName) {
if (isset($mitigIdMap[$statusId])) {
$mitigIdMap[$statusId]['name'] = $displayName;
}
}
$mitigationNames = [];
foreach ($mitigIdMap as $meta) {
$key = $meta['key'] ?? null;
if ($key) {
$mitigationNames[$key] = $meta['name'] ?? $key;
}
}
// ── 3. AoE detection — proximity clustering ────────────────────────────────
// Group events by abilityId, then cluster by time proximity (≤ 1000ms from
// the first event in the cluster) to avoid fixed-window boundary splits.
@ -477,4 +501,5 @@ echo json_encode([
'players' => array_values($players),
'aoe_events' => $aoeEvents,
'fight_start' => (int)$startTime,
'mitigation_names' => $mitigationNames,
]);

View File

@ -103,6 +103,7 @@
let currentPlayers = [];
let extFights = [];
let extReportCode = '';
let mitigationNames = {};
// ── Player grid ──────────────────────────────────────────────────────────
@ -683,6 +684,7 @@
populateRefFightSelect();
setupPhases(window.App?.phases ?? []);
renderPlayers(json.players ?? []);
mitigationNames = json.mitigation_names ?? {};
renderTimeline(json.aoe_events ?? [], json.fight_start ?? fightStart);
document.getElementById('analysis-loading').style.display = 'none';
@ -717,6 +719,9 @@
players: currentPlayers,
fightName: fight?.name ?? `Fight ${window.App?.fightId ?? '?'}`,
reportCode: window.App?.reportCode ?? '',
fightId: window.App?.fightId ?? 0,
fightEnd: window.App?.fightEnd ?? 0,
mitigationNames,
};
},
reset() {
@ -726,6 +731,7 @@
refPlayers = [];
extFights = [];
extReportCode = '';
mitigationNames = {};
document.getElementById('ref-player-section').style.display = 'none';
refFightSelect.value = '';
refFightSelect.style.display = 'none';

View File

@ -103,6 +103,7 @@ document.addEventListener('DOMContentLoaded', () => {
window.App.language = normalizeLanguage(languageSelect.value);
localStorage.setItem('ff14-mitigator-language', window.App.language);
setUrlState({ language: window.App.language });
window.dispatchEvent(new CustomEvent('ff14-language-change', { detail: { language: window.App.language } }));
if (window.App.reportCode) {
loadReport(window.App.reportCode, window.App.fightId);
}
@ -136,6 +137,12 @@ document.addEventListener('DOMContentLoaded', () => {
document.querySelector('.tabs .tab[data-tab="analysis"]')?.click();
}
function shouldAutoOpenAnalysis() {
const params = new URLSearchParams(window.location.search);
const requestedTab = params.get('tab') || localStorage.getItem('ff14-mitigator-active-tab');
return requestedTab !== 'planner';
}
function selectFight(id, updateUrl = true) {
const fight = allFights.find(f => f.id === id);
if (!fight) return false;
@ -340,7 +347,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (initialUrlState.reportCode) {
form.elements['report_code'].value = initialUrlState.reportCode;
loadReport(initialUrlState.reportCode, initialUrlState.fightId).then(() => {
if (initialUrlState.fightId) {
if (initialUrlState.fightId && shouldAutoOpenAnalysis()) {
openAnalysisTab();
}
if (initialUrlState.compareFightId) {

View File

@ -1,6 +1,7 @@
// ── Storage ───────────────────────────────────────────────────────────────────
const PLANNER_KEY = 'ff14-planner-plans';
const PLANNER_ACTIVE_KEY = 'ff14-planner-active-plan';
const FOLDERS_KEY = 'ff14-planner-folders';
function loadPlans() {
@ -39,6 +40,7 @@ function createPlan(name) {
createdAt: Date.now(),
updatedAt: Date.now(),
source: null,
mitigationNames: {},
folderId: null,
jobComposition: Array(8).fill(''),
mechanics: []
@ -118,6 +120,24 @@ function fmtNumber(n) {
return Number(n).toLocaleString('de-DE');
}
function assignmentAbilityName(assignment, plan = null) {
const key = assignment?.ability ?? '';
return assignment?.abilityName ?? plan?.mitigationNames?.[key] ?? key;
}
function plannerLanguage() {
return window.App?.language || localStorage.getItem('ff14-mitigator-language') || 'en';
}
function sameMechanic(existing, incoming, source) {
const fightStart = source?.fightStart ?? 0;
const incomingRel = incoming.timestamp - fightStart;
if (existing.abilityId && incoming.abilityId && existing.abilityId === incoming.abilityId) {
return Math.abs(existing.timestamp - incomingRel) < 1500;
}
return Math.abs(existing.timestamp - incomingRel) < 1500;
}
// ── Rendering: Plan List ──────────────────────────────────────────────────────
function planItemHtml(p) {
@ -354,7 +374,8 @@ function renderMechanicListHtml(plan) {
: 'badge-assign-buff';
const isMissing = !!a.job && !activeJobSet.has(a.job);
const icon = MITIG_ICONS[a.ability] ?? '';
const label = a.job ? `${escHtml(a.job)} · ${escHtml(a.ability)}` : escHtml(a.ability);
const ability = assignmentAbilityName(a, plan);
const label = a.job ? `${escHtml(a.job)} · ${escHtml(ability)}` : escHtml(ability);
const title = isMissing ? `${escHtml(a.job)} nicht in Jobaufstellung` : '';
const suggestions = isMissing ? findEquivSuggestions(a.ability, activeJobSet) : [];
const badgeHtml = `<span class="badge badge-assign ${cls}${isMissing ? ' badge-assign--missing-job' : ''}"${title ? ` title="${title}"` : ''}>
@ -576,8 +597,10 @@ function startRename(id, currentName) {
function openPlan(id) {
activePlanId = id;
localStorage.setItem(PLANNER_ACTIVE_KEY, id);
renderPlanList();
renderPlanDetail(getPlan(id));
refreshPlanLanguage(id);
}
// ── New plan form ─────────────────────────────────────────────────────────────
@ -928,9 +951,17 @@ function guessJob(abilityName, players) {
return '';
}
function mitigationDisplayName(mitigation) {
return mitigation?.name ?? mitigation?.key ?? '';
}
function mitigationKey(mitigation) {
return mitigation?.key ?? mitigation?.name ?? '';
}
// ── AoE Events → Plan Mechanics ───────────────────────────────────────────────
function aoeEventsToMechanics(aoeEvents, fightStart, phases, players, withMitigations) {
function aoeEventsToMechanics(aoeEvents, fightStart, phases, players, withMitigations, mitigationNames = {}) {
return aoeEvents.map(ev => {
const relTs = ev.timestamp - fightStart;
const phase = (phases ?? []).filter(p => p.id !== 0).find(p =>
@ -949,10 +980,15 @@ function aoeEventsToMechanics(aoeEvents, fightStart, phases, players, withMitiga
const seen = new Set();
for (const t of ev.targets) {
for (const m of (t.mitigations ?? [])) {
const key = m.key ?? m.name;
const key = mitigationKey(m);
if (!seen.has(key)) {
seen.add(key);
assignments.push({ ability: key, job: guessJob(key, players), buffType: m.buffType ?? '' });
assignments.push({
ability: key,
abilityName: mitigationDisplayName(m) || mitigationNames[key],
job: guessJob(key, players),
buffType: m.buffType ?? '',
});
}
}
}
@ -961,6 +997,7 @@ function aoeEventsToMechanics(aoeEvents, fightStart, phases, players, withMitiga
return {
id: crypto.randomUUID(),
name: ev.abilityName,
abilityId: ev.abilityId,
timestamp: relTs,
phase: phase?.name ?? '',
unmitigatedDamage: avgUnmit,
@ -986,14 +1023,16 @@ function extractJobComp(players) {
}
function doImport(data, withMitigations, whereMode, mergeId, newName) {
const { aoeEvents, fightStart, phases, players, fightName, reportCode } = data;
const mechanics = aoeEventsToMechanics(aoeEvents, fightStart, phases, players, withMitigations);
const { aoeEvents, fightStart, fightEnd, phases, players, fightName, reportCode, fightId, mitigationNames = {} } = data;
const mechanics = aoeEventsToMechanics(aoeEvents, fightStart, phases, players, withMitigations, mitigationNames);
const source = { reportCode, fightId, fightName, fightStart, fightEnd, language: plannerLanguage() };
if (whereMode === 'new') {
const plan = createPlan(uniquePlanName(newName || fightName || 'Importierter Plan'));
return updatePlan(plan.id, {
mechanics,
source: { reportCode, fightName },
source,
mitigationNames,
jobComposition: extractJobComp(players),
});
}
@ -1011,7 +1050,87 @@ function doImport(data, withMitigations, whereMode, mergeId, newName) {
}
merged.sort((a, b) => a.timestamp - b.timestamp);
return updatePlan(mergeId, { mechanics: merged });
return updatePlan(mergeId, {
mechanics: merged,
source: { ...(plan.source ?? {}), ...source },
mitigationNames: { ...(plan.mitigationNames ?? {}), ...mitigationNames },
});
}
const refreshingPlans = new Set();
async function refreshPlanLanguage(planId) {
const plan = getPlan(planId);
const source = plan?.source ?? {};
const language = plannerLanguage();
if (!plan || refreshingPlans.has(planId)) return;
if (!source.reportCode || !source.fightId || !source.fightStart || !source.fightEnd) return;
if (source.language === language && plan.mitigationNames && Object.keys(plan.mitigationNames).length) return;
refreshingPlans.add(planId);
try {
const params = new URLSearchParams({
report_code: source.reportCode,
fight_id: source.fightId,
start_time: source.fightStart,
end_time: source.fightEnd,
language,
});
const res = await fetch('api/analysis.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params,
});
const json = await res.json();
if (json.reauth) { window.location.href = window.App?.authStartUrl?.() ?? 'auth/start.php'; return; }
if (json.error) return;
const refreshed = (json.aoe_events ?? []);
const mechanics = plan.mechanics.map(mechanic => {
const match = refreshed.find(ev => sameMechanic(mechanic, ev, source));
if (!match) return mechanic;
const assignments = (mechanic.assignments ?? []).map(a => ({
...a,
abilityName: json.mitigation_names?.[a.ability] ?? a.abilityName,
}));
return {
...mechanic,
name: match.abilityName ?? mechanic.name,
abilityId: match.abilityId ?? mechanic.abilityId,
assignments,
};
});
let fightName = source.fightName;
try {
const fightRes = await fetch('api/fight.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ report_code: source.reportCode, language }),
});
const fightJson = await fightRes.json();
const fight = (fightJson?.data?.reportData?.report?.fights ?? []).find(f => f.id === source.fightId);
if (fight?.name) fightName = fight.name;
} catch { }
const nextSource = { ...source, fightName, language };
const nextName = plan.name === source.fightName && fightName ? fightName : plan.name;
const updated = updatePlan(planId, {
name: nextName,
mechanics,
source: nextSource,
mitigationNames: json.mitigation_names ?? plan.mitigationNames ?? {},
});
if (updated && activePlanId === planId) {
renderPlanList();
renderPlanDetail(updated);
}
} catch { }
finally {
refreshingPlans.delete(planId);
}
}
// ── Ability Assignment Modal ──────────────────────────────────────────────────
@ -1067,12 +1186,14 @@ function renderAbilityModalContent() {
const otherClass = byOtherJob ? ' ability-chip--other-job' : '';
const title = byOtherJob ? `Bereits von ${escHtml(assigned.job)} zugewiesen` : '';
const icon = MITIG_ICONS[ab.name] ?? '';
const assignedName = assigned ? assignmentAbilityName(assigned, plan) : '';
const label = assignedName || plan.mitigationNames?.[ab.name] || ab.name;
return `<button class="ability-chip ${cls}${activeClass}${otherClass}"
data-ability="${escHtml(ab.name)}"
data-job="${escHtml(job)}"
data-buff-type="${escHtml(ab.buffType)}"
${title ? `title="${title}"` : ''}
>${icon ? `<img class="badge-icon" src="${escHtml(icon)}" alt="">` : ''}${escHtml(ab.name)}</button>`;
>${icon ? `<img class="badge-icon" src="${escHtml(icon)}" alt="">` : ''}${escHtml(label)}</button>`;
}).join('');
return `
@ -1099,7 +1220,7 @@ function toggleAbilityAssignment(abilityName, job, buffType) {
mechanic.assignments[idx].job = job;
}
} else {
mechanic.assignments.push({ ability: abilityName, job, buffType });
mechanic.assignments.push({ ability: abilityName, abilityName: plan.mitigationNames?.[abilityName], job, buffType });
}
updatePlan(abilityModalPlanId, { mechanics: plan.mechanics });
@ -1241,6 +1362,17 @@ document.addEventListener('DOMContentLoaded', () => {
initNewFolderForm();
initImportModal();
initAbilityModal();
activePlanId = localStorage.getItem(PLANNER_ACTIVE_KEY);
renderPlanList();
if (activePlanId && getPlan(activePlanId)) {
renderPlanDetail(getPlan(activePlanId));
} else {
activePlanId = null;
renderPlanDetail(null);
}
});
window.addEventListener('ff14-language-change', () => {
if (!activePlanId) return;
refreshPlanLanguage(activePlanId);
});

View File

@ -1,8 +1,10 @@
document.addEventListener('DOMContentLoaded', () => {
const tabs = document.querySelectorAll('.tabs .tab');
const contents = document.querySelectorAll('.tab-content');
const validTabs = new Set([...tabs].map(btn => btn.dataset.tab));
function showTab(name) {
if (!validTabs.has(name)) name = 'report';
contents.forEach(el => el.style.display = 'none');
tabs.forEach(btn => btn.classList.remove('active'));
@ -14,8 +16,13 @@ document.addEventListener('DOMContentLoaded', () => {
if (name === 'analysis') window.analysisTab?.onTabOpen?.();
if (name === 'planner') window.plannerTab?.onTabOpen?.();
localStorage.setItem('ff14-mitigator-active-tab', name);
}
tabs.forEach(btn => btn.addEventListener('click', () => showTab(btn.dataset.tab)));
window.showTab = showTab;
const params = new URLSearchParams(window.location.search);
showTab(params.get('tab') || localStorage.getItem('ff14-mitigator-active-tab') || 'report');
});