Compare commits

...

4 Commits

Author SHA1 Message Date
xziino
fce55c5bb6 Planer: Meine-Spells-View + Lila Personal-Farbe + Sidebar-Layout + Gantt-Scrollbar
- js/planner.js:
  - Mechaniken-Card: View-Toggle 'Mechaniken' / 'Meine Spells'
  - renderMySpellsHtml(): Assignments pro Mechanik gruppiert (1 Zeile statt 1 Zeile/Ability)
  - initMySpells(): Toggle-Logik, Job-Dropdown persistent (localStorage), Kopieren-Button
  - Personal-Mits lila (myspells-type--personal / badge-assign-personal) in beiden Views
  - Legende: Personal-Eintrag ergänzt
- css/planner.css:
  - .view-toggle-btns / .view-toggle-btn: Toggle-Button-Styles
  - .myspells-*: Cheatsheet-Layout (Grid, Badges, Farbkodierung)
  - .badge-assign-personal: Lila für Personal-Mits in Mechaniken-Übersicht
  - .info-legend-dot--personal: Lila Dot in Legende
  - .planner-info-panel: border-bottom statt border-top (jetzt oben in Sidebar)
  - .timeline-scroll: dunkler Custom-Scrollbar (webkit + scrollbar-color)
- templates/tab-planner.php: Info-Panel über Pläne-Sektion verschoben
- templates/topbar.php: Planer-Icon 📋 -> ☰

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 17:12:28 +02:00
xziino
1a91d1af0e Planer/Analyse: Personal-Mits Knight's Resolve, Nascent Glint, Stem the Flow/Tide, Aquaveil, Exaltation
- api/analysis.php: 6 neue Einträge in MITIGATION_ABILITIES
  Knight's Resolve (PLD, 1002675, 10% DR), Nascent Glint (WAR, 1001858, 10% DR),
  Stem the Flow (WAR, 1002679, 10% DR), Stem the Tide (WAR, 1002680, Shield),
  Aquaveil (WHM, 1002708, 15% DR), Exaltation (AST, 1002717, 10% DR)
- js/ffxiv-data.js: JOB_ABILITIES, ABILITY_JOB_MAP, MITIG_ICONS, ABILITY_DR aktualisiert
- js/planner.js: alle 6 in TIMELINE_PERSONAL_ABILITIES eingetragen
- assets/jsons/Action.json: Recast-Daten für Nascent Flash (25s), Aquaveil (60s),
  Exaltation (60s) ergänzt (Gantt-Balken)
- assets/icons/mitigation/: 6 neue Icons heruntergeladen (XIVAPI Status-Icons)
- api/cache.php: Version v6 -> v8

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 16:22:12 +02:00
xziino
636a65965a Analyse+Planer: sourceID fuer Reprisal/Feint/Addle via Cast-Events
- Neuer Abschnitt 2c in analysis.php: Cast-Events fuer Reprisal/Feint/Addle
  per 1 GQL-Request (3 Aliase) abfragen, da dataType:Buffs nur Friendly-
  Targets liefert (Boss-Debuffs fehlen dort)
- buffSourceTimeline mit Cast-Timestamps + 10s Dauer befuellt
- findBuffSourcePlayer(): sucht aktiven Caster zum Schadens-Zeitpunkt
- resolveMitigations(): gibt sourcePlayerType aus buffSourceTimeline zurueck
- guessJob() in planner.js: sourcePlayerType als erste Prioritaet vor
  job-basiertem Fallback -> DRK Reprisal, MNK Feint etc. korrekt zugeordnet
- analysis.js: Debuff-Icons im Header zeigen Job im Tooltip (z.B. DRK - Reprisal)
- Cache v5 -> v6

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 15:22:55 +02:00
xziino
a9b3cc8666 Planer: Clarity of Corundum + separate Tankbuster-Fixes
- Clarity of Corundum (GNB Proc, 15% DR, statusId 1002684) neu in
  MITIGATION_ABILITIES, ABILITY_DR, JOB_ABILITIES, TIMELINE_PERSONAL_ABILITIES
- statusId fuer Heart of Corundum (1002683) und Great Nebula (1003838)
  nachgetragen (stabilere Erkennung via buffs-Feld)
- Icons heruntergeladen: heart-of-corundum.png, heart-of-stone.png,
  clarity-of-corundum.png (teilt HoC-Action-Icon vorlaeufig)
- Cache-Version v3 -> v4 (erzwingt Neu-Fetch nach Erkennungs-Aenderungen)

Separate Tankbuster:
- tankMaxHpFromEvent(): maxHP direkt aus dem aoe_event des getroffenen
  Tanks statt Roster-Durchschnitt (ein Tank getroffen = sein MaxHP)
- renderMechanicListHtml: m.tankMaxHp hat Vorrang vor avgTankMaxHp(plan)
- plannedAssignmentsForMechanic: persoenliche Mitigation erscheint nur
  auf der direkt zugewiesenen Mechanik (DRK-CDs nicht mehr auf GNB-TB)
- DR-Label: 'mitigiert' -> 'nach DR' (verbleibender Schaden, nicht
  reduzierter Betrag)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 15:04:21 +02:00
18 changed files with 494 additions and 39 deletions

View File

