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