/* Idle Fantasy Save Viewer – client UI */ let state = { data: null, snapshots: [], timeline: [], inventory: { search: "", categories: new Set(), sort: "category", highlightEquipped: false, collapsedGroups: new Set() }, skills: { search: "", sort: "level", sortAsc: false }, quests: { tab: "story", filter: "all" }, goals: { filter: "all", collapsedGroups: new Set(), data: { groups: [], ungrouped: [] } }, goalsOverview: null, goalModalItem: null, history: { olderId: null, newerId: null, diff: null }, charts: {}, inventoryTimeline: null, skillTimeline: null, lastImportChanges: null, globalSearch: "", }; 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", ]; const CATEGORY_I18N_KEYS = { "Currency": "category.currency", "Ores & Mining": "category.ores_mining", "Bars & Smithing": "category.bars_smithing", "Wood & Planks": "category.wood_planks", "Runes": "category.runes", "Raw Food": "category.raw_food", "Cooked Food": "category.cooked_food", "Seeds & Farming": "category.seeds_farming", "Melee Weapons": "category.melee_weapons", "Ranged": "category.ranged", "Magic": "category.magic", "Armor": "category.armor", "Bones & Hides": "category.bones_hides", "Gems & Jewelry": "category.gems_jewelry", "Potions & Brews": "category.potions_brews", "Misc": "category.misc", }; document.addEventListener("DOMContentLoaded", init); function getViewerId() { return document.body?.dataset?.viewerId || ""; } function trackEvent(name, props) { if (typeof window.plausible === "function") { window.plausible(name, props ? { props } : undefined); } } function apiBase() { const vid = getViewerId(); return vid ? `/v/${vid}/api` : "/api"; } function viewerPageUrl() { const vid = getViewerId(); if (!vid) return window.location.href; return `${window.location.origin}/v/${vid}/`; } async function init() { await I18n.init(); applyStaticI18n(); setupLanguage(); setupViewerBanner(); setupNav(); setupUpload(); setupExport(); setupViewerDbImport(); setupGlobalSearch(); setupGoalModal(); await loadData(); } function setupViewerBanner() { const vid = getViewerId(); if (!vid || vid === "local") return; const banner = document.getElementById("viewer-link-banner"); const urlEl = document.getElementById("viewer-link-url"); const copyBtn = document.getElementById("viewer-copy-link"); const url = viewerPageUrl(); banner.hidden = false; urlEl.textContent = url; copyBtn.addEventListener("click", async () => { try { await navigator.clipboard.writeText(url); const prev = copyBtn.textContent; copyBtn.textContent = t("viewer.copied"); setTimeout(() => { copyBtn.textContent = prev; }, 2000); } catch { window.prompt(t("viewer.copyPrompt"), url); } }); } function categoryLabel(cat) { const key = CATEGORY_I18N_KEYS[cat]; return key ? t(key) : cat; } function applyStaticI18n() { document.querySelectorAll("[data-i18n]").forEach((el) => { el.textContent = t(el.dataset.i18n); }); } function setupLanguage() { const sel = document.getElementById("locale-select"); sel.value = I18n.getPreference(); sel.addEventListener("change", async (e) => { await I18n.setPreference(e.target.value); applyStaticI18n(); resetLocaleDependentPanels(); if (state.data) renderAll(); const gs = document.getElementById("global-search"); if (gs) gs.placeholder = t("search.global"); if (document.getElementById("tab-history").classList.contains("active")) { loadHistoryTab(); } }); } function resetLocaleDependentPanels() { ["tab-skills", "tab-inventory"].forEach((id) => { document.getElementById(id).innerHTML = ""; }); } function activateTab(tab) { const btn = document.querySelector(`.nav-btn[data-tab="${tab}"]`); if (!btn) return; 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-${tab}`).classList.add("active"); if (tab === "history") loadHistoryTab(); if (tab === "goals") trackEvent("Goals Tab"); } function setupNav() { document.querySelectorAll(".nav-btn").forEach((btn) => { btn.addEventListener("click", () => { const tab = btn.dataset.tab; activateTab(tab); history.replaceState(null, "", `#${tab}`); }); }); const hash = (location.hash || "#overview").slice(1).split("?")[0] || "overview"; if (document.querySelector(`.nav-btn[data-tab="${hash}"]`)) activateTab(hash); } function setupExport() { const el = document.getElementById("export-viewer"); if (!el) return; el.href = `${apiBase()}/export`; el.title = t("viewerDb.exportHint"); el.addEventListener("click", () => trackEvent("Viewer Export")); } function setupViewerDbImport() { const input = document.getElementById("viewer-db-upload"); if (!input) return; input.addEventListener("change", async (e) => { const file = e.target.files[0]; e.target.value = ""; if (!file) return; if (!file.name.toLowerCase().endsWith(".db")) { alert(t("viewerDb.invalidFile")); return; } if (!confirm(t("viewerDb.importConfirm"))) return; const fd = new FormData(); fd.append("file", file); const res = await fetch(`${apiBase()}/import-viewer`, { method: "POST", body: fd }); const result = await res.json(); if (!res.ok || result.error) { alert(result.error || t("viewerDb.importFailed")); return; } trackEvent("Viewer Import", { snapshots: String(result.snapshots || 0), goals: String(result.goals || 0), }); state.lastImportChanges = null; state.inventoryTimeline = null; state.skillTimeline = null; await loadData(); alert(t("viewerDb.importSuccess", { snapshots: result.snapshots || 0, goals: result.goals || 0, })); }); } function setupGlobalSearch() { const input = document.getElementById("global-search"); if (!input) return; input.placeholder = t("search.global"); input.addEventListener("input", (e) => { state.globalSearch = e.target.value; renderGlobalSearchResults(); }); document.addEventListener("click", (e) => { const wrap = document.querySelector(".global-search-wrap"); const results = document.getElementById("global-search-results"); if (!wrap || !results || wrap.contains(e.target) || results.contains(e.target)) return; results.hidden = true; }); } function collectAllGoals() { const data = state.goals.data || { groups: [], ungrouped: [] }; return [ ...(data.ungrouped || []), ...(data.groups || []).flatMap((g) => g.goals || []), ]; } function collectOpenGoalKeys() { const keys = { items: new Set(), skills: new Set() }; for (const goal of collectAllGoals()) { if (goal.completed_at) continue; if (goal.goal_type === "skill") keys.skills.add(goal.skill_key || goal.item_key); else keys.items.add(goal.item_key); } return keys; } function renderGlobalSearchResults() { const el = document.getElementById("global-search-results"); const q = state.globalSearch.trim().toLowerCase(); if (!el || !q || !state.data) { if (el) el.hidden = true; return; } const hits = []; for (const item of state.data.inventory) { if (item.name.toLowerCase().includes(q) || item.key.toLowerCase().includes(q)) { hits.push({ type: "item", tab: "inventory", key: item.key, name: item.name, sub: fmt(item.qty) }); } } for (const sk of state.data.skills) { if (sk.name.toLowerCase().includes(q) || sk.key.toLowerCase().includes(q)) { hits.push({ type: "skill", tab: "skills", key: sk.key, name: sk.name, sub: `Lv ${sk.level}` }); } } for (const goal of collectAllGoals()) { if (goal.item_name.toLowerCase().includes(q)) { hits.push({ type: "goal", tab: "goals", key: String(goal.id), name: goal.item_name, sub: goal.completed_at ? t("goals.done") : t("goals.open"), }); } } if (!hits.length) { el.hidden = false; el.innerHTML = `

