Add combat trend sparklines for enemy kills and dungeon runs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-22 14:12:18 +02:00
parent eb7dbea2ee
commit be7fa072f7
6 changed files with 215 additions and 12 deletions
+7
View File
@@ -26,6 +26,7 @@ from db import (
goals_overview,
import_save,
init_db,
combat_timeline,
inventory_timeline,
list_goal_groups,
list_goals_structured,
@@ -188,6 +189,12 @@ def api_skill_timeline(viewer_id: str):
return jsonify(skill_timeline(db_path=db_path))
@viewer_bp.route("/api/combat/timeline")
def api_combat_timeline(viewer_id: str):
db_path = _resolve_viewer_db(viewer_id)
return jsonify(combat_timeline(db_path=db_path))
@viewer_bp.route("/api/goals/overview")
def api_goals_overview(viewer_id: str):
db_path = _resolve_viewer_db(viewer_id)
+54
View File
@@ -1083,6 +1083,60 @@ def skill_timeline(db_path: Path | str = DEFAULT_DB) -> dict[str, Any]:
return {"snapshots": snapshots, "series": series}
def _forward_fill_positive(series: dict[str, list[int]]) -> None:
for key in series:
last = 0
for i in range(len(series[key])):
if series[key][i] > 0:
last = series[key][i]
elif last > 0:
series[key][i] = last
def combat_timeline(db_path: Path | str = DEFAULT_DB) -> dict[str, Any]:
"""Enemy-kill and dungeon-run series aligned to snapshots (oldest → newest)."""
conn = get_connection(db_path)
init_db(conn)
snap_rows = conn.execute(
"""
SELECT id, imported_at, exported_at, raw_json FROM snapshots
ORDER BY exported_at ASC, id ASC
"""
).fetchall()
conn.close()
snapshots = [
{"id": r["id"], "imported_at": r["imported_at"], "exported_at": r["exported_at"]}
for r in snap_rows
]
if not snapshots:
return {"snapshots": [], "enemy_kills": {}, "dungeon_runs": {}}
n = len(snapshots)
enemy_kills: dict[str, list[int]] = {}
dungeon_runs: dict[str, list[int]] = {}
for idx, row in enumerate(snap_rows):
try:
data = json.loads(row["raw_json"])
except (json.JSONDecodeError, TypeError):
continue
combat = data.get("combat") or {}
for key, qty in (combat.get("enemy_kills") or {}).items():
if key not in enemy_kills:
enemy_kills[key] = [0] * n
enemy_kills[key][idx] = int(qty) if qty else 0
for key, qty in (combat.get("dungeon_runs") or {}).items():
if key not in dungeon_runs:
dungeon_runs[key] = [0] * n
dungeon_runs[key][idx] = int(qty) if qty else 0
_forward_fill_positive(enemy_kills)
_forward_fill_positive(dungeon_runs)
return {"snapshots": snapshots, "enemy_kills": enemy_kills, "dungeon_runs": dungeon_runs}
def delete_snapshot(snapshot_id: int, db_path: Path | str = DEFAULT_DB) -> bool:
conn = get_connection(db_path)
init_db(conn)
+137 -11
View File
@@ -14,6 +14,7 @@ let state = {
charts: {},
inventoryTimeline: null,
skillTimeline: null,
combatTimeline: null,
lastImportChanges: null,
globalSearch: "",
};
@@ -202,6 +203,7 @@ function setupViewerDbImport() {
state.lastImportChanges = null;
state.inventoryTimeline = null;
state.skillTimeline = null;
state.combatTimeline = null;
await loadData();
alert(t("viewerDb.importSuccess", {
snapshots: result.snapshots || 0,
@@ -422,6 +424,7 @@ async function loadData() {
state.data = await res.json();
state.inventoryTimeline = null;
state.skillTimeline = null;
state.combatTimeline = null;
renderAll();
} catch (err) {
showEmpty(t("empty.loadError", { message: err.message }));
@@ -752,6 +755,33 @@ function skillLevelSeries(skillKey) {
return values;
}
async function ensureCombatTimeline() {
if (state.combatTimeline) return state.combatTimeline;
try {
const res = await fetch(`${apiBase()}/combat/timeline`);
state.combatTimeline = res.ok
? await res.json()
: { snapshots: [], enemy_kills: {}, dungeon_runs: {} };
} catch {
state.combatTimeline = { snapshots: [], enemy_kills: {}, dungeon_runs: {} };
}
return state.combatTimeline;
}
function combatTrendEnabled() {
return (state.combatTimeline?.snapshots?.length || 0) >= 2;
}
function combatSeries(combatType, key) {
const tl = state.combatTimeline;
if (!tl) return null;
const bucket = combatType === "dungeon" ? tl.dungeon_runs : tl.enemy_kills;
const values = bucket?.[key];
if (!values || values.length < 2) return null;
if (values.filter((v) => v > 0).length < 2) return null;
return values;
}
function sparklineSvg(values, width = 72, height = 24) {
const max = Math.max(...values);
const min = Math.min(...values);
@@ -790,6 +820,20 @@ function renderSkillSparkCell(skill) {
</td>`;
}
function renderCombatSparkCell(combatKey, combatType, displayName) {
const values = combatSeries(combatType, combatKey);
if (!values) {
return `<td class="col-trend"><span class="inv-spark-empty">—</span></td>`;
}
const expandKey = combatType === "dungeon" ? "trendExpandRunsFor" : "trendExpandKillsFor";
return `
<td class="col-trend">
<button type="button" class="inv-spark-btn" data-combat-key="${esc(combatKey)}" data-combat-type="${esc(combatType)}" data-combat-name="${esc(displayName)}" title="${esc(t("combat.trendExpand"))}" aria-label="${esc(t(`combat.${expandKey}`, { name: displayName }))}">
${sparklineSvg(values)}
</button>
</td>`;
}
function setupTrendChartModal() {
if (document.getElementById("inv-chart-modal")) return;
@@ -822,7 +866,13 @@ function openTrendChartModal(title, snapshots, values, datasetLabel, color = "#4
document.body.classList.add("inv-chart-modal-open");
destroyChart("trendModal");
const bg = color === "#6c8cff" ? "rgba(108, 140, 255, 0.12)" : "rgba(74, 222, 128, 0.12)";
const bgMap = {
"#6c8cff": "rgba(108, 140, 255, 0.12)",
"#4ade80": "rgba(74, 222, 128, 0.12)",
"#f87171": "rgba(248, 113, 113, 0.12)",
"#fb923c": "rgba(251, 146, 60, 0.12)",
};
const bg = bgMap[color] || "rgba(74, 222, 128, 0.12)";
const points = skillSeries
? skillTimelineChartPoints(snapshots, values)
: timelineChartPoints(snapshots, values);
@@ -857,6 +907,16 @@ function openSkillChartModal(skillKey, skillName) {
openTrendChartModal(skillName, tl.snapshots, values, t("skills.level"), "#4ade80", true);
}
function openCombatChartModal(combatType, combatKey, combatName) {
const values = combatSeries(combatType, combatKey);
const tl = state.combatTimeline;
if (!values || !tl) return;
const isDungeon = combatType === "dungeon";
const label = isDungeon ? t("combat.runsLabel") : t("combat.kills");
const color = isDungeon ? "#fb923c" : "#f87171";
openTrendChartModal(combatName, tl.snapshots, values, label, color, true);
}
function closeTrendChartModal() {
const modal = document.getElementById("inv-chart-modal");
if (!modal || modal.hidden) return;
@@ -877,6 +937,11 @@ function bindSparklines(container) {
openSkillChartModal(btn.dataset.skillKey, btn.dataset.skillName);
});
});
container.querySelectorAll(".inv-spark-btn[data-combat-key]").forEach((btn) => {
btn.addEventListener("click", () => {
openCombatChartModal(btn.dataset.combatType, btn.dataset.combatKey, btn.dataset.combatName);
});
});
}
function renderInventoryTable(d, inv) {
@@ -1562,14 +1627,6 @@ function renderQuests(d) {
}
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("");
@@ -1579,11 +1636,79 @@ function renderCombat(d) {
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.enemyKills"))}</h3>
<div class="table-wrap" id="combat-kills-wrap">
<table class="combat-table" id="combat-kills-table">
<thead><tr>
<th>${esc(t("combat.entry"))}</th>
<th class="col-trend combat-trend-col" hidden>${esc(t("combat.trend"))}</th>
<th>${esc(t("combat.kills"))}</th>
</tr></thead>
<tbody id="combat-kills-tbody"></tbody>
</table>
</div>
</div>
<div class="card">
<h3>${esc(t("combat.dungeonRuns"))}</h3>
<div class="table-wrap" id="combat-dungeons-wrap">
<table class="combat-table" id="combat-dungeons-table">
<thead><tr>
<th>${esc(t("combat.entry"))}</th>
<th class="col-trend combat-trend-col" hidden>${esc(t("combat.trend"))}</th>
<th>${esc(t("combat.runsLabel"))}</th>
</tr></thead>
<tbody id="combat-dungeons-tbody"></tbody>
</table>
</div>
</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>`;
ensureCombatTimeline().then(() => renderCombatBody(d));
}
function renderCombatBody(d) {
const showTrend = combatTrendEnabled();
document.querySelectorAll("#tab-combat .combat-trend-col").forEach((el) => {
el.hidden = !showTrend;
});
const kills = Object.entries(d.combat.enemy_kills || {})
.sort((a, b) => b[1] - a[1]);
const dungeons = Object.entries(d.combat.dungeon_runs || {})
.sort((a, b) => b[1] - a[1]);
const killsTbody = document.getElementById("combat-kills-tbody");
if (killsTbody) {
killsTbody.innerHTML = kills.length
? kills.map(([k, v]) => {
const name = k.replace(/_/g, " ");
return `<tr>
<td>${esc(name)}</td>
${showTrend ? renderCombatSparkCell(k, "enemy", name) : ""}
<td>${fmt(v)}</td>
</tr>`;
}).join("")
: `<tr><td colspan="${showTrend ? 3 : 2}">${esc(t("empty.none"))}</td></tr>`;
bindSparklines(killsTbody);
}
const dungeonsTbody = document.getElementById("combat-dungeons-tbody");
if (dungeonsTbody) {
dungeonsTbody.innerHTML = dungeons.length
? dungeons.map(([k, v]) => {
const name = k.replace(/_/g, " ");
return `<tr>
<td>${esc(name)}</td>
${showTrend ? renderCombatSparkCell(k, "dungeon", name) : ""}
<td>${esc(t("combat.runs", { count: fmt(v) }))}</td>
</tr>`;
}).join("")
: `<tr><td colspan="${showTrend ? 3 : 2}">${esc(t("empty.none"))}</td></tr>`;
bindSparklines(dungeonsTbody);
}
}
async function ensureSkillTimeline() {
@@ -1690,6 +1815,7 @@ async function loadHistoryTab() {
trackEvent("Snapshot Delete");
state.inventoryTimeline = null;
state.skillTimeline = null;
state.combatTimeline = null;
await loadData();
loadHistoryTab();
});
+7
View File
@@ -161,6 +161,13 @@
"enemyKills": "Gegner-Kills",
"dungeonRuns": "Dungeon-Läufe",
"runs": "{count} Läufe",
"kills": "Kills",
"runsLabel": "Läufe",
"entry": "Name",
"trend": "Verlauf",
"trendExpand": "Klicken für großes Diagramm",
"trendExpandKillsFor": "Kill-Verlauf für {name}",
"trendExpandRunsFor": "Lauf-Verlauf für {name}",
"recentActivity": "Letzte Aktivität",
"activeSessions": "Aktive Sessions",
"sessionDone": "fertig",
+7
View File
@@ -161,6 +161,13 @@
"enemyKills": "Enemy kills",
"dungeonRuns": "Dungeon runs",
"runs": "{count} runs",
"kills": "Kills",
"runsLabel": "Runs",
"entry": "Name",
"trend": "Trend",
"trendExpand": "Click to enlarge chart",
"trendExpandKillsFor": "Kill history for {name}",
"trendExpandRunsFor": "Run history for {name}",
"recentActivity": "Recent activity",
"activeSessions": "Active sessions",
"sessionDone": "done",
+3 -1
View File
@@ -459,7 +459,9 @@ tr:hover td { background: var(--bg-hover); }
.inv-table th.col-trend,
.inv-table td.col-trend,
.skills-table th.col-trend,
.skills-table td.col-trend {
.skills-table td.col-trend,
.combat-table th.col-trend,
.combat-table td.col-trend {
padding-left: 8px;
padding-right: 8px;
text-align: center;