/* Idle Fantasy Save Viewer – client UI */ let state = { data: null, snapshots: [], timeline: [], inventory: { search: "", categories: new Set(), sort: "category", highlightEquipped: false }, skills: { search: "", sort: "level", sortAsc: false }, quests: { tab: "story", filter: "all" }, history: { olderId: null, newerId: null, diff: null }, charts: {}, }; const CATEGORY_ORDER = [ "Currency", "Ores & Mining", "Bars & Smithing", "Wood & Planks", "Runes", "Raw Food", "Cooked Food", "Seeds & Farming", "Melee Weapons", "Ranged", "Magic", "Armor", "Bones & Hides", "Gems & Jewelry", "Potions & Brews", "Misc", ]; document.addEventListener("DOMContentLoaded", init); async function init() { setupNav(); setupUpload(); await loadData(); } function setupNav() { document.querySelectorAll(".nav-btn").forEach((btn) => { btn.addEventListener("click", () => { document.querySelectorAll(".nav-btn").forEach((b) => b.classList.remove("active")); document.querySelectorAll(".tab-panel").forEach((p) => p.classList.remove("active")); btn.classList.add("active"); document.getElementById(`tab-${btn.dataset.tab}`).classList.add("active"); if (btn.dataset.tab === "history") loadHistoryTab(); }); }); } function setupUpload() { document.getElementById("file-upload").addEventListener("change", async (e) => { const file = e.target.files[0]; if (!file) return; const fd = new FormData(); fd.append("file", file); const res = await fetch("/api/import", { method: "POST", body: fd }); const result = await res.json(); if (result.imported || result.snapshot_id) { await loadData(); alert(result.imported ? `Importiert: Snapshot #${result.snapshot_id}` : "Backup bereits vorhanden (Duplikat)."); } else { alert(result.error || "Import fehlgeschlagen"); } e.target.value = ""; }); } async function loadData() { try { const res = await fetch("/api/snapshot/latest"); if (!res.ok) { showEmpty("Kein Save importiert. Starte mit: python app.py fantasyidler_save.json"); return; } state.data = await res.json(); renderAll(); } catch (err) { showEmpty(`Fehler beim Laden: ${err.message}`); } } function showEmpty(msg) { document.getElementById("character-header").innerHTML = `${esc(msg)}`; } function renderAll() { const d = state.data; if (!d) return; renderHeader(d); renderOverview(d); renderSkills(d); renderInventory(d); renderEquipment(d); renderQuests(d); renderCombat(d); } function renderHeader(d) { const c = d.character; const m = d.meta; document.getElementById("character-header").innerHTML = `

${esc(c.name || "Unbekannt")}

${esc(c.race || "")} · ${esc(c.gender || "")} · Export: ${formatTs(m.exported_at)}
`; document.getElementById("kpi-row").innerHTML = `
Coins
${fmt(m.coins)}
Gesamt-Level
${m.total_level}
Items
${m.item_count}
Stückzahl
${fmt(m.total_items)}
`; } function renderOverview(d) { const c = d.character; const queue = (d.session_queue || []).map((q) => `
  • ${esc(q.skill_display_name || q.skill_name)}${esc(q.activity_key || "—")} · ${q.qty || 0}
  • ` ).join(""); const slayer = d.combat.slayer_task; const slayerHtml = slayer ? `

    ${esc(slayer.display_name)}: ${slayer.kills_completed}/${slayer.target_kills} (${d.combat.slayer_points} Punkte)

    ` : "

    Kein Slayer-Task aktiv

    "; const pets = (d.pets || []).map((p) => `
  • ${esc(p.id.replace(/_/g, " "))}+${p.boost_percent}%
  • ` ).join(""); const farming = (d.farming || []).map((p) => `
  • Feld ${p.patchNumber}${esc(p.cropType || "—")}
  • ` ).join(""); document.getElementById("tab-overview").innerHTML = `

    Charakter

    Session-Queue

    Slayer

    ${slayerHtml}

    Pets

    Farming

    Gilden-Ruf

    `; } function renderSkills(d) { const panel = document.getElementById("tab-skills"); const s = state.skills; let items = [...d.skills]; if (s.search) { const q = s.search.toLowerCase(); items = items.filter((sk) => sk.name.toLowerCase().includes(q) || sk.key.includes(q)); } items.sort((a, b) => { let cmp = 0; if (s.sort === "name") cmp = a.name.localeCompare(b.name); else if (s.sort === "level") cmp = a.level - b.level; else cmp = a.xp - b.xp; return s.sortAsc ? cmp : -cmp; }); panel.innerHTML = `
    ${items.map((sk) => ` `).join("")}
    Skill Level XP Fortschritt
    ${esc(sk.name)} ${sk.level} ${fmt(sk.xp)} ${sk.progress_pct}%
    `; document.getElementById("skill-search").addEventListener("input", (e) => { state.skills.search = e.target.value; renderSkills(state.data); }); document.getElementById("skill-sort").addEventListener("change", (e) => { state.skills.sort = e.target.value; renderSkills(state.data); }); panel.querySelectorAll("th[data-sort]").forEach((th) => { th.addEventListener("click", () => { const key = th.dataset.sort; if (state.skills.sort === key) state.skills.sortAsc = !state.skills.sortAsc; else { state.skills.sort = key; state.skills.sortAsc = false; } renderSkills(state.data); }); }); } function renderInventory(d) { const panel = document.getElementById("tab-inventory"); const inv = state.inventory; const categories = [...new Set(d.inventory.map((i) => i.category))].sort( (a, b) => CATEGORY_ORDER.indexOf(a) - CATEGORY_ORDER.indexOf(b) ); let items = [...d.inventory]; if (inv.search) { const q = inv.search.toLowerCase(); items = items.filter((i) => i.name.toLowerCase().includes(q) || i.key.includes(q)); } if (inv.categories.size > 0) { items = items.filter((i) => inv.categories.has(i.category)); } items.sort((a, b) => { if (inv.sort === "qty") return b.qty - a.qty; if (inv.sort === "name") return a.name.localeCompare(b.name); const ca = CATEGORY_ORDER.indexOf(a.category); const cb = CATEGORY_ORDER.indexOf(b.category); return ca - cb || a.name.localeCompare(b.name); }); const grouped = {}; for (const item of items) { if (!grouped[item.category]) grouped[item.category] = []; grouped[item.category].push(item); } const groupRows = Object.entries(grouped).map(([cat, catItems]) => { const totalQty = catItems.reduce((s, i) => s + i.qty, 0); const header = ` `; const rows = catItems.map((i) => ` ${esc(i.name)}${i.equipped ? '' : ""} ${fmt(i.qty)} ${esc(i.key)} `).join(""); return header + rows; }).join(""); const tableHtml = groupRows ? `
    ${groupRows}
    Item Menge ID
    ` : "

    Keine Items gefunden

    "; panel.innerHTML = `
    ${categories.map((c) => ` ${esc(c)}`).join("")}
    ${tableHtml}
    `; document.getElementById("inv-search").addEventListener("input", (e) => { state.inventory.search = e.target.value; renderInventory(state.data); }); document.getElementById("inv-sort").addEventListener("change", (e) => { state.inventory.sort = e.target.value; renderInventory(state.data); }); document.getElementById("inv-equipped").addEventListener("change", (e) => { state.inventory.highlightEquipped = e.target.checked; renderInventory(state.data); }); panel.querySelectorAll(".chip").forEach((chip) => { chip.addEventListener("click", () => { const cat = chip.dataset.cat; if (state.inventory.categories.has(cat)) state.inventory.categories.delete(cat); else state.inventory.categories.add(cat); renderInventory(state.data); }); }); panel.querySelectorAll(".inv-group-toggle").forEach((btn) => { btn.addEventListener("click", () => { const group = btn.dataset.group; const expanded = btn.getAttribute("aria-expanded") === "true"; btn.setAttribute("aria-expanded", expanded ? "false" : "true"); panel.querySelectorAll(`.inv-item-row[data-group="${group}"]`).forEach((row) => { row.classList.toggle("collapsed", expanded); }); }); }); } function renderEquipment(d) { document.getElementById("tab-equipment").innerHTML = `

    Ausrüstung

    ${d.equipment.map((eq) => `
    ${esc(eq.slot_name)}
    ${eq.name ? esc(eq.name) : "—"}
    `).join("")}
    `; } function renderQuests(d) { const panel = document.getElementById("tab-quests"); const q = state.quests; const tabs = [ { key: "story", label: "Story" }, { key: "daily", label: "Daily" }, { key: "weekly", label: "Weekly" }, { key: "guild", label: "Gilde" }, ]; let items = d.quests[q.tab] || []; if (q.tab === "story") { if (q.filter === "open") items = items.filter((x) => !x.completed); if (q.filter === "done") items = items.filter((x) => x.completed); } else { if (q.filter === "open") items = items.filter((x) => !x.claimed); if (q.filter === "done") items = items.filter((x) => x.claimed); } const isStory = q.tab === "story"; panel.innerHTML = `
    ${tabs.map((t) => ``).join("")}
    ${items.map((quest) => { const done = isStory ? quest.completed : quest.claimed; return ``; }).join("")}
    Quest Fortschritt Status
    ${esc(quest.name)} ${fmt(quest.progress)} ${done ? "Erledigt" : "Offen"}
    `; panel.querySelectorAll(".quest-tab").forEach((btn) => { btn.addEventListener("click", () => { state.quests.tab = btn.dataset.tab; renderQuests(state.data); }); }); document.getElementById("quest-filter").addEventListener("change", (e) => { state.quests.filter = e.target.value; renderQuests(state.data); }); } function renderCombat(d) { const kills = Object.entries(d.combat.enemy_kills || {}) .sort((a, b) => b[1] - a[1]) .map(([k, v]) => `
  • ${esc(k.replace(/_/g, " "))}${fmt(v)}
  • `).join(""); const dungeons = Object.entries(d.combat.dungeon_runs || {}) .sort((a, b) => b[1] - a[1]) .map(([k, v]) => `
  • ${esc(k.replace(/_/g, " "))}${fmt(v)} Runs
  • `).join(""); const recent = (d.recent_sessions || []) .map((s) => `
  • ${esc(s.activity_display_name || s.activity_key)}${esc(s.skill_name)}
  • `).join(""); const active = (d.sessions || []) .map((s) => `
  • ${esc(s.activity)}${esc(s.skill)} · ${s.completed ? "fertig" : "läuft"}
  • `).join(""); document.getElementById("tab-combat").innerHTML = `

    Feind-Kills

    Dungeon-Runs

    Letzte Aktivitäten

    Aktive Sessions

    `; } async function loadHistoryTab() { const panel = document.getElementById("tab-history"); panel.innerHTML = "

    Lade Verlauf…

    "; const [snapRes, tlRes] = await Promise.all([ fetch("/api/snapshots"), fetch("/api/timeline"), ]); state.snapshots = await snapRes.json(); state.timeline = await tlRes.json(); if (state.snapshots.length === 0) { panel.innerHTML = "

    Noch keine Snapshots. Importiere ein Backup.

    "; return; } const h = state.history; if (!h.newerId) h.newerId = state.snapshots[0].id; if (!h.olderId && state.snapshots.length > 1) h.olderId = state.snapshots[1].id; panel.innerHTML = `

    Coins-Verlauf

    Gesamt-Level-Verlauf

    Snapshot-Vergleich

    Alle Snapshots

    ${state.snapshots.map((s) => ` `).join("")}
    IDCharakterCoinsLevelExportDatei
    ${s.id} ${esc(s.character_name || "—")} ${fmt(s.coins)} ${s.total_level} ${formatTs(s.exported_at)} ${esc(s.source_file)}
    `; renderTimelineCharts(); document.getElementById("diff-older").addEventListener("change", (e) => { h.olderId = +e.target.value; }); document.getElementById("diff-newer").addEventListener("change", (e) => { h.newerId = +e.target.value; }); document.getElementById("diff-run").addEventListener("click", runDiff); if (h.olderId && h.newerId && h.olderId !== h.newerId) runDiff(); } function option(s, selected) { const label = `#${s.id} · ${s.character_name || "?"} · ${formatTs(s.exported_at)}`; return ``; } function renderTimelineCharts() { const tl = state.timeline; if (!tl.length) return; const labels = tl.map((s) => formatTs(s.exported_at)); const coins = tl.map((s) => s.coins); const levels = tl.map((s) => s.total_level); destroyChart("coins"); destroyChart("level"); state.charts.coins = new Chart(document.getElementById("chart-coins"), { type: "line", data: { labels, datasets: [{ label: "Coins", data: coins, borderColor: "#6c8cff", tension: 0.3, fill: false }] }, options: chartOpts(), }); state.charts.level = new Chart(document.getElementById("chart-level"), { type: "line", data: { labels, datasets: [{ label: "Gesamt-Level", data: levels, borderColor: "#4ade80", tension: 0.3, fill: false }] }, options: chartOpts(), }); } function chartOpts() { return { responsive: true, maintainAspectRatio: false, plugins: { legend: { labels: { color: "#8b92a8" } } }, scales: { x: { ticks: { color: "#8b92a8" }, grid: { color: "#2d3348" } }, y: { ticks: { color: "#8b92a8" }, grid: { color: "#2d3348" } }, }, }; } function destroyChart(key) { if (state.charts[key]) { state.charts[key].destroy(); delete state.charts[key]; } } async function runDiff() { const h = state.history; const el = document.getElementById("diff-result"); if (!h.olderId || !h.newerId || h.olderId === h.newerId) { el.innerHTML = "

    Wähle zwei verschiedene Snapshots.

    "; return; } const older = Math.min(h.olderId, h.newerId); const newer = Math.max(h.olderId, h.newerId); const res = await fetch(`/api/snapshots/${older}/diff/${newer}`); const diff = await res.json(); if (diff.error) { el.innerHTML = `

    ${esc(diff.error)}

    `; return; } const coinDelta = diff.summary.coins_delta; const coinCls = coinDelta >= 0 ? "delta-pos" : "delta-neg"; const invRows = diff.inventory_changes.slice(0, 50).map((i) => ` ${esc(i.name)} ${fmt(i.old_qty)} → ${fmt(i.new_qty)} ${i.delta >= 0 ? "+" : ""}${fmt(i.delta)} `).join(""); const skRows = diff.skill_changes .sort((a, b) => b.xp_delta - a.xp_delta) .slice(0, 20) .map((s) => ` ${esc(s.name)} ${s.old_level} → ${s.new_level} ${s.xp_delta >= 0 ? "+" : ""}${fmt(s.xp_delta)} XP `).join(""); el.innerHTML = `

    Coins: ${coinDelta >= 0 ? "+" : ""}${fmt(coinDelta)} · Gesamt-Level: ${diff.summary.total_level_delta >= 0 ? "+" : ""}${diff.summary.total_level_delta}

    Inventar-Änderungen (${diff.inventory_changes.length})

    ${invRows || ""}
    ItemMengeDelta
    Keine Änderungen

    Skill-Änderungen (${diff.skill_changes.length})

    ${skRows || ""}
    SkillLevelXP-Delta
    Keine Änderungen
    `; } function fmt(n) { if (n == null) return "—"; return Number(n).toLocaleString("de-DE"); } function formatTs(ts) { if (!ts) return "—"; const d = new Date(Number(ts)); return d.toLocaleString("de-DE", { dateStyle: "short", timeStyle: "short" }); } function esc(s) { if (s == null) return ""; return String(s) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); }