Add per-item inventory sparklines with expandable history charts.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-19 17:08:40 +02:00
parent 810ad67ab6
commit 6c65732eb1
6 changed files with 300 additions and 9 deletions
+7
View File
@@ -19,6 +19,7 @@ from db import (
get_snapshot, get_snapshot,
import_save, import_save,
init_db, init_db,
inventory_timeline,
list_snapshots, list_snapshots,
get_connection, get_connection,
timeline, timeline,
@@ -113,6 +114,12 @@ def api_timeline(viewer_id: str):
return jsonify(timeline(db_path=db_path)) 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"]) @viewer_bp.route("/api/import", methods=["POST"])
@limiter.limit(IMPORT_LIMIT) @limiter.limit(IMPORT_LIMIT)
def api_import(viewer_id: str): def api_import(viewer_id: str):
+34
View File
@@ -308,3 +308,37 @@ def timeline(db_path: Path | str = DEFAULT_DB) -> list[dict]:
).fetchall() ).fetchall()
conn.close() conn.close()
return [dict(r) for r in rows] 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}
+141 -7
View File
@@ -9,6 +9,7 @@ let state = {
quests: { tab: "story", filter: "all" }, quests: { tab: "story", filter: "all" },
history: { olderId: null, newerId: null, diff: null }, history: { olderId: null, newerId: null, diff: null },
charts: {}, charts: {},
inventoryTimeline: null,
}; };
const CATEGORY_ORDER = [ const CATEGORY_ORDER = [
@@ -238,6 +239,7 @@ async function loadData() {
return; return;
} }
state.data = await res.json(); state.data = await res.json();
state.inventoryTimeline = null;
renderAll(); renderAll();
} catch (err) { } catch (err) {
showEmpty(t("empty.loadError", { message: err.message })); showEmpty(t("empty.loadError", { message: err.message }));
@@ -436,8 +438,129 @@ function getFilteredInventoryItems(d, inv) {
return items; 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 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) { function renderInventoryTable(d, inv) {
const items = getFilteredInventoryItems(d, inv); const items = getFilteredInventoryItems(d, inv);
const showTrend = inventoryTrendEnabled();
const colSpan = showTrend ? 4 : 3;
const grouped = {}; const grouped = {};
for (const item of items) { for (const item of items) {
if (!grouped[item.category]) grouped[item.category] = []; if (!grouped[item.category]) grouped[item.category] = [];
@@ -450,7 +573,7 @@ function renderInventoryTable(d, inv) {
const catLabel = categoryLabel(cat); const catLabel = categoryLabel(cat);
const header = ` const header = `
<tr class="inv-group-row" data-group="${esc(cat)}"> <tr class="inv-group-row" data-group="${esc(cat)}">
<td colspan="3"> <td colspan="${colSpan}">
<button type="button" class="inv-group-toggle" data-group="${esc(cat)}" aria-expanded="${expanded}"> <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-title">${esc(catLabel)}</span>
<span class="inv-group-meta">${esc(t("inventory.groupMeta", { count: catItems.length, qty: fmt(totalQty) }))}</span> <span class="inv-group-meta">${esc(t("inventory.groupMeta", { count: catItems.length, qty: fmt(totalQty) }))}</span>
@@ -460,6 +583,7 @@ function renderInventoryTable(d, inv) {
const rows = catItems.map((i) => ` const rows = catItems.map((i) => `
<tr class="inv-item-row ${i.equipped && inv.highlightEquipped ? "item-equipped" : ""} ${expanded ? "" : "collapsed"}" data-group="${esc(cat)}"> <tr class="inv-item-row ${i.equipped && inv.highlightEquipped ? "item-equipped" : ""} ${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>` : ""}</td> <td class="col-name">${esc(i.name)}${i.equipped ? `<span class="equipped-mark" title="${esc(t("inventory.equipped"))}">⚡</span>` : ""}</td>
${showTrend ? renderItemSparkCell(i) : ""}
<td class="col-qty">${fmt(i.qty)}</td> <td class="col-qty">${fmt(i.qty)}</td>
<td class="col-key"><code>${esc(i.key)}</code></td> <td class="col-key"><code>${esc(i.key)}</code></td>
</tr>`).join(""); </tr>`).join("");
@@ -472,17 +596,26 @@ function renderInventoryTable(d, inv) {
return; return;
} }
const trendCol = showTrend
? `<col class="col-trend">`
: "";
const trendHeader = showTrend
? `<th class="col-trend">${esc(t("inventory.trend"))}</th>`
: "";
results.innerHTML = ` results.innerHTML = `
<div class="inv-table-wrap"> <div class="inv-table-wrap">
<table class="inv-table"> <table class="inv-table ${showTrend ? "has-trend" : ""}">
<colgroup> <colgroup>
<col class="col-name"> <col class="col-name">
${trendCol}
<col class="col-qty"> <col class="col-qty">
<col class="col-key"> <col class="col-key">
</colgroup> </colgroup>
<thead> <thead>
<tr> <tr>
<th class="col-name">${esc(t("inventory.item"))}</th> <th class="col-name">${esc(t("inventory.item"))}</th>
${trendHeader}
<th class="col-qty">${esc(t("inventory.qty"))}</th> <th class="col-qty">${esc(t("inventory.qty"))}</th>
<th class="col-key">${esc(t("inventory.id"))}</th> <th class="col-key">${esc(t("inventory.id"))}</th>
</tr> </tr>
@@ -503,6 +636,7 @@ function renderInventoryTable(d, inv) {
}); });
}); });
}); });
bindInventorySparklines(results);
} }
function renderInventoryChips(d, inv) { function renderInventoryChips(d, inv) {
@@ -518,7 +652,7 @@ function renderInventoryChips(d, inv) {
if (inv.categories.has(cat)) inv.categories.delete(cat); if (inv.categories.has(cat)) inv.categories.delete(cat);
else inv.categories.add(cat); else inv.categories.add(cat);
renderInventoryChips(d, inv); renderInventoryChips(d, inv);
renderInventoryTable(d, inv); ensureInventoryTimeline().then(() => renderInventoryTable(d, inv));
}); });
}); });
} }
@@ -546,15 +680,15 @@ function renderInventory(d) {
document.getElementById("inv-search").addEventListener("input", (e) => { document.getElementById("inv-search").addEventListener("input", (e) => {
state.inventory.search = e.target.value; state.inventory.search = e.target.value;
renderInventoryTable(state.data, state.inventory); ensureInventoryTimeline().then(() => renderInventoryTable(state.data, state.inventory));
}); });
document.getElementById("inv-sort").addEventListener("change", (e) => { document.getElementById("inv-sort").addEventListener("change", (e) => {
state.inventory.sort = e.target.value; state.inventory.sort = e.target.value;
renderInventoryTable(state.data, state.inventory); ensureInventoryTimeline().then(() => renderInventoryTable(state.data, state.inventory));
}); });
document.getElementById("inv-equipped").addEventListener("change", (e) => { document.getElementById("inv-equipped").addEventListener("change", (e) => {
state.inventory.highlightEquipped = e.target.checked; state.inventory.highlightEquipped = e.target.checked;
renderInventoryTable(state.data, state.inventory); ensureInventoryTimeline().then(() => renderInventoryTable(state.data, state.inventory));
}); });
} }
@@ -568,7 +702,7 @@ function renderInventory(d) {
document.getElementById("inv-sort").value = inv.sort; document.getElementById("inv-sort").value = inv.sort;
document.getElementById("inv-equipped").checked = inv.highlightEquipped; document.getElementById("inv-equipped").checked = inv.highlightEquipped;
renderInventoryChips(d, inv); renderInventoryChips(d, inv);
renderInventoryTable(d, inv); ensureInventoryTimeline().then(() => renderInventoryTable(d, inv));
} }
function renderEquipment(d) { function renderEquipment(d) {
+4 -1
View File
@@ -116,7 +116,10 @@
"qty": "Menge", "qty": "Menge",
"id": "ID", "id": "ID",
"equipped": "Ausgerüstet", "equipped": "Ausgerüstet",
"groupMeta": "{count} Items · {qty} Stk." "groupMeta": "{count} Items · {qty} Stk.",
"trend": "Verlauf",
"trendExpand": "Klicken für großes Diagramm",
"trendExpandFor": "Mengenverlauf für {name}"
}, },
"equipment": { "equipment": {
"title": "Ausrüstung" "title": "Ausrüstung"
+4 -1
View File
@@ -116,7 +116,10 @@
"qty": "Qty", "qty": "Qty",
"id": "ID", "id": "ID",
"equipped": "Equipped", "equipped": "Equipped",
"groupMeta": "{count} items · {qty} pcs" "groupMeta": "{count} items · {qty} pcs",
"trend": "Trend",
"trendExpand": "Click to enlarge chart",
"trendExpandFor": "Quantity history for {name}"
}, },
"equipment": { "equipment": {
"title": "Equipment" "title": "Equipment"
+110
View File
@@ -383,6 +383,11 @@ tr:hover td { background: var(--bg-hover); }
.inv-table col.col-qty { width: 6.25rem; } .inv-table col.col-qty { width: 6.25rem; }
.inv-table col.col-key { width: 34%; } .inv-table col.col-key { width: 34%; }
.inv-table.has-trend col.col-name { width: 38%; }
.inv-table.has-trend col.col-trend { width: 5.5rem; }
.inv-table.has-trend col.col-qty { width: 5.5rem; }
.inv-table.has-trend col.col-key { width: 30%; }
.inv-table thead th { .inv-table thead th {
position: sticky; position: sticky;
top: 0; top: 0;
@@ -451,6 +456,111 @@ tr:hover td { background: var(--bg-hover); }
color: var(--accent); color: var(--accent);
} }
.inv-table th.col-trend,
.inv-table td.col-trend {
padding-left: 8px;
padding-right: 8px;
text-align: center;
width: 5.5rem;
}
.inv-spark-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 28px;
padding: 2px 4px;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: var(--accent);
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.inv-spark-btn:hover {
background: var(--bg-hover);
border-color: var(--border);
}
.inv-spark-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.inv-spark-svg {
display: block;
pointer-events: none;
}
.inv-spark-empty {
color: var(--text-muted);
font-size: 0.85rem;
}
body.inv-chart-modal-open {
overflow: hidden;
}
.inv-chart-modal {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.inv-chart-modal[hidden] {
display: none;
}
.inv-chart-modal-backdrop {
position: absolute;
inset: 0;
background: rgba(8, 10, 16, 0.72);
}
.inv-chart-modal-panel {
position: relative;
z-index: 1;
width: min(720px, 100%);
margin: 0;
padding: 20px 20px 16px;
}
.inv-chart-modal-panel h3 {
margin: 0 32px 12px 0;
font-size: 1.05rem;
}
.inv-chart-modal-close {
position: absolute;
top: 12px;
right: 12px;
width: 32px;
height: 32px;
border: none;
border-radius: 8px;
background: transparent;
color: var(--text-muted);
font-size: 1.4rem;
line-height: 1;
cursor: pointer;
}
.inv-chart-modal-close:hover {
background: var(--bg-hover);
color: var(--text);
}
.chart-wrap-modal {
height: 280px;
margin-top: 0;
}
.inv-group-row td { .inv-group-row td {
padding: 0; padding: 0;
border-bottom: none; border-bottom: none;