Files
Idle-Fantasy-Save-Viewer/static/app.js
T
elpatron 3314de829d Use real timestamps on history chart x-axes.
Plot snapshots by export or import time instead of category indices so
coins, level, skill, and inventory trends reflect actual elapsed time.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-20 09:43:31 +02:00

1859 lines
68 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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 = `<p class="global-search-empty">${esc(t("search.noResults"))}</p>`;
return;
}
el.hidden = false;
el.innerHTML = hits.slice(0, 15).map((hit) => `
<button type="button" class="global-search-hit" data-tab="${esc(hit.tab)}" data-type="${esc(hit.type)}" data-key="${esc(hit.key)}">
<span class="global-search-hit-type">${esc(t(`search.type.${hit.type}`))}</span>
<span class="global-search-hit-name">${esc(hit.name)}</span>
<span class="global-search-hit-sub">${esc(hit.sub)}</span>
</button>`).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 = `
<div class="import-report-header">
<strong>${esc(title)}</strong>
<span class="import-report-counts">
${errors.length ? t("import.countErrors", { count: errors.length }) : ""}
${warnings.length ? t("import.countWarnings", { count: warnings.length }) : ""}
${infos.length ? t("import.countInfos", { count: infos.length }) : ""}
</span>
<button type="button" class="import-report-dismiss" title="${esc(t("actions.dismiss"))}">×</button>
</div>
<ul class="import-report-list">
${visible.map((i) => `<li class="import-issue import-issue-${i.level}">${esc(I18n.translateIssue(i))}</li>`).join("")}
${infos.length ? `
<li class="import-issue import-issue-info-collapsed">
<details>
<summary>${esc(t("import.newFieldsSummary", { count: infos.length }))}</summary>
<ul>${infos.map((i) => `<li>${esc(I18n.translateIssue(i))}</li>`).join("")}</ul>
</details>
</li>` : ""}
</ul>`;
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 = `<span class="loading">${esc(msg)}</span>`;
}
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 = `
<h2>${esc(c.name || t("empty.unknown"))}</h2>
<div class="character-meta">
${esc(c.race || "")} · ${esc(c.gender || "")} · ${t("meta.export")}: ${formatTs(m.exported_at)}
</div>`;
document.getElementById("kpi-row").innerHTML = `
<div class="kpi"><div class="kpi-label">${esc(t("kpi.coins"))}</div><div class="kpi-value">${fmt(m.coins)}</div></div>
<div class="kpi"><div class="kpi-label">${esc(t("kpi.totalLevel"))}</div><div class="kpi-value">${m.total_level}</div></div>
<div class="kpi"><div class="kpi-label">${esc(t("kpi.items"))}</div><div class="kpi-value">${m.item_count}</div></div>
<div class="kpi"><div class="kpi-label">${esc(t("kpi.totalQty"))}</div><div class="kpi-value">${fmt(m.total_items)}</div></div>
${state.goalsOverview ? `<div class="kpi kpi-goals"><div class="kpi-label">${esc(t("kpi.goalsOpen"))}</div><div class="kpi-value">${state.goalsOverview.open}</div></div>` : ""}`;
}
function renderOverview(d) {
const c = d.character;
const queue = (d.session_queue || []).map((q) =>
`<li><span>${esc(q.skill_display_name || q.skill_name)}</span><span>${esc(q.activity_key || "—")} · ${q.qty || 0}</span></li>`
).join("");
const slayer = d.combat.slayer_task;
const slayerHtml = slayer
? `<p><strong>${esc(slayer.display_name)}</strong>: ${slayer.kills_completed}/${slayer.target_kills} (${d.combat.slayer_points} ${esc(t("meta.points"))})</p>`
: `<p class='empty-state'>${esc(t("overview.noSlayerTask"))}</p>`;
const pets = (d.pets || []).map((p) =>
`<li><span>${esc(p.id.replace(/_/g, " "))}</span><span>+${p.boost_percent}%</span></li>`
).join("");
const farming = (d.farming || []).map((p) =>
`<li><span>${esc(t("overview.patch", { n: p.patchNumber }))}</span><span>${esc(p.cropType || "—")}</span></li>`
).join("");
document.getElementById("tab-overview").innerHTML = `
${renderImportChangesCard(state.lastImportChanges)}
<div class="grid-2">
<div class="card">
<h3>${esc(t("overview.character"))}</h3>
<ul class="list-compact">
<li><span>${esc(t("overview.hp"))}</span><span>${c.hp ?? "—"}</span></li>
<li><span>${esc(t("overview.activePotion"))}</span><span>${esc(c.active_potion || "—")}</span></li>
<li><span>${esc(t("overview.activeSpell"))}</span><span>${esc(c.active_spell || "—")}</span></li>
<li><span>${esc(t("overview.weaponSlot"))}</span><span>${esc(c.active_weapon_slot || "—")}</span></li>
<li><span>${esc(t("overview.blessing"))}</span><span>${esc(c.active_blessing || "—")}</span></li>
</ul>
</div>
<div class="card">
<h3>${esc(t("overview.sessionQueue"))}</h3>
<ul class="list-compact">${queue || `<li><span>${esc(t("empty.empty"))}</span></li>`}</ul>
</div>
<div class="card">
<h3>${esc(t("overview.slayer"))}</h3>
${slayerHtml}
</div>
<div class="card">
<h3>${esc(t("overview.pets"))}</h3>
<ul class="list-compact">${pets || `<li><span>${esc(t("empty.none"))}</span></li>`}</ul>
</div>
<div class="card">
<h3>${esc(t("overview.farming"))}</h3>
<ul class="list-compact">${farming || `<li><span>${esc(t("empty.none"))}</span></li>`}</ul>
</div>
<div class="card">
<h3>${esc(t("overview.guildRep"))}</h3>
<ul class="list-compact">${Object.entries(d.guild_reputation || {}).map(([k, v]) =>
`<li><span>${esc(k)}</span><span>${fmt(v)}</span></li>`).join("")}</ul>
</div>
</div>`;
}
function renderImportChangesCard(changes) {
if (!changes?.has_previous) return "";
const invTop = (changes.top_inventory || []).map((i) =>
`<li><span>${esc(i.name)}</span><span class="${i.delta >= 0 ? "delta-pos" : "delta-neg"}">${i.delta >= 0 ? "+" : ""}${fmt(i.delta)}</span></li>`
).join("");
const skTop = (changes.top_skills || []).map((s) =>
`<li><span>${esc(s.name)}</span><span class="${s.xp_delta >= 0 ? "delta-pos" : "delta-neg"}">+${fmt(s.xp_delta)} XP</span></li>`
).join("");
return `
<div class="card import-changes-card" id="import-changes-card">
<div class="import-report-header">
<h3>${esc(t("import.changesTitle"))}</h3>
<button type="button" class="import-report-dismiss" id="import-changes-dismiss" title="${esc(t("actions.dismiss"))}">×</button>
</div>
<p class="import-changes-summary">${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,
}))}</p>
<ul class="list-compact import-changes-stats">
${changes.quests_completed ? `<li><span>${esc(t("import.questsCompleted"))}</span><span>${changes.quests_completed}</span></li>` : ""}
${changes.slayer_kills_delta ? `<li><span>${esc(t("import.slayerKills"))}</span><span>+${changes.slayer_kills_delta}</span></li>` : ""}
${changes.dungeon_runs_delta ? `<li><span>${esc(t("import.dungeonRuns"))}</span><span>+${changes.dungeon_runs_delta}</span></li>` : ""}
</ul>
${invTop ? `<h4>${esc(t("import.topInventory"))}</h4><ul class="list-compact">${invTop}</ul>` : ""}
${skTop ? `<h4>${esc(t("import.topSkills"))}</h4><ul class="list-compact">${skTop}</ul>` : ""}
</div>`;
}
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 = `
<div class="toolbar">
<input class="search-input" id="skill-search" placeholder="" value="">
<select class="select-input" id="skill-sort">
<option value="level"></option>
<option value="xp"></option>
<option value="name"></option>
</select>
</div>
<div class="card">
<table>
<thead><tr>
<th data-sort="name"></th>
<th data-sort="level"></th>
<th data-sort="xp">XP</th>
<th></th>
<th class="col-actions"></th>
</tr></thead>
<tbody id="skill-tbody"></tbody>
</table>
</div>`;
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 `
<tr class="${hasGoal ? "has-goal" : ""}">
<td>${esc(sk.name)}${hasGoal ? `<span class="goal-mark" title="${esc(t("goals.hasGoal"))}">🎯</span>` : ""}</td>
<td>${sk.level}</td>
<td>${fmt(sk.xp)}</td>
<td style="min-width:140px">
${sk.progress_pct}%
<div class="progress-bar"><div class="progress-fill" style="width:${sk.progress_pct}%"></div></div>
</td>
<td class="col-actions">
<button type="button" class="goal-add-btn" data-skill-key="${esc(sk.key)}" data-skill-name="${esc(sk.name)}" data-skill-level="${sk.level}" title="${esc(t("skills.addGoalFor", { name: sk.name }))}" aria-label="${esc(t("skills.addGoalFor", { name: sk.name }))}">+</button>
</td>
</tr>`;
}).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 `<svg class="inv-spark-svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" aria-hidden="true"><polyline points="${points}" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
}
function renderItemSparkCell(item) {
const values = itemQtySeries(item.key);
if (!values) {
return `<td class="col-trend"><span class="inv-spark-empty">—</span></td>`;
}
return `
<td class="col-trend">
<button type="button" class="inv-spark-btn" data-item-key="${esc(item.key)}" data-item-name="${esc(item.name)}" title="${esc(t("inventory.trendExpand"))}" aria-label="${esc(t("inventory.trendExpandFor", { name: item.name }))}">
${sparklineSvg(values)}
</button>
</td>`;
}
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 = `
<div class="inv-chart-modal-backdrop" data-close="1"></div>
<div class="inv-chart-modal-panel card" role="dialog" aria-modal="true" aria-labelledby="inv-chart-modal-title">
<button type="button" class="inv-chart-modal-close" data-close="1" aria-label="${esc(t("actions.dismiss"))}">×</button>
<h3 id="inv-chart-modal-title"></h3>
<div class="chart-wrap chart-wrap-modal"><canvas id="inv-chart-modal-canvas"></canvas></div>
</div>`;
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 = `
<tr class="inv-group-row" data-group="${esc(cat)}">
<td colspan="${colSpan}">
<button type="button" class="inv-group-toggle" data-group="${esc(cat)}" aria-expanded="${expanded}">
<span class="inv-group-title">${esc(catLabel)}</span>
<span class="inv-group-meta">${esc(t("inventory.groupMeta", { count: catItems.length, qty: fmt(totalQty) }))}</span>
</button>
</td>
</tr>`;
const rows = catItems.map((i) => {
const hasGoal = openGoals.items.has(i.key);
return `
<tr class="inv-item-row ${i.equipped && inv.highlightEquipped ? "item-equipped" : ""} ${hasGoal ? "has-goal" : ""} ${expanded ? "" : "collapsed"}" data-group="${esc(cat)}">
<td class="col-name">${esc(i.name)}${i.equipped ? `<span class="equipped-mark" title="${esc(t("inventory.equipped"))}">⚡</span>` : ""}${hasGoal ? `<span class="goal-mark" title="${esc(t("goals.hasGoal"))}">🎯</span>` : ""}</td>
${showTrend ? renderItemSparkCell(i) : ""}
<td class="col-qty">${fmt(i.qty)}</td>
<td class="col-key"><code>${esc(i.key)}</code></td>
<td class="col-actions">
<button type="button" class="goal-add-btn" data-item-key="${esc(i.key)}" data-item-name="${esc(i.name)}" data-item-qty="${i.qty}" title="${esc(t("inventory.addGoalFor", { name: i.name }))}" aria-label="${esc(t("inventory.addGoalFor", { name: i.name }))}">+</button>
</td>
</tr>`;
}).join("");
return header + rows;
}).join("");
const results = document.getElementById("inv-results");
if (!groupRows) {
results.innerHTML = `<p class='empty-state'>${esc(t("empty.noItems"))}</p>`;
return;
}
const trendCol = showTrend
? `<col class="col-trend">`
: "";
const trendHeader = showTrend
? `<th class="col-trend">${esc(t("inventory.trend"))}</th>`
: "";
results.innerHTML = `
<div class="inv-table-wrap">
<table class="inv-table ${showTrend ? "has-trend" : ""}">
<colgroup>
<col class="col-name">
${trendCol}
<col class="col-qty">
<col class="col-key">
<col class="col-actions">
</colgroup>
<thead>
<tr>
<th class="col-name">${esc(t("inventory.item"))}</th>
${trendHeader}
<th class="col-qty">${esc(t("inventory.qty"))}</th>
<th class="col-key">${esc(t("inventory.id"))}</th>
<th class="col-actions">${esc(t("goals.actions"))}</th>
</tr>
</thead>
<tbody>${groupRows}</tbody>
</table>
</div>`;
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) => `
<span class="chip ${inv.categories.has(c) ? "active" : ""}" data-cat="${esc(c)}">${esc(categoryLabel(c))}</span>`).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 = `
<div class="toolbar">
<input class="search-input" id="inv-search" placeholder="" value="">
<select class="select-input" id="inv-sort">
<option value="category"></option>
<option value="name"></option>
<option value="qty"></option>
</select>
<label class="toggle-label">
<input type="checkbox" id="inv-equipped">
<span id="inv-equipped-label"></span>
</label>
</div>
<div class="chip-row" id="inv-chips"></div>
<div class="card inv-card" id="inv-results"></div>`;
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"
? `<span class="goal-mode-badge" title="${esc(t("goals.modeRelativeHint"))}">+${isSkill ? goal.target_qty : fmt(goal.target_qty)}</span>`
: "";
const typeBadge = isSkill ? `<span class="goal-type-badge">${esc(t("goals.typeSkill"))}</span> ` : "";
const progressText = isSkill
? `${goal.current_qty} / ${goal.target_display}`
: `${fmt(goal.current_qty)} / ${fmt(goal.target_display)}`;
const eta = !done && goal.eta_snapshots
? `<div class="goal-eta">${esc(t("goals.etaSnapshots", { n: goal.eta_snapshots }))}</div>`
: "";
const missing = !done && goal.missing_qty > 0
? `<div class="goal-missing">${esc(t("goals.missing", { qty: isSkill ? goal.missing_qty : fmt(goal.missing_qty) }))}</div>`
: "";
const deleteBtn = done
? `<button type="button" class="goal-delete-btn" data-goal-id="${goal.id}">${esc(t("goals.delete"))}</button>`
: "";
return `<tr>
<td>${typeBadge}${esc(goal.item_name)}</td>
<td>
<div class="goal-progress-text">${progressText} ${modeHint}</div>
<div class="progress-bar"><div class="progress-fill" style="width:${goal.progress_pct}%"></div></div>
${missing}${eta}
</td>
<td><span class="badge ${done ? "badge-success" : "badge-warning"}">${esc(done ? t("goals.done") : t("goals.open"))}</span></td>
<td class="goal-actions-cell">${deleteBtn}</td>
</tr>`;
}
function renderGoalsTableBody(goals) {
if (!goals.length) {
return `<tr><td colspan="4" class="empty-state">${esc(t("goals.empty"))}</td></tr>`;
}
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
? `<div class="goal-missing-summary">${missingGoals.map((goal) => {
const qty = goal.goal_type === "skill" ? goal.missing_qty : fmt(goal.missing_qty);
return `<span>${esc(goal.item_name)}: ${esc(t("goals.missingShort", { qty }))}</span>`;
}).join(" · ")}</div>`
: "";
const headerActions = `
${completedIds.length ? `<button type="button" class="goal-clear-completed" data-goal-ids="${completedIds.join(",")}">${esc(t("goals.clearCompleted"))}</button>` : ""}
<button type="button" class="goal-group-rename" data-group-id="${group.id}" data-group-name="${esc(group.name)}">${esc(t("goals.renameGroup"))}</button>
<button type="button" class="goal-group-delete" data-group-id="${group.id}" data-group-name="${esc(group.name)}">${esc(t("goals.deleteGroup"))}</button>`;
const rows = goals.length
? goals.map((goal) => {
const row = renderGoalRow(goal);
return row.replace("<tr>", `<tr class="goal-item-row ${expanded ? "" : "collapsed"}" data-group="${sectionKey}">`);
}).join("")
: `<tr class="goal-item-row ${expanded ? "" : "collapsed"}" data-group="${sectionKey}"><td colspan="4" class="empty-state">${esc(t("goals.empty"))}</td></tr>`;
return `
<div class="card goals-group-card">
<table class="goals-table">
<tbody>
<tr class="inv-group-row goal-group-header">
<td colspan="4">
<button type="button" class="inv-group-toggle goal-group-toggle" data-group="${sectionKey}" aria-expanded="${expanded}">
<span class="inv-group-title">${esc(group.name)}</span>
<span class="inv-group-meta">${esc(t("goals.groupProgress", { completed, total }))}</span>
</button>
<span class="goal-group-actions">${headerActions}</span>
${missingSummary}
</td>
</tr>
${rows}
</tbody>
</table>
</div>`;
}).join("");
const ungrouped = (data.ungrouped || []).filter((goal) => goalMatchesFilter(goal, filter));
const ungroupedSection = (filter === "all" || ungrouped.length) ? `
<div class="card goals-group-card">
<h3 class="goals-ungrouped-title">${esc(t("goals.ungrouped"))}</h3>
<table class="goals-table">
<thead>
<tr>
<th>${esc(t("goals.item"))}</th>
<th>${esc(t("goals.progress"))}</th>
<th>${esc(t("goals.status"))}</th>
<th>${esc(t("goals.actions"))}</th>
</tr>
</thead>
<tbody>${renderGoalsTableBody(ungrouped)}</tbody>
</table>
</div>` : "";
const hasAny = groupSections || ungrouped.length || filter === "all";
const overview = state.goalsOverview;
const overviewHtml = overview ? `
<div class="goals-overview-kpi">
<span class="goals-kpi-item"><strong>${overview.open}</strong> ${esc(t("goals.filterOpen"))}</span>
<span class="goals-kpi-item"><strong>${overview.completed}</strong> ${esc(t("goals.filterDone"))}</span>
<span class="goals-kpi-item"><strong>${overview.total}</strong> ${esc(t("goals.total"))}</span>
</div>` : "";
panel.innerHTML = `
<div class="toolbar goals-toolbar">
${overviewHtml}
<select class="select-input" id="goals-filter">
<option value="all" ${filter === "all" ? "selected" : ""}>${esc(t("goals.filterAll"))}</option>
<option value="open" ${filter === "open" ? "selected" : ""}>${esc(t("goals.filterOpen"))}</option>
<option value="done" ${filter === "done" ? "selected" : ""}>${esc(t("goals.filterDone"))}</option>
</select>
<button type="button" class="upload-btn" id="goals-create-group">${esc(t("goals.createGroup"))}</button>
</div>
${hasAny ? groupSections + ungroupedSection : `<div class="card"><p class="empty-state">${esc(t("goals.empty"))}</p></div>`}`;
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 = `
<option value="">${esc(t("goals.noGroup"))}</option>
${groups.map((g) => `<option value="${g.id}">${esc(g.name)}</option>`).join("")}
<option value="new">${esc(t("goals.newGroup"))}</option>`;
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 `<li>${esc(t("goals.completedItem", {
group: groupPrefix,
name: goal.item_name,
current: fmt(goal.current_qty),
target: fmt(goal.target_qty),
}))}</li>`;
}).join("");
const groupLines = groupsDone.map((g) =>
`<li class="goal-group-completed-line">${esc(t("goals.groupCompleted", { name: g.name }))}</li>`
).join("");
el.hidden = false;
el.className = "goals-completed-banner import-report import-report-info";
el.innerHTML = `
<div class="import-report-header">
<strong>${esc(t("goals.completedBannerTitle"))}</strong>
<button type="button" class="import-report-dismiss" title="${esc(t("actions.dismiss"))}">×</button>
</div>
<ul class="import-report-list">${items}${groupLines}</ul>`;
el.querySelector(".import-report-dismiss").addEventListener("click", () => {
el.hidden = true;
});
}
function renderEquipment(d) {
document.getElementById("tab-equipment").innerHTML = `
<div class="card">
<h3>${esc(t("equipment.title"))}</h3>
<div class="equip-grid">
${d.equipment.map((eq) => `
<div class="equip-slot ${eq.key ? "" : "empty"}">
<div class="slot-name">${esc(eq.slot_name)}</div>
<div class="item-name">${eq.name ? esc(eq.name) : "—"}</div>
</div>`).join("")}
</div>
</div>`;
}
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 = `
<div class="quest-tabs">
${tabs.map((tab) => `<button class="quest-tab ${q.tab === tab.key ? "active" : ""}" data-tab="${tab.key}">${esc(tab.label)}</button>`).join("")}
</div>
<div class="toolbar">
<select class="select-input" id="quest-filter">
<option value="all" ${q.filter === "all" ? "selected" : ""}>${esc(t("quests.filterAll"))}</option>
<option value="open" ${q.filter === "open" ? "selected" : ""}>${esc(t("quests.filterOpen"))}</option>
<option value="done" ${q.filter === "done" ? "selected" : ""}>${esc(t("quests.filterDone"))}</option>
</select>
</div>
<div class="card">
<table>
<thead><tr>
<th>${esc(t("quests.quest"))}</th>
<th>${esc(t("quests.progress"))}</th>
<th>${esc(t("quests.status"))}</th>
</tr></thead>
<tbody>${items.map((quest) => {
const done = isStory ? quest.completed : quest.claimed;
return `<tr>
<td>${esc(quest.name)}</td>
<td>${fmt(quest.progress)}</td>
<td><span class="badge ${done ? "badge-success" : "badge-warning"}">${esc(done ? t("quests.done") : t("quests.open"))}</span></td>
</tr>`;
}).join("")}</tbody>
</table>
</div>`;
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]) => `<li><span>${esc(k.replace(/_/g, " "))}</span><span>${fmt(v)}</span></li>`).join("");
const dungeons = Object.entries(d.combat.dungeon_runs || {})
.sort((a, b) => b[1] - a[1])
.map(([k, v]) => `<li><span>${esc(k.replace(/_/g, " "))}</span><span>${esc(t("combat.runs", { count: fmt(v) }))}</span></li>`).join("");
const recent = (d.recent_sessions || [])
.map((s) => `<li><span>${esc(s.activity_display_name || s.activity_key)}</span><span>${esc(s.skill_name)}</span></li>`).join("");
const active = (d.sessions || [])
.map((s) => `<li><span>${esc(s.activity)}</span><span>${esc(s.skill)} · ${esc(s.completed ? t("combat.sessionDone") : t("combat.sessionRunning"))}</span></li>`).join("");
const none = `<li>${esc(t("empty.none"))}</li>`;
document.getElementById("tab-combat").innerHTML = `
<div class="grid-2">
<div class="card"><h3>${esc(t("combat.enemyKills"))}</h3><ul class="list-compact">${kills || none}</ul></div>
<div class="card"><h3>${esc(t("combat.dungeonRuns"))}</h3><ul class="list-compact">${dungeons || none}</ul></div>
<div class="card"><h3>${esc(t("combat.recentActivity"))}</h3><ul class="list-compact">${recent || none}</ul></div>
<div class="card"><h3>${esc(t("combat.activeSessions"))}</h3><ul class="list-compact">${active || none}</ul></div>
</div>`;
}
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 = `<p class='loading'>${esc(t("history.loading"))}</p>`;
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 = `<p class='empty-state'>${esc(t("empty.noSnapshots"))}</p>`;
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 = `
<div class="grid-2">
<div class="card">
<h3>${esc(t("history.coinsChart"))}</h3>
<div class="chart-wrap"><canvas id="chart-coins"></canvas></div>
</div>
<div class="card">
<h3>${esc(t("history.levelChart"))}</h3>
<div class="chart-wrap"><canvas id="chart-level"></canvas></div>
</div>
</div>
<div class="card">
<h3>${esc(t("history.skillLevelChart"))}</h3>
<div class="chart-wrap"><canvas id="chart-skills"></canvas></div>
</div>
<div class="card">
<h3>${esc(t("history.snapshotCompare"))}</h3>
<div class="toolbar">
<select class="select-input" id="diff-older">
${state.snapshots.map((s) => option(s, h.olderId)).join("")}
</select>
<span>→</span>
<select class="select-input" id="diff-newer">
${state.snapshots.map((s) => option(s, h.newerId)).join("")}
</select>
<button class="select-input" id="diff-run" style="background:var(--accent);border-color:var(--accent);color:#fff;font-weight:600">${esc(t("actions.compare"))}</button>
</div>
<div id="diff-result"></div>
</div>
<div class="card">
<h3>${esc(t("history.allSnapshots"))}</h3>
<table>
<thead><tr>
<th>ID</th>
<th>${esc(t("history.character"))}</th>
<th>${esc(t("kpi.coins"))}</th>
<th>${esc(t("kpi.totalLevel"))}</th>
<th>${esc(t("meta.export"))}</th>
<th>${esc(t("history.file"))}</th>
<th>${esc(t("goals.actions"))}</th>
</tr></thead>
<tbody>${state.snapshots.map((s) => `
<tr>
<td>${s.id}</td>
<td>${esc(s.character_name || "—")}</td>
<td>${fmt(s.coins)}</td>
<td>${s.total_level}</td>
<td>${formatTs(s.exported_at)}</td>
<td>${esc(s.source_file)}</td>
<td>
<button type="button" class="snapshot-delete-btn" data-snapshot-id="${s.id}" ${state.snapshots.length <= 1 ? "disabled" : ""} title="${esc(t("history.deleteSnapshot"))}">${esc(t("history.delete"))}</button>
</td>
</tr>`).join("")}
</tbody>
</table>
</div>`;
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 `<option value="${s.id}" ${s.id === selected ? "selected" : ""}>${esc(label)}</option>`;
}
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 = `<p class='empty-state'>${esc(t("empty.pickTwoSnapshots"))}</p>`;
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 = `<p class='empty-state'>${esc(diff.error)}</p>`;
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) => `
<tr>
<td>${esc(i.name)}</td>
<td>${fmt(i.old_qty)}${fmt(i.new_qty)}</td>
<td class="${i.delta >= 0 ? "delta-pos" : "delta-neg"}">${i.delta >= 0 ? "+" : ""}${fmt(i.delta)}</td>
</tr>`).join("");
const skRows = diff.skill_changes
.sort((a, b) => b.xp_delta - a.xp_delta)
.slice(0, 20)
.map((s) => `
<tr>
<td>${esc(s.name)}</td>
<td>${s.old_level}${s.new_level}</td>
<td class="${s.xp_delta >= 0 ? "delta-pos" : "delta-neg"}">${s.xp_delta >= 0 ? "+" : ""}${fmt(s.xp_delta)} XP</td>
</tr>`).join("");
const noChanges = `<tr><td colspan='3'>${esc(t("empty.noChanges"))}</td></tr>`;
el.innerHTML = `
<p>${esc(t("history.coinsSummary", {
delta: `${coinDelta >= 0 ? "+" : ""}${fmt(coinDelta)}`,
levelDelta: `${levelDelta >= 0 ? "+" : ""}${levelDelta}`,
}))}</p>
<h4 style="margin-top:16px">${esc(t("history.inventoryChanges", { count: diff.inventory_changes.length }))}</h4>
<table><thead><tr>
<th>${esc(t("inventory.item"))}</th>
<th>${esc(t("inventory.qty"))}</th>
<th>${esc(t("history.delta"))}</th>
</tr></thead>
<tbody>${invRows || noChanges}</tbody></table>
<h4 style="margin-top:16px">${esc(t("history.skillChanges", { count: diff.skill_changes.length }))}</h4>
<table><thead><tr>
<th>${esc(t("skills.skill"))}</th>
<th>${esc(t("skills.level"))}</th>
<th>${esc(t("history.xpDelta"))}</th>
</tr></thead>
<tbody>${skRows || noChanges}</tbody></table>`;
}
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}