${esc(t("search.noResults"))}

`; return; } el.hidden = false; el.innerHTML = hits.slice(0, 15).map((hit) => ` `).join(""); el.querySelectorAll(".global-search-hit").forEach((btn) => { btn.addEventListener("click", () => { const tab = btn.dataset.tab; const type = btn.dataset.type; const key = btn.dataset.key; activateTab(tab); history.replaceState(null, "", `#${tab}`); if (type === "item") { state.inventory.search = key; renderInventory(state.data); } else if (type === "skill") { state.skills.search = key; renderSkills(state.data); } el.hidden = true; document.getElementById("global-search").value = ""; state.globalSearch = ""; }); }); } 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(`${apiBase()}/import`, { method: "POST", body: fd }); const result = await res.json(); if (!res.ok || result.error) { showImportFailure(result); e.target.value = ""; return; } trackEvent("JSON Upload", { status: result.imported ? "imported" : result.reason || "ok" }); if (result.imported) { state.lastImportChanges = result.import_changes || null; await loadData(); notifyImportSuccess(result); showGoalsCompletedBanner(result); } else if (result.reason === "duplicate") { alert(t("import.duplicate")); } e.target.value = ""; }); } function showImportFailure(result) { const lines = [result.error || t("import.failed")]; for (const item of result.import_report || []) { lines.push(`• ${I18n.translateIssue(item)}`); } alert(lines.join("\n")); } function notifyImportSuccess(result) { const summary = result.import_summary || {}; const warnings = summary.warnings || 0; const infos = summary.infos || 0; if (warnings || infos) { alert(t("import.successWithNotes", { id: result.snapshot_id, warnings, infos, })); } } function renderImportReport(meta) { const el = document.getElementById("import-report"); const report = meta?.import_report || []; const visible = report.filter((i) => i.level === "error" || i.level === "warning"); const infos = report.filter((i) => i.level === "info"); if (!report.length) { el.hidden = true; el.innerHTML = ""; return; } const errors = report.filter((i) => i.level === "error"); const warnings = report.filter((i) => i.level === "warning"); const level = errors.length ? "error" : warnings.length ? "warning" : "info"; const title = errors.length ? t("import.titleError") : warnings.length ? t("import.titleWarning") : t("import.titleInfo"); el.hidden = false; el.className = `import-report import-report-${level}`; el.innerHTML = `
${esc(title)} ${errors.length ? t("import.countErrors", { count: errors.length }) : ""} ${warnings.length ? t("import.countWarnings", { count: warnings.length }) : ""} ${infos.length ? t("import.countInfos", { count: infos.length }) : ""}
`; el.querySelector(".import-report-dismiss").addEventListener("click", () => { el.hidden = true; }); } async function loadData() { try { const [res, , overviewRes] = await Promise.all([ fetch(`${apiBase()}/snapshot/latest`), loadGoals(), fetch(`${apiBase()}/goals/overview`), ]); state.goalsOverview = overviewRes.ok ? await overviewRes.json() : null; if (!res.ok) { showEmpty(getViewerId() ? t("empty.noSaveWeb") : t("empty.noSave")); renderGoals(); return; } state.data = await res.json(); state.inventoryTimeline = null; state.skillTimeline = null; renderAll(); } catch (err) { showEmpty(t("empty.loadError", { message: err.message })); renderGoals(); } } function showEmpty(msg) { document.getElementById("character-header").innerHTML = `${esc(msg)}`; } function renderAll() { const d = state.data; if (!d) return; renderHeader(d); renderImportReport(d.meta); renderOverview(d); bindImportChangesCard(); renderSkills(d); renderInventory(d); renderGoals(); 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 || t("empty.unknown"))}

${esc(c.race || "")} · ${esc(c.gender || "")} · ${t("meta.export")}: ${formatTs(m.exported_at)}
`; document.getElementById("kpi-row").innerHTML = `
${esc(t("kpi.coins"))}
${fmt(m.coins)}
${esc(t("kpi.totalLevel"))}
${m.total_level}
${esc(t("kpi.items"))}
${m.item_count}
${esc(t("kpi.totalQty"))}
${fmt(m.total_items)}
${state.goalsOverview ? `
${esc(t("kpi.goalsOpen"))}
${state.goalsOverview.open}
` : ""}`; } 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} ${esc(t("meta.points"))})

    ` : `

    ${esc(t("overview.noSlayerTask"))}

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

    ${esc(t("overview.character"))}

    ${esc(t("overview.sessionQueue"))}

    ${esc(t("overview.slayer"))}

    ${slayerHtml}

    ${esc(t("overview.pets"))}

    ${esc(t("overview.farming"))}

    ${esc(t("overview.guildRep"))}

    `; } function renderImportChangesCard(changes) { if (!changes?.has_previous) return ""; const invTop = (changes.top_inventory || []).map((i) => `
  • ${esc(i.name)}${i.delta >= 0 ? "+" : ""}${fmt(i.delta)}
  • ` ).join(""); const skTop = (changes.top_skills || []).map((s) => `
  • ${esc(s.name)}+${fmt(s.xp_delta)} XP
  • ` ).join(""); return `

    ${esc(t("import.changesTitle"))}

    ${esc(t("import.changesSummary", { coins: `${changes.coins_delta >= 0 ? "+" : ""}${fmt(changes.coins_delta)}`, level: `${changes.total_level_delta >= 0 ? "+" : ""}${changes.total_level_delta}`, inv: changes.inventory_changes, skills: changes.skill_changes, }))}

    ${invTop ? `

    ${esc(t("import.topInventory"))}

    ` : ""} ${skTop ? `

    ${esc(t("import.topSkills"))}

    ` : ""}
    `; } function bindImportChangesCard() { const dismiss = document.getElementById("import-changes-dismiss"); if (!dismiss) return; dismiss.addEventListener("click", () => { state.lastImportChanges = null; const card = document.getElementById("import-changes-card"); if (card) card.remove(); }); } function renderSkills(d) { const panel = document.getElementById("tab-skills"); const s = state.skills; if (!panel.querySelector("#skill-search")) { panel.innerHTML = `
    XP
    `; document.getElementById("skill-search").addEventListener("input", (e) => { state.skills.search = e.target.value; renderSkillsBody(state.data); }); document.getElementById("skill-sort").addEventListener("change", (e) => { state.skills.sort = e.target.value; renderSkillsBody(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; } renderSkillsBody(state.data); }); }); } document.getElementById("skill-search").placeholder = t("skills.search"); document.getElementById("skill-sort").options[0].textContent = t("skills.sortLevel"); document.getElementById("skill-sort").options[1].textContent = t("skills.sortXp"); document.getElementById("skill-sort").options[2].textContent = t("skills.sortName"); panel.querySelector('th[data-sort="name"]').textContent = t("skills.skill"); panel.querySelector('th[data-sort="level"]').textContent = t("skills.level"); panel.querySelectorAll("thead tr th")[3].textContent = t("skills.progress"); panel.querySelector("thead tr th.col-actions").textContent = t("goals.actions"); document.getElementById("skill-search").value = s.search; document.getElementById("skill-sort").value = s.sort; renderSkillsBody(d); } function renderSkillsBody(d) { const s = state.skills; const openGoals = collectOpenGoalKeys(); 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; }); document.getElementById("skill-tbody").innerHTML = items.map((sk) => { const hasGoal = openGoals.skills.has(sk.key); return ` ${esc(sk.name)}${hasGoal ? `🎯` : ""} ${sk.level} ${fmt(sk.xp)} ${sk.progress_pct}%
    `; }).join(""); document.getElementById("skill-tbody").querySelectorAll(".goal-add-btn").forEach((btn) => { btn.addEventListener("click", () => { openGoalModal({ key: btn.dataset.skillKey, name: btn.dataset.skillName, level: Number(btn.dataset.skillLevel), goalType: "skill", }); }); }); } function getFilteredInventoryItems(d, inv) { 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); }); return items; } async function ensureInventoryTimeline() { if (state.inventoryTimeline) return state.inventoryTimeline; try { const res = await fetch(`${apiBase()}/inventory/timeline`); state.inventoryTimeline = res.ok ? await res.json() : { snapshots: [], series: {} }; } catch { state.inventoryTimeline = { snapshots: [], series: {} }; } return state.inventoryTimeline; } function inventoryTrendEnabled() { return (state.inventoryTimeline?.snapshots?.length || 0) >= 2; } function itemQtySeries(itemKey) { const tl = state.inventoryTimeline; if (!tl?.series) return null; const values = tl.series[itemKey]; if (!values || values.length < 2) return null; return values; } function sparklineSvg(values, width = 72, height = 24) { const max = Math.max(...values); const min = Math.min(...values); const range = max - min || 1; const points = values.map((v, i) => { const x = (i / (values.length - 1)) * width; const y = height - ((v - min) / range) * (height - 4) - 2; return `${x.toFixed(1)},${y.toFixed(1)}`; }).join(" "); return ``; } function renderItemSparkCell(item) { const values = itemQtySeries(item.key); if (!values) { return ``; } return ` `; } function setupInventoryChartModal() { if (document.getElementById("inv-chart-modal")) return; const modal = document.createElement("div"); modal.id = "inv-chart-modal"; modal.className = "inv-chart-modal"; modal.hidden = true; modal.innerHTML = `
    `; document.body.appendChild(modal); modal.querySelectorAll("[data-close]").forEach((el) => { el.addEventListener("click", closeInventoryChartModal); }); document.addEventListener("keydown", (e) => { if (e.key === "Escape" && !modal.hidden) closeInventoryChartModal(); }); } function openInventoryChartModal(itemKey, itemName) { const values = itemQtySeries(itemKey); const tl = state.inventoryTimeline; if (!values || !tl) return; setupInventoryChartModal(); const modal = document.getElementById("inv-chart-modal"); const title = document.getElementById("inv-chart-modal-title"); title.textContent = itemName; modal.hidden = false; document.body.classList.add("inv-chart-modal-open"); destroyChart("inventoryModal"); const points = timelineChartPoints(tl.snapshots, values); state.charts.inventoryModal = new Chart(document.getElementById("inv-chart-modal-canvas"), { type: "line", data: { datasets: [{ label: t("inventory.qty"), data: points, borderColor: "#6c8cff", backgroundColor: "rgba(108, 140, 255, 0.12)", tension: 0.3, fill: true, }], }, options: chartOptsTime(tl.snapshots), }); } function closeInventoryChartModal() { const modal = document.getElementById("inv-chart-modal"); if (!modal || modal.hidden) return; modal.hidden = true; document.body.classList.remove("inv-chart-modal-open"); destroyChart("inventoryModal"); } function bindInventorySparklines(container) { container.querySelectorAll(".inv-spark-btn").forEach((btn) => { btn.addEventListener("click", () => { openInventoryChartModal(btn.dataset.itemKey, btn.dataset.itemName); }); }); } function renderInventoryTable(d, inv) { const items = getFilteredInventoryItems(d, inv); const showTrend = inventoryTrendEnabled(); const colSpan = showTrend ? 5 : 4; const openGoals = collectOpenGoalKeys(); 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 expanded = !inv.collapsedGroups.has(cat); const catLabel = categoryLabel(cat); const header = ` `; const rows = catItems.map((i) => { const hasGoal = openGoals.items.has(i.key); return ` ${esc(i.name)}${i.equipped ? `` : ""}${hasGoal ? `🎯` : ""} ${showTrend ? renderItemSparkCell(i) : ""} ${fmt(i.qty)} ${esc(i.key)} `; }).join(""); return header + rows; }).join(""); const results = document.getElementById("inv-results"); if (!groupRows) { results.innerHTML = `

    ${esc(t("empty.noItems"))}

    `; return; } const trendCol = showTrend ? `` : ""; const trendHeader = showTrend ? `${esc(t("inventory.trend"))}` : ""; results.innerHTML = `
    ${trendCol} ${trendHeader} ${groupRows}
    ${esc(t("inventory.item"))}${esc(t("inventory.qty"))} ${esc(t("inventory.id"))} ${esc(t("goals.actions"))}
    `; results.querySelectorAll(".goal-add-btn").forEach((btn) => { btn.addEventListener("click", () => { openGoalModal({ key: btn.dataset.itemKey, name: btn.dataset.itemName, qty: Number(btn.dataset.itemQty), goalType: "item", }); }); }); results.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"); if (expanded) inv.collapsedGroups.add(group); else inv.collapsedGroups.delete(group); results.querySelectorAll(`.inv-item-row[data-group="${group}"]`).forEach((row) => { row.classList.toggle("collapsed", expanded); }); }); }); bindInventorySparklines(results); } function renderInventoryChips(d, inv) { const categories = [...new Set(d.inventory.map((i) => i.category))].sort( (a, b) => CATEGORY_ORDER.indexOf(a) - CATEGORY_ORDER.indexOf(b) ); const chips = document.getElementById("inv-chips"); chips.innerHTML = categories.map((c) => ` ${esc(categoryLabel(c))}`).join(""); chips.querySelectorAll(".chip").forEach((chip) => { chip.addEventListener("click", () => { const cat = chip.dataset.cat; if (inv.categories.has(cat)) inv.categories.delete(cat); else inv.categories.add(cat); renderInventoryChips(d, inv); ensureInventoryTimeline().then(() => renderInventoryTable(d, inv)); }); }); } function renderInventory(d) { const panel = document.getElementById("tab-inventory"); const inv = state.inventory; if (!panel.querySelector("#inv-search")) { panel.innerHTML = `
    `; document.getElementById("inv-search").addEventListener("input", (e) => { state.inventory.search = e.target.value; ensureInventoryTimeline().then(() => renderInventoryTable(state.data, state.inventory)); }); document.getElementById("inv-sort").addEventListener("change", (e) => { state.inventory.sort = e.target.value; ensureInventoryTimeline().then(() => renderInventoryTable(state.data, state.inventory)); }); document.getElementById("inv-equipped").addEventListener("change", (e) => { state.inventory.highlightEquipped = e.target.checked; ensureInventoryTimeline().then(() => renderInventoryTable(state.data, state.inventory)); }); } document.getElementById("inv-search").placeholder = t("inventory.search"); document.getElementById("inv-sort").options[0].textContent = t("inventory.sortCategory"); document.getElementById("inv-sort").options[1].textContent = t("inventory.sortName"); document.getElementById("inv-sort").options[2].textContent = t("inventory.sortQty"); document.getElementById("inv-equipped-label").textContent = t("inventory.highlightEquipped"); document.getElementById("inv-search").value = inv.search; document.getElementById("inv-sort").value = inv.sort; document.getElementById("inv-equipped").checked = inv.highlightEquipped; renderInventoryChips(d, inv); ensureInventoryTimeline().then(() => renderInventoryTable(d, inv)); } async function loadGoals() { try { const res = await fetch(`${apiBase()}/goals`); state.goals.data = res.ok ? await res.json() : { groups: [], ungrouped: [] }; } catch { state.goals.data = { groups: [], ungrouped: [] }; } } function goalMatchesFilter(goal, filter) { if (filter === "open") return !goal.completed_at; if (filter === "done") return !!goal.completed_at; return true; } function renderGoalRow(goal) { const done = !!goal.completed_at; const isSkill = goal.goal_type === "skill"; const modeHint = goal.mode === "relative" ? `+${isSkill ? goal.target_qty : fmt(goal.target_qty)}` : ""; const typeBadge = isSkill ? `${esc(t("goals.typeSkill"))} ` : ""; const progressText = isSkill ? `${goal.current_qty} / ${goal.target_display}` : `${fmt(goal.current_qty)} / ${fmt(goal.target_display)}`; const eta = !done && goal.eta_snapshots ? `
    ${esc(t("goals.etaSnapshots", { n: goal.eta_snapshots }))}
    ` : ""; const missing = !done && goal.missing_qty > 0 ? `
    ${esc(t("goals.missing", { qty: isSkill ? goal.missing_qty : fmt(goal.missing_qty) }))}
    ` : ""; const deleteBtn = done ? `` : ""; return ` ${typeBadge}${esc(goal.item_name)}
    ${progressText} ${modeHint}
    ${missing}${eta} ${esc(done ? t("goals.done") : t("goals.open"))} ${deleteBtn} `; } function renderGoalsTableBody(goals) { if (!goals.length) { return `${esc(t("goals.empty"))}`; } return goals.map(renderGoalRow).join(""); } function bindGoalActions(panel) { panel.querySelectorAll(".goal-delete-btn").forEach((btn) => { btn.addEventListener("click", async () => { if (!confirm(t("goals.deleteConfirm"))) return; const res = await fetch(`${apiBase()}/goals/${btn.dataset.goalId}`, { method: "DELETE" }); if (res.ok) { trackEvent("Goal Delete"); await loadGoals(); const overviewRes = await fetch(`${apiBase()}/goals/overview`); if (overviewRes.ok) state.goalsOverview = await overviewRes.json(); } renderGoals(); if (state.data) { renderHeader(state.data); renderSkills(state.data); renderInventory(state.data); } }); }); panel.querySelectorAll(".goal-group-rename").forEach((btn) => { btn.addEventListener("click", async () => { const name = prompt(t("goals.renameGroupPrompt"), btn.dataset.groupName); if (!name || !name.trim() || name.trim() === btn.dataset.groupName) return; const res = await fetch(`${apiBase()}/goal-groups/${btn.dataset.groupId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: name.trim() }), }); if (res.ok) { trackEvent("Goal Group Rename"); await loadGoals(); const overviewRes = await fetch(`${apiBase()}/goals/overview`); if (overviewRes.ok) state.goalsOverview = await overviewRes.json(); } renderGoals(); if (state.data) renderHeader(state.data); }); }); panel.querySelectorAll(".goal-group-delete").forEach((btn) => { btn.addEventListener("click", async () => { const name = btn.dataset.groupName; if (!confirm(t("goals.deleteGroupConfirm", { name }))) return; const res = await fetch(`${apiBase()}/goal-groups/${btn.dataset.groupId}`, { method: "DELETE" }); if (res.ok) { trackEvent("Goal Group Delete"); await loadGoals(); } renderGoals(); }); }); panel.querySelectorAll(".goal-clear-completed").forEach((btn) => { btn.addEventListener("click", async () => { const ids = (btn.dataset.goalIds || "").split(",").filter(Boolean); for (const id of ids) { await fetch(`${apiBase()}/goals/${id}`, { method: "DELETE" }); } if (ids.length) { trackEvent("Goals Clear Completed", { count: String(ids.length) }); } await loadGoals(); renderGoals(); }); }); panel.querySelectorAll(".goal-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"); if (expanded) state.goals.collapsedGroups.add(group); else state.goals.collapsedGroups.delete(group); panel.querySelectorAll(`.goal-item-row[data-group="${group}"]`).forEach((row) => { row.classList.toggle("collapsed", expanded); }); }); }); } function renderGoals() { const panel = document.getElementById("tab-goals"); const g = state.goals; const filter = g.filter; const data = g.data || { groups: [], ungrouped: [] }; const groupSections = data.groups.map((group) => { const goals = (group.goals || []).filter((goal) => goalMatchesFilter(goal, filter)); if (!goals.length && filter !== "all") return ""; const completed = goals.filter((goal) => goal.completed_at).length; const total = goals.length; const sectionKey = `group-${group.id}`; const expanded = !g.collapsedGroups.has(sectionKey); const completedIds = goals.filter((goal) => goal.completed_at).map((goal) => goal.id); const missingGoals = goals.filter((goal) => !goal.completed_at && goal.missing_qty > 0); const missingSummary = missingGoals.length ? `
    ${missingGoals.map((goal) => { const qty = goal.goal_type === "skill" ? goal.missing_qty : fmt(goal.missing_qty); return `${esc(goal.item_name)}: ${esc(t("goals.missingShort", { qty }))}`; }).join(" · ")}
    ` : ""; const headerActions = ` ${completedIds.length ? `` : ""} `; const rows = goals.length ? goals.map((goal) => { const row = renderGoalRow(goal); return row.replace("", ``); }).join("") : `${esc(t("goals.empty"))}`; return `
    ${rows}
    ${headerActions} ${missingSummary}
    `; }).join(""); const ungrouped = (data.ungrouped || []).filter((goal) => goalMatchesFilter(goal, filter)); const ungroupedSection = (filter === "all" || ungrouped.length) ? `

    ${esc(t("goals.ungrouped"))}

    ${renderGoalsTableBody(ungrouped)}
    ${esc(t("goals.item"))} ${esc(t("goals.progress"))} ${esc(t("goals.status"))} ${esc(t("goals.actions"))}
    ` : ""; const hasAny = groupSections || ungrouped.length || filter === "all"; const overview = state.goalsOverview; const overviewHtml = overview ? `
    ${overview.open} ${esc(t("goals.filterOpen"))} ${overview.completed} ${esc(t("goals.filterDone"))} ${overview.total} ${esc(t("goals.total"))}
    ` : ""; panel.innerHTML = `
    ${overviewHtml}
    ${hasAny ? groupSections + ungroupedSection : `

    ${esc(t("goals.empty"))}

    `}`; document.getElementById("goals-filter").addEventListener("change", (e) => { state.goals.filter = e.target.value; renderGoals(); }); document.getElementById("goals-create-group").addEventListener("click", async () => { const name = prompt(t("goals.createGroupPrompt")); if (!name || !name.trim()) return; const res = await fetch(`${apiBase()}/goal-groups`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: name.trim() }), }); if (!res.ok) { alert(t("goals.groupCreateFailed")); return; } trackEvent("Goal Group Create", { source: "goals_tab" }); await loadGoals(); renderGoals(); }); bindGoalActions(panel); } async function fetchGoalGroups() { const res = await fetch(`${apiBase()}/goal-groups`); return res.ok ? await res.json() : []; } function setupGoalModal() { document.getElementById("goal-modal-cancel").addEventListener("click", closeGoalModal); document.getElementById("goal-modal-backdrop").addEventListener("click", closeGoalModal); document.getElementById("goal-modal-group").addEventListener("change", (e) => { document.getElementById("goal-modal-new-group-wrap").hidden = e.target.value !== "new"; }); document.getElementById("goal-modal-submit").addEventListener("click", submitGoalModal); } function applyGoalModalI18n() { const item = state.goalModalItem; const isSkill = item?.goalType === "skill"; document.getElementById("goal-modal-title").textContent = isSkill ? t("goals.modalTitleSkill") : t("goals.modalTitle"); document.getElementById("goal-modal-mode-label").textContent = t("goals.mode"); document.getElementById("goal-modal-mode").options[0].textContent = t("goals.modeAbsolute"); document.getElementById("goal-modal-mode").options[1].textContent = t("goals.modeRelative"); document.getElementById("goal-modal-qty-label").textContent = isSkill ? t("goals.targetLevel") : t("goals.targetQty"); document.getElementById("goal-modal-group-label").textContent = t("goals.selectGroup"); document.getElementById("goal-modal-new-group-label").textContent = t("goals.newGroupName"); document.getElementById("goal-modal-cancel").textContent = t("goals.cancel"); document.getElementById("goal-modal-submit").textContent = isSkill ? t("skills.addGoal") : t("inventory.addGoal"); } async function openGoalModal(item) { state.goalModalItem = item; applyGoalModalI18n(); const modal = document.getElementById("goal-modal"); const errEl = document.getElementById("goal-modal-error"); errEl.hidden = true; errEl.textContent = ""; const isSkill = item.goalType === "skill"; const currentVal = isSkill ? item.level : item.qty; const currentLabel = isSkill ? t("goals.currentLevel", { level: currentVal }) : t("goals.currentQty", { qty: fmt(currentVal) }); document.getElementById("goal-modal-item").textContent = `${item.name} — ${currentLabel}`; document.getElementById("goal-modal-qty").value = Math.max(currentVal + 1, 1); document.getElementById("goal-modal-mode").value = "absolute"; document.getElementById("goal-modal-new-group").value = ""; document.getElementById("goal-modal-new-group-wrap").hidden = true; const groups = await fetchGoalGroups(); const sel = document.getElementById("goal-modal-group"); sel.innerHTML = ` ${groups.map((g) => ``).join("")} `; modal.hidden = false; trackEvent("Goal Modal Open"); } function closeGoalModal() { document.getElementById("goal-modal").hidden = true; state.goalModalItem = null; } async function submitGoalModal() { const item = state.goalModalItem; if (!item) return; const errEl = document.getElementById("goal-modal-error"); errEl.hidden = true; const targetQty = parseInt(document.getElementById("goal-modal-qty").value, 10); const mode = document.getElementById("goal-modal-mode").value; const isSkill = item.goalType === "skill"; if (!targetQty || targetQty <= 0) { errEl.textContent = t("goals.createFailed"); errEl.hidden = false; return; } let groupId = null; let newGroupFromModal = false; const groupVal = document.getElementById("goal-modal-group").value; if (groupVal === "new") { const name = document.getElementById("goal-modal-new-group").value.trim(); if (!name) { errEl.textContent = t("goals.groupCreateFailed"); errEl.hidden = false; return; } const gRes = await fetch(`${apiBase()}/goal-groups`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }), }); if (!gRes.ok) { errEl.textContent = t("goals.groupCreateFailed"); errEl.hidden = false; return; } const gData = await gRes.json(); groupId = gData.id; newGroupFromModal = true; } else if (groupVal) { groupId = parseInt(groupVal, 10); } const body = isSkill ? { goal_type: "skill", skill_key: item.key, target_level: targetQty, group_id: groupId, mode } : { item_key: item.key, target_qty: targetQty, group_id: groupId, mode }; const res = await fetch(`${apiBase()}/goals`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); const result = await res.json(); if (!res.ok) { errEl.textContent = result.error || t("goals.createFailed"); errEl.hidden = false; return; } if (newGroupFromModal) { trackEvent("Goal Group Create", { source: "modal" }); } trackEvent("Goal Create", { source: isSkill ? "skills" : "inventory", type: isSkill ? "skill" : "item", mode, group: newGroupFromModal ? "new" : groupId ? "existing" : "none", immediate: result.completed_at ? "true" : "false", }); closeGoalModal(); await loadGoals(); const overviewRes = await fetch(`${apiBase()}/goals/overview`); if (overviewRes.ok) state.goalsOverview = await overviewRes.json(); renderGoals(); if (state.data) { renderHeader(state.data); renderSkills(state.data); renderInventory(state.data); } } function showGoalsCompletedBanner(result) { const el = document.getElementById("goals-completed-banner"); const completed = result.goals_completed || []; const groupsDone = result.groups_completed || []; if (!completed.length && !groupsDone.length) { el.hidden = true; el.innerHTML = ""; return; } trackEvent("Goals Reached", { goals: String(completed.length), groups: String(groupsDone.length), }); const items = completed.map((goal) => { const groupPrefix = goal.group_name ? t("goals.completedItemGroup", { name: goal.group_name }) : ""; return `
  • ${esc(t("goals.completedItem", { group: groupPrefix, name: goal.item_name, current: fmt(goal.current_qty), target: fmt(goal.target_qty), }))}
  • `; }).join(""); const groupLines = groupsDone.map((g) => `
  • ${esc(t("goals.groupCompleted", { name: g.name }))}
  • ` ).join(""); el.hidden = false; el.className = "goals-completed-banner import-report import-report-info"; el.innerHTML = `
    ${esc(t("goals.completedBannerTitle"))}
    `; el.querySelector(".import-report-dismiss").addEventListener("click", () => { el.hidden = true; }); } function renderEquipment(d) { document.getElementById("tab-equipment").innerHTML = `

    ${esc(t("equipment.title"))}

    ${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: t("quests.story") }, { key: "daily", label: t("quests.daily") }, { key: "weekly", label: t("quests.weekly") }, { key: "guild", label: t("quests.guild") }, ]; 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((tab) => ``).join("")}
    ${items.map((quest) => { const done = isStory ? quest.completed : quest.claimed; return ``; }).join("")}
    ${esc(t("quests.quest"))} ${esc(t("quests.progress"))} ${esc(t("quests.status"))}
    ${esc(quest.name)} ${fmt(quest.progress)} ${esc(done ? t("quests.done") : t("quests.open"))}
    `; 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, " "))}${esc(t("combat.runs", { count: fmt(v) }))}
  • `).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)} · ${esc(s.completed ? t("combat.sessionDone") : t("combat.sessionRunning"))}
  • `).join(""); const none = `
  • ${esc(t("empty.none"))}
  • `; document.getElementById("tab-combat").innerHTML = `

    ${esc(t("combat.enemyKills"))}

    ${esc(t("combat.dungeonRuns"))}

    ${esc(t("combat.recentActivity"))}

    ${esc(t("combat.activeSessions"))}

    `; } async function ensureSkillTimeline() { if (state.skillTimeline) return state.skillTimeline; try { const res = await fetch(`${apiBase()}/skills/timeline`); state.skillTimeline = res.ok ? await res.json() : { snapshots: [], series: {} }; } catch { state.skillTimeline = { snapshots: [], series: {} }; } return state.skillTimeline; } async function loadHistoryTab() { const panel = document.getElementById("tab-history"); panel.innerHTML = `

    ${esc(t("history.loading"))}

    `; const [snapRes, tlRes] = await Promise.all([ fetch(`${apiBase()}/snapshots`), fetch(`${apiBase()}/timeline`), ]); state.snapshots = await snapRes.json(); state.timeline = await tlRes.json(); await ensureSkillTimeline(); if (state.snapshots.length === 0) { panel.innerHTML = `

    ${esc(t("empty.noSnapshots"))}

    `; 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 = `

    ${esc(t("history.coinsChart"))}

    ${esc(t("history.levelChart"))}

    ${esc(t("history.skillLevelChart"))}

    ${esc(t("history.snapshotCompare"))}

    ${esc(t("history.allSnapshots"))}

    ${state.snapshots.map((s) => ` `).join("")}
    ID ${esc(t("history.character"))} ${esc(t("kpi.coins"))} ${esc(t("kpi.totalLevel"))} ${esc(t("meta.export"))} ${esc(t("history.file"))} ${esc(t("goals.actions"))}
    ${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); panel.querySelectorAll(".snapshot-delete-btn").forEach((btn) => { btn.addEventListener("click", async () => { if (!confirm(t("history.deleteSnapshotConfirm"))) return; const res = await fetch(`${apiBase()}/snapshots/${btn.dataset.snapshotId}`, { method: "DELETE" }); if (!res.ok) { alert(t("history.deleteSnapshotFailed")); return; } trackEvent("Snapshot Delete"); state.inventoryTimeline = null; state.skillTimeline = null; await loadData(); loadHistoryTab(); }); }); 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; destroyChart("coins"); destroyChart("level"); destroyChart("skills"); state.charts.coins = new Chart(document.getElementById("chart-coins"), { type: "line", data: { datasets: [{ label: t("kpi.coins"), data: timelineChartPoints(tl, tl.map((s) => s.coins)), borderColor: "#6c8cff", tension: 0.3, fill: false, }], }, options: chartOptsTime(tl), }); state.charts.level = new Chart(document.getElementById("chart-level"), { type: "line", data: { datasets: [{ label: t("kpi.totalLevel"), data: timelineChartPoints(tl, tl.map((s) => s.total_level)), borderColor: "#4ade80", tension: 0.3, fill: false, }], }, options: chartOptsTime(tl), }); const skillTl = state.skillTimeline; const skillCanvas = document.getElementById("chart-skills"); if (!skillCanvas || !skillTl?.snapshots?.length) return; const skillEntries = Object.entries(skillTl.series || {}) .map(([key, values]) => ({ key, values, latest: values[values.length - 1] || 0 })) .sort((a, b) => b.latest - a.latest) .slice(0, 5); const colors = ["#6c8cff", "#4ade80", "#fbbf24", "#f87171", "#a78bfa"]; const skillName = (key) => { const sk = state.data?.skills?.find((s) => s.key === key); return sk?.name || key.replace(/_/g, " "); }; state.charts.skills = new Chart(skillCanvas, { type: "line", data: { datasets: skillEntries.map((entry, idx) => ({ label: skillName(entry.key), data: timelineChartPoints(skillTl.snapshots, entry.values), borderColor: colors[idx % colors.length], tension: 0.3, fill: false, })), }, options: chartOptsTime(skillTl.snapshots), }); } function normalizeTimeMs(ts) { const n = Number(ts); if (!Number.isFinite(n) || n <= 0) return null; return n < 1e12 ? n * 1000 : n; } function snapshotTimeMs(snapshot) { if (!snapshot) return null; const exported = normalizeTimeMs(snapshot.exported_at); if (exported != null) return exported; if (snapshot.imported_at) { const imported = Date.parse(snapshot.imported_at); if (Number.isFinite(imported)) return imported; } return null; } function timelineChartPoints(snapshots, values) { return snapshots.map((snapshot, i) => { const x = snapshotTimeMs(snapshot); if (x == null) return null; return { x, y: values[i] }; }).filter(Boolean); } function chartTimeRange(snapshots) { const times = snapshots.map(snapshotTimeMs).filter((t) => t != null); if (!times.length) return {}; const min = Math.min(...times); const max = Math.max(...times); if (min === max) { const pad = 60 * 60 * 1000; return { min: min - pad, max: max + pad }; } return { min, max }; } function formatChartAxisTs(ms, spanMs = 0) { if (!ms || !Number.isFinite(ms)) return ""; const d = new Date(ms); const dayMs = 86400000; if (spanMs > 14 * dayMs) { return d.toLocaleString(I18n.localeTag(), { dateStyle: "short" }); } if (spanMs > 2 * dayMs) { return d.toLocaleString(I18n.localeTag(), { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); } return d.toLocaleString(I18n.localeTag(), { dateStyle: "short", timeStyle: "short" }); } function chartOptsTime(snapshots) { const range = chartTimeRange(snapshots); const spanMs = (range.max ?? 0) - (range.min ?? 0); return { responsive: true, maintainAspectRatio: false, plugins: { legend: { labels: { color: "#8b92a8" } }, tooltip: { callbacks: { title: (items) => { const x = items[0]?.parsed?.x; return x != null ? formatTs(x) : ""; }, }, }, }, scales: { x: { type: "linear", ...range, ticks: { color: "#8b92a8", maxTicksLimit: 8, callback: (v) => formatChartAxisTs(v, spanMs), }, 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 = `

    ${esc(t("empty.pickTwoSnapshots"))}

    `; return; } const older = Math.min(h.olderId, h.newerId); const newer = Math.max(h.olderId, h.newerId); const res = await fetch(`${apiBase()}/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 levelDelta = diff.summary.total_level_delta; 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(""); const noChanges = `${esc(t("empty.noChanges"))}`; el.innerHTML = `

    ${esc(t("history.coinsSummary", { delta: `${coinDelta >= 0 ? "+" : ""}${fmt(coinDelta)}`, levelDelta: `${levelDelta >= 0 ? "+" : ""}${levelDelta}`, }))}

    ${esc(t("history.inventoryChanges", { count: diff.inventory_changes.length }))}

    ${invRows || noChanges}
    ${esc(t("inventory.item"))} ${esc(t("inventory.qty"))} ${esc(t("history.delta"))}

    ${esc(t("history.skillChanges", { count: diff.skill_changes.length }))}

    ${skRows || noChanges}
    ${esc(t("skills.skill"))} ${esc(t("skills.level"))} ${esc(t("history.xpDelta"))}
    `; } function fmt(n) { if (n == null) return "—"; return Number(n).toLocaleString(I18n.localeTag()); } function formatTs(ts) { const ms = normalizeTimeMs(ts); if (ms == null) return "—"; const d = new Date(ms); return d.toLocaleString(I18n.localeTag(), { dateStyle: "short", timeStyle: "short" }); } function esc(s) { if (s == null) return ""; return String(s) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); }