/* 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" },
history: { olderId: null, newerId: null, diff: null },
charts: {},
inventoryTimeline: null,
};
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();
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();
if (document.getElementById("tab-history").classList.contains("active")) {
loadHistoryTab();
}
});
}
function resetLocaleDependentPanels() {
["tab-skills", "tab-inventory"].forEach((id) => {
document.getElementById(id).innerHTML = "";
});
}
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(`${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) {
await loadData();
notifyImportSuccess(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 = `
${visible.map((i) => `- ${esc(I18n.translateIssue(i))}
`).join("")}
${infos.length ? `
-
${esc(t("import.newFieldsSummary", { count: infos.length }))}
${infos.map((i) => `- ${esc(I18n.translateIssue(i))}
`).join("")}
` : ""}
`;
el.querySelector(".import-report-dismiss").addEventListener("click", () => {
el.hidden = true;
});
}
async function loadData() {
try {
const res = await fetch(`${apiBase()}/snapshot/latest`);
if (!res.ok) {
showEmpty(getViewerId() ? t("empty.noSaveWeb") : t("empty.noSave"));
return;
}
state.data = await res.json();
state.inventoryTimeline = null;
renderAll();
} catch (err) {
showEmpty(t("empty.loadError", { message: err.message }));
}
}
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);
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 || 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)}
`;
}
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 = `
${esc(t("overview.character"))}
- ${esc(t("overview.hp"))}${c.hp ?? "—"}
- ${esc(t("overview.activePotion"))}${esc(c.active_potion || "—")}
- ${esc(t("overview.activeSpell"))}${esc(c.active_spell || "—")}
- ${esc(t("overview.weaponSlot"))}${esc(c.active_weapon_slot || "—")}
- ${esc(t("overview.blessing"))}${esc(c.active_blessing || "—")}
${esc(t("overview.sessionQueue"))}
${queue || `- ${esc(t("empty.empty"))}
`}
${esc(t("overview.slayer"))}
${slayerHtml}
${esc(t("overview.pets"))}
${pets || `- ${esc(t("empty.none"))}
`}
${esc(t("overview.farming"))}
${farming || `- ${esc(t("empty.none"))}
`}
${esc(t("overview.guildRep"))}
${Object.entries(d.guild_reputation || {}).map(([k, v]) =>
`- ${esc(k)}${fmt(v)}
`).join("")}
`;
}
function renderSkills(d) {
const panel = document.getElementById("tab-skills");
const s = state.skills;
if (!panel.querySelector("#skill-search")) {
panel.innerHTML = `
`;
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.querySelector("thead tr th:last-child").textContent = t("skills.progress");
document.getElementById("skill-search").value = s.search;
document.getElementById("skill-sort").value = s.sort;
renderSkillsBody(d);
}
function renderSkillsBody(d) {
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;
});
document.getElementById("skill-tbody").innerHTML = items.map((sk) => `
| ${esc(sk.name)} |
${sk.level} |
${fmt(sk.xp)} |
${sk.progress_pct}%
|
`).join("");
}
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 labels = tl.snapshots.map((s) => formatTs(s.exported_at));
state.charts.inventoryModal = new Chart(document.getElementById("inv-chart-modal-canvas"), {
type: "line",
data: {
labels,
datasets: [{
label: t("inventory.qty"),
data: values,
borderColor: "#6c8cff",
backgroundColor: "rgba(108, 140, 255, 0.12)",
tension: 0.3,
fill: true,
}],
},
options: chartOpts(),
});
}
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 ? 4 : 3;
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) => `
| ${esc(i.name)}${i.equipped ? `⚡` : ""} |
${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}
| ${esc(t("inventory.item"))} |
${trendHeader}
${esc(t("inventory.qty"))} |
${esc(t("inventory.id"))} |
${groupRows}
`;
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));
}
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("")}
| ${esc(t("quests.quest"))} |
${esc(t("quests.progress"))} |
${esc(t("quests.status"))} |
${items.map((quest) => {
const done = isStory ? quest.completed : quest.claimed;
return `
| ${esc(quest.name)} |
${fmt(quest.progress)} |
${esc(done ? t("quests.done") : t("quests.open"))} |
`;
}).join("")}
`;
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 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();
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.snapshotCompare"))}
→
${esc(t("history.allSnapshots"))}
| ID |
${esc(t("history.character"))} |
${esc(t("kpi.coins"))} |
${esc(t("kpi.totalLevel"))} |
${esc(t("meta.export"))} |
${esc(t("history.file"))} |
${state.snapshots.map((s) => `
| ${s.id} |
${esc(s.character_name || "—")} |
${fmt(s.coins)} |
${s.total_level} |
${formatTs(s.exported_at)} |
${esc(s.source_file)} |
`).join("")}
`;
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: t("kpi.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: t("kpi.totalLevel"), 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 = `${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 }))}
| ${esc(t("inventory.item"))} |
${esc(t("inventory.qty"))} |
${esc(t("history.delta"))} |
${invRows || noChanges}
${esc(t("history.skillChanges", { count: diff.skill_changes.length }))}
| ${esc(t("skills.skill"))} |
${esc(t("skills.level"))} |
${esc(t("history.xpDelta"))} |
${skRows || noChanges}
`;
}
function fmt(n) {
if (n == null) return "—";
return Number(n).toLocaleString(I18n.localeTag());
}
function formatTs(ts) {
if (!ts) return "—";
const d = new Date(Number(ts));
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, """);
}