@ -88,10 +88,12 @@ const MITIGATION_ABILITIES = [
'Dark Missionary' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1001894, 'extraAbilityGameID' => 16471],
'Heart of Light' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1001839, 'extraAbilityGameID' => 16160],
'Temperance' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1001873, 'extraAbilityGameID' => 16536],
'Aquaveil' => ['dr' => 15, 'buffType' => 'buff', 'statusId' => 1002708, 'extraAbilityGameID' => 25861], // Personal, WHM auf Ziel
'Sacred Soil' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1001944, 'extraAbilityGameID' => 188],
'Expedient' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1002711, 'extraAbilityGameID' => 25868], // FFLogs: "Desperate Measures"
'Fey Illumination' => ['dr' => 5, 'buffType' => 'buff', 'statusId' => 1000317, 'extraAbilityGameID' => 16538],
'Collective Unconscious' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1000849, 'extraAbilityGameID' => 3613],
'Exaltation' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1002717, 'extraAbilityGameID' => 25873], // Personal, AST auf Ziel
'Holos' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1003003, 'extraAbilityGameID' => 24310],
'Kerachole' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1002618, 'extraAbilityGameID' => 24298],
'Troubadour' => ['dr' => 15, 'buffType' => 'buff', 'statusId' => 1001934, 'extraAbilityGameID' => 7405],
@ -106,12 +108,15 @@ const MITIGATION_ABILITIES = [
'Bulwark' => ['dr' => 0, 'buffType' => 'buff', 'extraAbilityGameID' => 22],
'Holy Sheltron' => ['dr' => 15, 'buffType' => 'buff', 'extraAbilityGameID' => 25746],
'Intervention' => ['dr' => 10, 'buffType' => 'buff', 'extraAbilityGameID' => 7382],
'Knight\'s Resolve' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1002675, 'extraAbilityGameID' => 7382], // Proc von Intervention
// WAR
'Holmgang' => ['dr' => 0, 'buffType' => 'buff', 'extraAbilityGameID' => 43],
'Vengeance' => ['dr' => 30, 'buffType' => 'buff', 'extraAbilityGameID' => 44],
'Damnation' => ['dr' => 40, 'buffType' => 'buff', 'extraAbilityGameID' => 36923],
'Thrill of Battle' => ['dr' => 0, 'buffType' => 'buff', 'extraAbilityGameID' => 40],
'Raw Intuition' => ['dr' => 10, 'buffType' => 'buff', 'extraAbilityGameID' => 3551],
'Nascent Glint' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1001858, 'extraAbilityGameID' => 16464], // Proc von Nascent Flash auf Ziel
'Stem the Flow' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1002679, 'extraAbilityGameID' => 25751], // Proc von Bloodwhetting / Nascent Flash
// DRK
'Living Dead' => ['dr' => 0, 'buffType' => 'buff', 'extraAbilityGameID' => 3638],
'Shadow Wall' => ['dr' => 30, 'buffType' => 'buff', 'extraAbilityGameID' => 3636],
@ -122,10 +127,11 @@ const MITIGATION_ABILITIES = [
// GNB
'Superbolide' => ['dr' => 100, 'buffType' => 'buff', 'extraAbilityGameID' => 16152],
'Nebula' => ['dr' => 30, 'buffType' => 'buff', 'extraAbilityGameID' => 16148],
'Great Nebula' => ['dr' => 40, 'buffType' => 'buff', 'extraAbilityGameID' => 36935],
'Great Nebula' => ['dr' => 40, 'buffType' => 'buff', 'statusId' => 1003838, 'extraAbilityGameID' => 36935],
'Camouflage' => ['dr' => 10, 'buffType' => 'buff', 'extraAbilityGameID' => 16140],
'Heart of Stone' => ['dr' => 15, 'buffType' => 'buff', 'extraAbilityGameID' => 16161],
'Heart of Corundum' => ['dr' => 15, 'buffType' => 'buff', 'extraAbilityGameID' => 25758],
'Heart of Corundum' => ['dr' => 15, 'buffType' => 'buff', 'statusId' => 1002683, 'extraAbilityGameID' => 25758],
'Clarity of Corundum' => ['dr' => 15, 'buffType' => 'buff', 'statusId' => 1002684, 'extraAbilityGameID' => 25758], // Proc von Heart of Corundum, kann beliebiges Partymitglied treffen
// DPS
'Riddle of Earth' => ['dr' => 20, 'buffType' => 'buff', 'extraAbilityGameID' => 7394],
'Shade Shift' => ['dr' => 0, 'buffType' => 'shield', 'extraAbilityGameID' => 2241],
@ -139,6 +145,7 @@ const MITIGATION_ABILITIES = [
// WAR
'Shake It Off' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001457, 'extraAbilityGameID' => 7388],
'Bloodwhetting' => ['dr' => 10, 'buffType' => 'shield', 'statusId' => 1002678, 'extraAbilityGameID' => 25751],
'Stem the Tide' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002680, 'extraAbilityGameID' => 25751], // Proc von Bloodwhetting / Nascent Flash
// WHM
'Divine Benison' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001218, 'extraAbilityGameID' => 7432],
'Divine Caress' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003903, 'extraAbilityGameID' => 37011],
@ -171,7 +178,7 @@ const MITIGATION_ABILITIES = [
'Addle' => ['dr' => 10, 'buffType' => 'debuff', 'statusId' => 1001203, 'extraAbilityGameID' => 7560],
];
function resolveMitigations(string $buffStr, array $mitigIdMap): array {
function resolveMitigations(string $buffStr, array $mitigIdMap, array $buffSourceTimeline = [], array $players = [], float $ts = 0): array {
if ($buffStr === '') return [];
$result = [];
$seen = [];
@ -181,18 +188,33 @@ function resolveMitigations(string $buffStr, array $mitigIdMap): array {
$name = $mitigIdMap[$id]['name'];
if (isset($seen[$name])) continue;
$seen[$name] = true;
$result[] = [
$entry = [
'key' => $mitigIdMap[$id]['key'] ?? $name,
'name' => $name,
'dr' => $mitigIdMap[$id]['dr'],
'buffType' => $mitigIdMap[$id]['buffType'],
'extraAbilityGameID' => $mitigIdMap[$id]['extraAbilityGameID'] ?? null,
];
$source = findBuffSourcePlayer($buffSourceTimeline, $id, $ts, $players);
if ($source) $entry['sourcePlayerType'] = $source['type'];
$result[] = $entry;
}
}
return $result;
}
// Findet den Spieler der einen Buff zum Zeitpunkt $ts gecastet hat (anhand der applybuff-Timeline).
function findBuffSourcePlayer(array $sourceTimeline, int $statusId, float $ts, array $players): ?array {
$best = null;
foreach ($sourceTimeline[$statusId] ?? [] as $entry) {
if ($entry['apply'] > $ts + 200) continue; // noch nicht aktiv
if ($entry['remove'] !== null && $entry['remove'] < $ts - 200) continue; // schon abgelaufen
if ($best === null || $entry['apply'] > $best['apply']) $best = $entry;
}
if ($best === null || empty($best['sourceId'])) return null;
return $players[$best['sourceId']] ?? null;
}
// Fallback for shields consumed by a hit: the damage event's buffs field no
// longer contains the shield ID (already removed), but the applybuff/removebuff
// timeline shows it was active just before the hit.
@ -325,9 +347,10 @@ for ($page = 0; $page < 10; $page++) {
// ── 2b. Shield buff/debuff timeline ────────────────────────────────────────
// 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
$statusActionIds = []; // statusId → applybuff extraAbilityGameID from FFLogs
$shieldTimeline = []; // targetId → statusId → [[apply, remove|null], ...]
$buffSourceTimeline = []; // statusId → [[apply, remove|null, sourceId], ...] — wer hat den Buff gecastet?
$statusNames = []; // statusId → localized display name from Buffs events
$statusActionIds = []; // statusId → applybuff extraAbilityGameID from FFLogs
if (!empty($trackedStatusIds)) {
$nextPage = $startTime;
@ -372,6 +395,21 @@ if (!empty($trackedStatusIds)) {
$type = $ev['type'] ?? '';
$meta = $mitigIdMap[$abId] ?? null;
// Source-Tracking für alle getrackten Abilities (unabhängig von buffType)
$srcId = (int)($ev['sourceID'] ?? 0);
if ($srcId > 0 && isset($players[$srcId])) {
if ($type === 'applybuff') {
$buffSourceTimeline[$abId][] = ['apply' => $ts, 'remove' => null, 'sourceId' => $srcId];
} elseif ($type === 'removebuff') {
for ($i = count($buffSourceTimeline[$abId] ?? []) - 1; $i >= 0; $i--) {
if ($buffSourceTimeline[$abId][$i]['remove'] === null) {
$buffSourceTimeline[$abId][$i]['remove'] = $ts;
break;
}
}
}
}
if (($meta['buffType'] ?? null) !== 'shield') continue;
if ($type === 'applybuff') {
@ -393,6 +431,50 @@ if (!empty($trackedStatusIds)) {
}
}
// ── 2c. Boss-Debuff-Source via Casts ───────────────────────────────────────
// dataType: Buffs liefert nur Events auf Spieler (Friendly). Reprisal/Feint/Addle
// werden auf den Boss (Hostile) angewendet und tauchen dort nicht auf.
// Lösung: Cast-Events der drei Abilities direkt abfragen — 1 GQL-Request, 3 Aliase.
$dbReprisalActionId = (int)(MITIGATION_ABILITIES['Reprisal']['extraAbilityGameID'] ?? 0);
$dbFeintActionId = (int)(MITIGATION_ABILITIES['Feint']['extraAbilityGameID'] ?? 0);
$dbAddleActionId = (int)(MITIGATION_ABILITIES['Addle']['extraAbilityGameID'] ?? 0);
$dbReprisalStatusId = (int)(MITIGATION_ABILITIES['Reprisal']['statusId'] ?? 0);
$dbFeintStatusId = (int)(MITIGATION_ABILITIES['Feint']['statusId'] ?? 0);
$dbAddleStatusId = (int)(MITIGATION_ABILITIES['Addle']['statusId'] ?? 0);
if ($dbReprisalActionId && $dbFeintActionId && $dbAddleActionId) {
$dbResult = fflogs_gql(<<<GQL
{
reportData {
report(code: "$reportCode") {
reprisal: events(fightIDs: [$fightId], dataType: Casts, abilityID: $dbReprisalActionId, startTime: $startTime, endTime: $endTime) { data }
feint: events(fightIDs: [$fightId], dataType: Casts, abilityID: $dbFeintActionId, startTime: $startTime, endTime: $endTime) { data }
addle: events(fightIDs: [$fightId], dataType: Casts, abilityID: $dbAddleActionId, startTime: $startTime, endTime: $endTime) { data }
}
}
}
GQL);
if (isset($dbResult['_reauth'])) { echo json_encode(['reauth' => true]); exit; }
foreach ([
'reprisal' => ['statusId' => $dbReprisalStatusId, 'durationMs' => 10000],
'feint' => ['statusId' => $dbFeintStatusId, 'durationMs' => 10000],
'addle' => ['statusId' => $dbAddleStatusId, 'durationMs' => 10000],
] as $alias => $info) {
foreach ($dbResult['data']['reportData']['report'][$alias]['data'] ?? [] as $ev) {
if (($ev['type'] ?? '') !== 'cast') continue;
$srcId = (int)($ev['sourceID'] ?? 0);
if ($srcId <= 0 || !isset($players[$srcId]) || !$info['statusId']) continue;
$ts = (float)($ev['timestamp'] ?? 0);
$buffSourceTimeline[$info['statusId']][] = [
'apply' => $ts,
'remove' => $ts + $info['durationMs'],
'sourceId' => $srcId,
];
}
}
}
foreach ($statusNames as $statusId => $displayName) {
if (isset($mitigIdMap[$statusId])) {
$mitigIdMap[$statusId]['name'] = $displayName;
@ -526,8 +608,8 @@ foreach ($clusters as $group) {
'overkill' => $tgt['overkill'],
'hp' => $tgt['hp'],
'maxHp' => $tgt['maxHp'],
'mitigations' => (function() use ($tgt, $mitigIdMap, $shieldTimeline) {
$mitigations = resolveMitigations($tgt['buffs'], $mitigIdMap);
'mitigations' => (function() use ($tgt, $mitigIdMap, $shieldTimeline, $buffSourceTimeline, $players) {
$mitigations = resolveMitigations($tgt['buffs'], $mitigIdMap, $buffSourceTimeline, $players, $tgt['ts']);
if ($tgt['absorbed'] > 0 && !empty($shieldTimeline)) {
$existing = [];
foreach ($mitigations as $m) {

View File

@ -2,7 +2,7 @@
declare(strict_types=1);
const CACHED_LOG_DIR = __DIR__ . '/../cached_logs';
const CACHED_LOG_VERSION = 'v3';
const CACHED_LOG_VERSION = 'v8';
function cache_language(string $language): string {
$language = strtolower(trim($language));

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -778,5 +778,41 @@
},
"icon": "https://xivapi.com/i/003000/003689_hr1.png",
"shield": "360% of HP restored"
},
"16464": {
"cast": 0,
"recast": 250,
"names": {
"en": "Nascent Flash",
"de": "Urflackern",
"fr": "Exaltation naissante",
"jp": "原初の猛り"
},
"icon": null,
"shield": null
},
"25861": {
"cast": 0,
"recast": 600,
"names": {
"en": "Aquaveil",
"de": "Wasserschleier",
"fr": "Aquavoile",
"jp": "アクアヴェール"
},
"icon": null,
"shield": null
},
"25873": {
"cast": 0,
"recast": 600,
"names": {
"en": "Exaltation",
"de": "Exaltation",
"fr": "Exaltation",
"jp": "エクザルテーション"
},
"icon": null,
"shield": null
}
}

View File

@ -320,9 +320,10 @@
font-size: 13px;
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); }
.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); }
.badge-assign-personal { background: rgba(177,112,255,.08); border-color: rgba(177,112,255,.4); color: #dbc7ff; }
.badge-assign--missing-job {
border-style: dashed;
@ -485,9 +486,10 @@
}
.ability-chip:hover { background: var(--bg3); color: var(--t1); }
.ability-chip.badge-assign-buff.ability-chip--active { background: rgba(200,168,75,.18); border-color: rgba(200,168,75,.6); color: var(--gold); }
.ability-chip.badge-assign-debuff.ability-chip--active { background: rgba(224,92,92,.18); border-color: rgba(224,92,92,.6); color: var(--red); }
.ability-chip.badge-assign-shield.ability-chip--active { background: rgba(74,158,255,.18); border-color: rgba(74,158,255,.6); color: var(--blue); }
.ability-chip.badge-assign-buff.ability-chip--active { background: rgba(200,168,75,.18); border-color: rgba(200,168,75,.6); color: var(--gold); }
.ability-chip.badge-assign-debuff.ability-chip--active { background: rgba(224,92,92,.18); border-color: rgba(224,92,92,.6); color: var(--red); }
.ability-chip.badge-assign-shield.ability-chip--active { background: rgba(74,158,255,.18); border-color: rgba(74,158,255,.6); color: var(--blue); }
.ability-chip.badge-assign-personal.ability-chip--active { background: rgba(177,112,255,.18); border-color: rgba(177,112,255,.6); color: #dbc7ff; }
.ability-chip--other-job { opacity: 0.45; }
@ -526,9 +528,9 @@
/* ── Info Panel ──────────────────────────────────────────────────────────────── */
.planner-info-panel {
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid var(--border);
padding-bottom: 16px;
margin-bottom: 16px;
border-bottom: 1px solid var(--border);
}
.info-section { margin-bottom: 12px; }
@ -560,9 +562,10 @@
border-radius: 2px;
flex-shrink: 0;
}
.info-legend-dot--buff { background: rgba(200,168,75,.8); }
.info-legend-dot--debuff { background: var(--red); }
.info-legend-dot--shield { background: var(--blue); }
.info-legend-dot--buff { background: rgba(200,168,75,.8); }
.info-legend-dot--debuff { background: var(--red); }
.info-legend-dot--shield { background: var(--blue); }
.info-legend-dot--personal { background: rgba(177,112,255,.9); }
.info-legend-label {
font-size: 12px;
@ -754,6 +757,8 @@
overflow-y: hidden;
border: 1px solid var(--border);
background: var(--bg1);
scrollbar-color: var(--border) var(--bg1);
scrollbar-width: thin;
max-width: 100%;
width: 100%;
cursor: grab;
@ -764,6 +769,11 @@
cursor: grabbing;
}
.timeline-scroll::-webkit-scrollbar { height: 6px; }
.timeline-scroll::-webkit-scrollbar-track { background: var(--bg1); }
.timeline-scroll::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
.timeline-scroll::-webkit-scrollbar-thumb:hover { background: var(--t3); }
.timeline-grid {
width: calc(180px + var(--timeline-width));
display: grid;
@ -1210,6 +1220,124 @@
font-size: 12px;
}
/* ── View Toggle ─────────────────────────────────────────────────────────── */
.view-toggle-btns {
display: flex;
gap: 4px;
}
.view-toggle-btn {
padding: 4px 12px;
border: 1px solid var(--border);
border-radius: var(--r);
background: transparent;
color: var(--t2);
font-size: 12px;
font-family: var(--font-b);
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.view-toggle-btn:hover {
color: var(--t1);
border-color: var(--t3);
}
.view-toggle-btn.active {
background: var(--gold);
border-color: var(--gold);
color: #000;
font-weight: 600;
}
/* ── Meine Spells ────────────────────────────────────────────────────────── */
.myspells-controls {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 0 14px;
border-bottom: 1px solid var(--border);
margin-bottom: 4px;
}
.myspells-controls select {
padding: 5px 10px;
border: 1px solid var(--border);
border-radius: var(--r);
background: var(--bg2);
color: var(--t1);
font-size: 13px;
cursor: pointer;
}
.myspells-row {
display: grid;
grid-template-columns: 48px 180px 1fr;
align-items: center;
gap: 10px;
padding: 7px 4px;
border-bottom: 1px solid rgba(255,255,255,0.04);
font-size: 13px;
}
.myspells-abilities {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.myspells-row:last-child {
border-bottom: none;
}
.myspells-time {
font-variant-numeric: tabular-nums;
font-weight: 600;
color: var(--gold);
font-size: 12px;
}
.myspells-mechanic {
color: var(--t2);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.myspells-ability {
display: flex;
align-items: center;
gap: 5px;
font-weight: 500;
font-size: 12px;
color: var(--t1);
background: var(--bg2);
border: 1px solid var(--border);
border-radius: var(--r);
padding: 3px 8px 3px 4px;
white-space: nowrap;
}
.myspells-ability.myspells-type--debuff { color: var(--orange); border-color: rgba(255,140,0,0.3); }
.myspells-ability.myspells-type--shield { color: var(--blue); border-color: rgba(88,166,255,0.3); }
.myspells-ability.myspells-type--personal { color: #dbc7ff; border-color: rgba(177,112,255,0.4); }
.myspells-icon {
width: 20px;
height: 20px;
object-fit: contain;
flex-shrink: 0;
}
.myspells-empty {
padding: 24px 0;
text-align: center;
color: var(--t3);
font-size: 13px;
}
@media (max-width: 980px) {
.planner-layout {
grid-template-columns: 1fr;

View File

@ -605,10 +605,12 @@
].map(m => {
const iconSrc = mitigationIcon(m);
if (!iconSrc) return '';
const dr = m.dr > 0 ? ` ${m.dr}%` : '';
const dr = m.dr > 0 ? ` ${m.dr}%` : '';
const jobAbbr = m.sourcePlayerType ? (JOB_ABBR[m.sourcePlayerType] ?? '') : '';
const label = jobAbbr ? `${jobAbbr} · ${m.name}` : m.name;
return m.missing
? `<img class="aoe-target-buff-icon aoe-buff-missing" src="${iconSrc}" alt="${m.name}" title="${m.name}${dr} fehlt (war im Referenz-Pull aktiv)">`
: `<img class="aoe-target-buff-icon" src="${iconSrc}" alt="${m.name}" title="${m.name}${dr}">`;
? `<img class="aoe-target-buff-icon aoe-buff-missing" src="${iconSrc}" alt="${m.name}" title="${label}${dr} fehlt (war im Referenz-Pull aktiv)">`
: `<img class="aoe-target-buff-icon" src="${iconSrc}" alt="${m.name}" title="${label}${dr}">`;
}).join('');
// Current targets

View File

@ -36,6 +36,7 @@
{ name: 'Bulwark', buffType: 'buff', extraAbilityGameID: 22, duration: 10 },
{ name: 'Holy Sheltron', buffType: 'buff', extraAbilityGameID: 25746, duration: 8 },
{ name: 'Intervention', buffType: 'buff', extraAbilityGameID: 7382, duration: 8 },
{ name: 'Knight\'s Resolve', buffType: 'buff', extraAbilityGameID: 7382, duration: 4 }, // Proc von Intervention
{ name: 'Reprisal', buffType: 'debuff' },
],
'WAR': [
@ -47,6 +48,9 @@
{ name: 'Thrill of Battle', buffType: 'buff', extraAbilityGameID: 40, duration: 10 },
{ name: 'Raw Intuition', buffType: 'buff', extraAbilityGameID: 3551, duration: 6 },
{ name: 'Bloodwhetting', buffType: 'shield', extraAbilityGameID: 25751, duration: 8 },
{ name: 'Nascent Glint', buffType: 'buff', extraAbilityGameID: 16464, duration: 8 }, // Proc von Nascent Flash auf Ziel
{ name: 'Stem the Flow', buffType: 'buff', extraAbilityGameID: 25751, duration: 4 }, // Proc von Bloodwhetting / Nascent Flash
{ name: 'Stem the Tide', buffType: 'shield', extraAbilityGameID: 25751, duration: 4 }, // Proc von Bloodwhetting / Nascent Flash
{ name: 'Reprisal', buffType: 'debuff' },
],
'DRK': [
@ -69,10 +73,12 @@
{ name: 'Camouflage', buffType: 'buff', extraAbilityGameID: 16140, duration: 20 },
{ name: 'Heart of Stone', buffType: 'buff', extraAbilityGameID: 16161, duration: 7 },
{ name: 'Heart of Corundum', buffType: 'buff', extraAbilityGameID: 25758, duration: 8 },
{ name: 'Clarity of Corundum', buffType: 'buff', extraAbilityGameID: 25758, duration: 4 }, // Proc von HoC, geht auf beliebiges Partymitglied
{ name: 'Reprisal', buffType: 'debuff' },
],
'WHM': [
{ name: 'Temperance', buffType: 'buff' },
{ name: 'Aquaveil', buffType: 'buff', extraAbilityGameID: 25861, duration: 8 }, // Personal, WHM auf Ziel
{ name: 'Divine Benison', buffType: 'shield', extraAbilityGameID: 7432, duration: 15 },
{ name: 'Divine Caress', buffType: 'shield' },
],
@ -86,6 +92,7 @@
],
'AST': [
{ name: 'Collective Unconscious', buffType: 'buff' },
{ name: 'Exaltation', buffType: 'buff', extraAbilityGameID: 25873, duration: 8 }, // Personal, AST auf Ziel
{ name: 'Neutral Sect', buffType: 'shield' },
{ name: 'Intersection', buffType: 'shield', extraAbilityGameID: 16556, duration: 30 },
{ name: 'the Spire', buffType: 'shield', extraAbilityGameID: 37025, duration: 30 },
@ -148,19 +155,21 @@
'Passage of Arms': 'PLD', 'Divine Veil': 'PLD',
'Hallowed Ground': 'PLD', 'Sentinel': 'PLD', 'Guardian': 'PLD',
'Bulwark': 'PLD', 'Holy Sheltron': 'PLD', 'Intervention': 'PLD',
'Knight\'s Resolve': 'PLD',
'Shake It Off': 'WAR', 'Holmgang': 'WAR', 'Vengeance': 'WAR',
'Damnation': 'WAR', 'Thrill of Battle': 'WAR', 'Raw Intuition': 'WAR',
'Bloodwhetting': 'WAR',
'Bloodwhetting': 'WAR', 'Nascent Glint': 'WAR',
'Stem the Flow': 'WAR', 'Stem the Tide': 'WAR',
'Dark Missionary': 'DRK', 'Living Dead': 'DRK', 'Shadow Wall': 'DRK',
'Shadowed Vigil': 'DRK', 'Dark Mind': 'DRK', 'The Blackest Night': 'DRK',
'Oblation': 'DRK',
'Heart of Light': 'GNB', 'Superbolide': 'GNB', 'Nebula': 'GNB',
'Great Nebula': 'GNB', 'Camouflage': 'GNB', 'Heart of Stone': 'GNB',
'Heart of Corundum': 'GNB',
'Temperance': 'WHM', 'Divine Benison': 'WHM', 'Divine Caress': 'WHM',
'Heart of Corundum': 'GNB', 'Clarity of Corundum': 'GNB',
'Temperance': 'WHM', 'Aquaveil': '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',
'Collective Unconscious': 'AST', 'Exaltation': 'AST', 'Neutral Sect': 'AST',
'Intersection': 'AST', 'the Spire': 'AST',
'Kerachole': 'SGE', 'Holos': 'SGE', 'Holosakos': 'SGE',
'Panhaima': 'SGE', 'Haima': 'SGE',
@ -219,6 +228,15 @@
'Tempera Coat': 'assets/icons/mitigation/tempera-coat.png',
'Tempera Grassa': 'assets/icons/mitigation/tempera-grassa.png',
'Improvised Finish': 'assets/icons/mitigation/improvised-finish.png',
'Aquaveil': 'assets/icons/mitigation/aquaveil.png',
'Exaltation': 'assets/icons/mitigation/exaltation.png',
'Heart of Corundum': 'assets/icons/mitigation/heart-of-corundum.png',
'Clarity of Corundum': 'assets/icons/mitigation/clarity-of-corundum.png',
'Heart of Stone': 'assets/icons/mitigation/heart-of-stone.png',
'Knight\'s Resolve': 'assets/icons/mitigation/knights-resolve.png',
'Nascent Glint': 'assets/icons/mitigation/nascent-glint.png',
'Stem the Flow': 'assets/icons/mitigation/stem-the-flow.png',
'Stem the Tide': 'assets/icons/mitigation/stem-the-tide.png',
};
const ABILITY_DR = {
@ -245,10 +263,15 @@
'Guardian': 0.40,
'Holy Sheltron': 0.15,
'Intervention': 0.10,
'Knight\'s Resolve': 0.10,
'Aquaveil': 0.15,
'Exaltation': 0.10,
'Vengeance': 0.30,
'Damnation': 0.40,
'Raw Intuition': 0.10,
'Bloodwhetting': 0.10,
'Nascent Glint': 0.10,
'Stem the Flow': 0.10,
'Shadow Wall': 0.30,
'Shadowed Vigil': 0.40,
'Dark Mind': 0.20,
@ -259,6 +282,7 @@
'Camouflage': 0.10,
'Heart of Stone': 0.15,
'Heart of Corundum': 0.15,
'Clarity of Corundum': 0.15,
'Riddle of Earth': 0.20,
'Third Eye': 0.10,
};

View File

@ -461,10 +461,26 @@ function renderPlanDetail(plan) {
<div class="card">
<div class="card-title-row">
<div class="card-title">Mechaniken</div>
<div class="view-toggle-btns">
<button class="view-toggle-btn active" data-view="mechanics">Mechaniken</button>
<button class="view-toggle-btn" data-view="myspells"> Meine Spells</button>
</div>
</div>
<div id="mechanic-list">
${renderMechanicListHtml(plan)}
</div>
<div id="myspells-panel" style="display:none">
<div class="myspells-controls">
<select id="myspells-job-select">
<option value=""> Job wählen </option>
${(plan.jobComposition ?? []).filter(Boolean).filter((j, i, a) => a.indexOf(j) === i).map(j =>
`<option value="${escHtml(j)}">${escHtml(j)}</option>`
).join('')}
</select>
<button id="myspells-copy-btn" class="btn btn-sm" title="Als Text kopieren"> Kopieren</button>
</div>
<div id="myspells-list"></div>
</div>
</div>
`;
@ -478,10 +494,118 @@ function renderPlanDetail(plan) {
initTimelineOptions(plan.id);
initTimeline(plan.id);
initMechanicClicks(plan.id);
initMySpells(plan.id);
renderInfoPanel(plan);
ensureActionMetaLoaded().then(() => refreshMechanicList(plan.id));
}
// ── Meine Spells ─────────────────────────────────────────────────────────────
function renderMySpellsHtml(plan, job) {
if (!job) return '<div class="myspells-empty">Job auswählen um Assignments zu sehen.</div>';
const mechanics = visiblePlanMechanics(plan);
const rows = [];
for (const mechanic of mechanics) {
const mine = (mechanic.assignments ?? []).filter(a => a.job === job);
if (!mine.length) continue;
rows.push({ mechanic, assignments: mine });
}
if (!rows.length) {
return `<div class="myspells-empty">Keine Assignments für ${escHtml(job)} in diesem Plan.</div>`;
}
return rows.map(({ mechanic, assignments }) => {
const time = escHtml(fmtTimestamp(mechanic.timestamp));
const mechName = escHtml(mechanic.name);
const abilities = assignments.map(a => {
const iconSrc = abilityIcon(a.ability);
const icon = iconSrc
? `<img src="${escHtml(iconSrc)}" class="myspells-icon" alt="">`
: '';
const name = escHtml(assignmentAbilityName(a, plan));
const isPersonal = TIMELINE_PERSONAL_ABILITIES.has(a.ability);
const typeClass = a.buffType === 'debuff' ? 'myspells-type--debuff'
: a.buffType === 'shield' ? 'myspells-type--shield'
: isPersonal ? 'myspells-type--personal'
: 'myspells-type--buff';
return `<span class="myspells-ability ${typeClass}">${icon}${name}</span>`;
}).join('');
return `
<div class="myspells-row">
<span class="myspells-time">${time}</span>
<span class="myspells-mechanic">${mechName}</span>
<div class="myspells-abilities">${abilities}</div>
</div>`;
}).join('');
}
function mySpellsPlainText(plan, job) {
if (!job) return '';
const mechanics = visiblePlanMechanics(plan);
const lines = [`Meine Spells — ${job} (${plan.name})`, '─'.repeat(36)];
for (const mechanic of mechanics) {
const mine = (mechanic.assignments ?? []).filter(a => a.job === job);
if (!mine.length) continue;
const time = fmtTimestamp(mechanic.timestamp);
const names = mine.map(a => assignmentAbilityName(a, plan)).join(', ');
lines.push(`${time.padEnd(6)} ${mechanic.name.padEnd(22)}${names}`);
}
return lines.join('\n');
}
function initMySpells(planId) {
const viewBtns = document.querySelectorAll('.view-toggle-btn');
const mechList = document.getElementById('mechanic-list');
const myPanel = document.getElementById('myspells-panel');
const jobSelect = document.getElementById('myspells-job-select');
const myList = document.getElementById('myspells-list');
const copyBtn = document.getElementById('myspells-copy-btn');
if (!mechList || !myPanel || !jobSelect || !myList) return;
viewBtns.forEach(btn => {
btn.addEventListener('click', () => {
viewBtns.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const view = btn.dataset.view;
mechList.style.display = view === 'mechanics' ? '' : 'none';
myPanel.style.display = view === 'myspells' ? '' : 'none';
if (view === 'myspells') {
const plan = loadPlans().find(p => p.id === planId);
if (plan) myList.innerHTML = renderMySpellsHtml(plan, jobSelect.value || '');
}
});
});
// Gespeicherten Job wiederherstellen
const savedJob = localStorage.getItem('ff14-planner-myspells-job') ?? '';
if (savedJob && [...jobSelect.options].some(o => o.value === savedJob)) {
jobSelect.value = savedJob;
const plan = loadPlans().find(p => p.id === planId);
if (plan) myList.innerHTML = renderMySpellsHtml(plan, savedJob);
}
jobSelect.addEventListener('change', () => {
localStorage.setItem('ff14-planner-myspells-job', jobSelect.value);
const plan = loadPlans().find(p => p.id === planId);
if (plan) myList.innerHTML = renderMySpellsHtml(plan, jobSelect.value);
});
copyBtn?.addEventListener('click', () => {
const plan = loadPlans().find(p => p.id === planId);
if (!plan) return;
const text = mySpellsPlainText(plan, jobSelect.value);
navigator.clipboard.writeText(text).then(() => {
copyBtn.textContent = '✓ Kopiert';
setTimeout(() => { copyBtn.innerHTML = '⎘ Kopieren'; }, 1800);
}).catch(() => {
copyBtn.textContent = '✗ Fehler';
setTimeout(() => { copyBtn.innerHTML = '⎘ Kopieren'; }, 1800);
});
});
}
function avgNonTankMaxHp(plan) {
const roster = plan.playerRoster ?? [];
const jobComp = plan.jobComposition ?? [];
@ -493,10 +617,40 @@ function avgNonTankMaxHp(plan) {
return Math.round(hps.reduce((s, v) => s + v, 0) / hps.length);
}
function avgTankMaxHp(plan) {
const roster = plan.playerRoster ?? [];
const jobComp = plan.jobComposition ?? [];
const hps = jobComp
.map((job, i) => ({ job, maxHp: roster[i]?.maxHp ?? 0 }))
.filter(p => p.job && JOB_ROLE[p.job] === 'tank' && p.maxHp > 0)
.map(p => p.maxHp);
if (!hps.length) return 0;
// Tankbuster trifft einen Tank → Durchschnitt über alle gefundenen Tanks
// (1 Tank → sein MaxHP direkt; 2 Tanks → (hp1 + hp2) / 2)
return Math.round(hps.reduce((s, v) => s + v, 0) / hps.length);
}
// Ermittelt den maxHP-Wert direkt aus dem aoe_event (für präzise Tankbuster-Anzeige)
function tankMaxHpFromEvent(ev) {
if (!ev?.isHeavyTankbuster) return 0;
const tankTargets = (ev.targets ?? []).filter(t => t.role === 'tank' && (t.maxHp ?? 0) > 0);
if (tankTargets.length === 1) return tankTargets[0].maxHp;
if (tankTargets.length > 1) {
// Mehrere Tanks gleichzeitig → Durchschnitt
return Math.round(tankTargets.reduce((s, t) => s + t.maxHp, 0) / tankTargets.length);
}
// Fallback: erstes Target mit maxHp (z.B. wenn Role nicht gesetzt)
const any = (ev.targets ?? []).find(t => (t.maxHp ?? 0) > 0);
return any?.maxHp ?? 0;
}
function simulateDrMultiplier(mechanic, assignments = mechanic.assignments ?? []) {
const isTankbuster = !!mechanic.isHeavyTankbuster;
let mult = 1;
for (const a of assignments) {
if (a.buffType === 'shield') continue;
// Persönliche Mitigation nur bei Tankbustern einrechnen
if (!isTankbuster && TIMELINE_PERSONAL_ABILITIES.has(a.ability)) continue;
mult *= (1 - (ABILITY_DR[a.ability] ?? 0));
}
return mult;
@ -510,6 +664,13 @@ function plannedAssignmentsForMechanic(plan, targetMechanic) {
for (const entry of canonicalAssignmentActivations(plan, { dedupeKey: canonicalMechanicKey })) {
if (targetTime < entry.start - tolerance || targetTime > entry.end + tolerance) continue;
// Persönliche Mitigation zeigt nur auf der Mechanik, der sie direkt zugewiesen ist
// (verhindert dass DRK-Cooldowns auf GNB-Tankbuster erscheinen und umgekehrt)
if (TIMELINE_PERSONAL_ABILITIES.has(entry.assignment.ability)
&& entry.mechanic.id !== targetMechanic.id) {
continue;
}
result.push({
...entry.assignment,
sourceMechanicId: entry.mechanic.id,
@ -535,16 +696,22 @@ function renderMechanicListHtml(plan) {
}
const activeJobSet = new Set(plan.jobComposition.filter(j => j));
const avgHp = avgNonTankMaxHp(plan);
const nonTankAvgHp = avgNonTankMaxHp(plan);
const tankAvgHp = avgTankMaxHp(plan);
return mechanics.map(m => {
// Tankbuster: gespeicherter maxHP des getroffenen Tanks hat Vorrang (präziser als Roster-Durchschnitt)
const avgHp = m.isHeavyTankbuster
? ((m.tankMaxHp ?? 0) > 0 ? m.tankMaxHp : tankAvgHp)
: nonTankAvgHp;
const planned = plannedAssignmentsForMechanic(plan, m);
const sorted = sortedAssignments(planned);
const assignHtml = sorted.length === 0
? '<span class="mechanic-no-assign">Keine Zuweisung</span>'
: sorted.map(a => {
const cls = a.buffType === 'debuff' ? 'badge-assign-debuff'
: a.buffType === 'shield' ? 'badge-assign-shield'
const cls = a.buffType === 'debuff' ? 'badge-assign-debuff'
: a.buffType === 'shield' ? 'badge-assign-shield'
: TIMELINE_PERSONAL_ABILITIES.has(a.ability) ? 'badge-assign-personal'
: 'badge-assign-buff';
const isMissing = !!a.job && !activeJobSet.has(a.job);
const icon = abilityIcon(a.ability);
@ -588,7 +755,7 @@ function renderMechanicListHtml(plan) {
: ''
}
${hasDrAssign || hasShield ? `<div class="mechanic-dmg mechanic-mitig-row">
${hasDrAssign ? `<span class="mechanic-mitig-val${drOnlyCls ? ' ' + drOnlyCls : ''}">→ ${fmtNumber(drOnly)}</span> mitigiert` : ''}
${hasDrAssign ? `<span class="mechanic-mitig-val${drOnlyCls ? ' ' + drOnlyCls : ''}">→ ${fmtNumber(drOnly)}</span> nach DR` : ''}
${hasShield ? `<span class="mechanic-mitig-shield${fullCls ? ' ' + fullCls : ''}">Mitigation mit Schild ${fmtNumber(mitigFull)}</span>` : ''}
</div>` : ''}
<div class="mechanic-assignments">${assignHtml}</div>
@ -693,12 +860,18 @@ const TIMELINE_PERSONAL_ABILITIES = new Set([
'Bulwark',
'Holy Sheltron',
'Intervention',
'Knight\'s Resolve',
'Aquaveil',
'Exaltation',
'Holmgang',
'Vengeance',
'Damnation',
'Thrill of Battle',
'Raw Intuition',
'Bloodwhetting',
'Nascent Glint',
'Stem the Flow',
'Stem the Tide',
'Living Dead',
'Shadow Wall',
'Shadowed Vigil',
@ -711,6 +884,7 @@ const TIMELINE_PERSONAL_ABILITIES = new Set([
'Camouflage',
'Heart of Stone',
'Heart of Corundum',
'Clarity of Corundum',
'Divine Benison',
'Intersection',
'the Spire',
@ -1861,6 +2035,7 @@ function renderInfoPanel(plan) {
<div class="info-legend-row"><span class="info-legend-dot info-legend-dot--buff"></span><span class="info-legend-label">Mitigation</span></div>
<div class="info-legend-row"><span class="info-legend-dot info-legend-dot--debuff"></span><span class="info-legend-label">Debuff</span></div>
<div class="info-legend-row"><span class="info-legend-dot info-legend-dot--shield"></span><span class="info-legend-label">Schild</span></div>
<div class="info-legend-row"><span class="info-legend-dot info-legend-dot--personal"></span><span class="info-legend-label">Personal</span></div>
</div>
</div>`;
@ -2195,7 +2370,11 @@ function sortedAssignments(assignments) {
);
}
function guessJob(abilityName, players) {
function guessJob(abilityName, players, mitigation = null) {
// Direkte Zuordnung über sourcePlayerType aus dem applybuff-Event (zuverlässigste Quelle)
if (mitigation?.sourcePlayerType) {
return JOB_FROM_TYPE[mitigation.sourcePlayerType] ?? '';
}
if (ABILITY_JOB_MAP[abilityName]) return ABILITY_JOB_MAP[abilityName];
const jobs = (players ?? []).map(p => JOB_FROM_TYPE[p.type] ?? '');
if (abilityName === 'Reprisal') {
@ -2250,7 +2429,7 @@ function aoeEventsToMechanics(aoeEvents, fightStart, phases, players, withMitiga
ability: key,
abilityName: mitigationDisplayName(m) || mitigationNames[key],
actionId: m.extraAbilityGameID ?? null,
job: guessJob(key, players),
job: guessJob(key, players, m),
buffType: m.buffType ?? '',
timestamp: Math.max(0, relTs + IMPORT_OFFSET_MS),
});
@ -2267,6 +2446,7 @@ function aoeEventsToMechanics(aoeEvents, fightStart, phases, players, withMitiga
phase: phase?.name ?? '',
unmitigatedDamage: avgUnmit,
isHeavyTankbuster: !!ev.isHeavyTankbuster,
tankMaxHp: tankMaxHpFromEvent(ev),
notes: '',
assignments,
};
@ -2387,9 +2567,10 @@ async function refreshPlanLanguage(planId) {
}));
return {
...mechanic,
name: match.abilityName ?? mechanic.name,
abilityId: match.abilityId ?? mechanic.abilityId,
name: match.abilityName ?? mechanic.name,
abilityId: match.abilityId ?? mechanic.abilityId,
isHeavyTankbuster: mechanic.mechanicTypeManual ? !!mechanic.isHeavyTankbuster : !!match.isHeavyTankbuster,
tankMaxHp: mechanic.mechanicTypeManual ? (mechanic.tankMaxHp ?? 0) : tankMaxHpFromEvent(match),
assignments,
};
});

View File

@ -2,6 +2,9 @@
<!-- Left: Plan list sidebar -->
<div class="plan-sidebar">
<div id="planner-info-panel" class="planner-info-panel"></div>
<div class="plan-sidebar-header">
<div class="card-title">Pläne</div>
<button id="planner-new-folder-btn" class="btn btn-sm" title="Neuer Ordner">+ Ordner</button>
@ -26,7 +29,6 @@
<div id="plan-list"></div>
<div id="planner-info-panel" class="planner-info-panel"></div>
</div>
<!-- Right: Plan detail -->

View File

@ -3,7 +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>
<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>