From 6c65732eb1dd65a16d78f4c37fb05cd5f1024c8d Mon Sep 17 00:00:00 2001 From: elpatron Date: Fri, 19 Jun 2026 17:08:40 +0200 Subject: [PATCH] Add per-item inventory sparklines with expandable history charts. Co-authored-by: Cursor --- app.py | 7 ++ db.py | 34 ++++++++++ static/app.js | 148 +++++++++++++++++++++++++++++++++++++++-- static/locales/de.json | 5 +- static/locales/en.json | 5 +- static/style.css | 110 ++++++++++++++++++++++++++++++ 6 files changed, 300 insertions(+), 9 deletions(-) diff --git a/app.py b/app.py index 1e9eacd..1b2ccce 100644 --- a/app.py +++ b/app.py @@ -19,6 +19,7 @@ from db import ( get_snapshot, import_save, init_db, + inventory_timeline, list_snapshots, get_connection, timeline, @@ -113,6 +114,12 @@ def api_timeline(viewer_id: str): return jsonify(timeline(db_path=db_path)) +@viewer_bp.route("/api/inventory/timeline") +def api_inventory_timeline(viewer_id: str): + db_path = _resolve_viewer_db(viewer_id) + return jsonify(inventory_timeline(db_path=db_path)) + + @viewer_bp.route("/api/import", methods=["POST"]) @limiter.limit(IMPORT_LIMIT) def api_import(viewer_id: str): diff --git a/db.py b/db.py index 2445f7f..8f9671f 100644 --- a/db.py +++ b/db.py @@ -308,3 +308,37 @@ def timeline(db_path: Path | str = DEFAULT_DB) -> list[dict]: ).fetchall() conn.close() return [dict(r) for r in rows] + + +def inventory_timeline(db_path: Path | str = DEFAULT_DB) -> dict[str, Any]: + """Per-item quantity series aligned to snapshots (oldest → newest).""" + conn = get_connection(db_path) + init_db(conn) + snap_rows = conn.execute( + """ + SELECT id, exported_at FROM snapshots + ORDER BY exported_at ASC, id ASC + """ + ).fetchall() + snapshots = [dict(r) for r in snap_rows] + if not snapshots: + conn.close() + return {"snapshots": [], "series": {}} + + snap_index = {row["id"]: idx for idx, row in enumerate(snapshots)} + inv_rows = conn.execute( + "SELECT snapshot_id, item_key, qty FROM inventory_snapshots" + ).fetchall() + conn.close() + + series: dict[str, list[int]] = {} + n = len(snapshots) + for row in inv_rows: + key = row["item_key"] + if key not in series: + series[key] = [0] * n + idx = snap_index.get(row["snapshot_id"]) + if idx is not None: + series[key][idx] = row["qty"] + + return {"snapshots": snapshots, "series": series} diff --git a/static/app.js b/static/app.js index d3f93ab..5020b50 100644 --- a/static/app.js +++ b/static/app.js @@ -9,6 +9,7 @@ let state = { quests: { tab: "story", filter: "all" }, history: { olderId: null, newerId: null, diff: null }, charts: {}, + inventoryTimeline: null, }; const CATEGORY_ORDER = [ @@ -238,6 +239,7 @@ async function loadData() { return; } state.data = await res.json(); + state.inventoryTimeline = null; renderAll(); } catch (err) { showEmpty(t("empty.loadError", { message: err.message })); @@ -436,8 +438,129 @@ function getFilteredInventoryItems(d, inv) { 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] = []; @@ -450,7 +573,7 @@ function renderInventoryTable(d, inv) { const catLabel = categoryLabel(cat); const header = ` - +