diff --git a/app.py b/app.py
index 73c3c53..b2fa86a 100644
--- a/app.py
+++ b/app.py
@@ -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)
diff --git a/db.py b/db.py
index c59c992..9c515f6 100644
--- a/db.py
+++ b/db.py
@@ -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)
diff --git a/static/app.js b/static/app.js
index 5e23098..fb673fa 100644
--- a/static/app.js
+++ b/static/app.js
@@ -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) {
`;
}
+function renderCombatSparkCell(combatKey, combatType, displayName) {
+ const values = combatSeries(combatType, combatKey);
+ if (!values) {
+ return `
— | `;
+ }
+ const expandKey = combatType === "dungeon" ? "trendExpandRunsFor" : "trendExpandKillsFor";
+ return `
+
+
+ | `;
+}
+
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]) => `${esc(k.replace(/_/g, " "))}${fmt(v)}`).join("");
-
- const dungeons = Object.entries(d.combat.dungeon_runs || {})
- .sort((a, b) => b[1] - a[1])
- .map(([k, v]) => `${esc(k.replace(/_/g, " "))}${esc(t("combat.runs", { count: fmt(v) }))}`).join("");
-
const recent = (d.recent_sessions || [])
.map((s) => `${esc(s.activity_display_name || s.activity_key)}${esc(s.skill_name)}`).join("");
@@ -1579,11 +1636,79 @@ function renderCombat(d) {
const none = `${esc(t("empty.none"))}`;
document.getElementById("tab-combat").innerHTML = `
-
${esc(t("combat.enemyKills"))}
-
${esc(t("combat.dungeonRuns"))}
+
+
${esc(t("combat.enemyKills"))}
+
+
+
+ | ${esc(t("combat.entry"))} |
+ ${esc(t("combat.trend"))} |
+ ${esc(t("combat.kills"))} |
+
+
+
+
+
+
+
${esc(t("combat.dungeonRuns"))}
+
+
+
+ | ${esc(t("combat.entry"))} |
+ ${esc(t("combat.trend"))} |
+ ${esc(t("combat.runsLabel"))} |
+
+
+
+
+
${esc(t("combat.recentActivity"))}
${esc(t("combat.activeSessions"))}
`;
+
+ 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 `
+ | ${esc(name)} |
+ ${showTrend ? renderCombatSparkCell(k, "enemy", name) : ""}
+ ${fmt(v)} |
+
`;
+ }).join("")
+ : `| ${esc(t("empty.none"))} |
`;
+ 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 `
+ | ${esc(name)} |
+ ${showTrend ? renderCombatSparkCell(k, "dungeon", name) : ""}
+ ${esc(t("combat.runs", { count: fmt(v) }))} |
+
`;
+ }).join("")
+ : `| ${esc(t("empty.none"))} |
`;
+ 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();
});
diff --git a/static/locales/de.json b/static/locales/de.json
index 953d0a3..b677b71 100644
--- a/static/locales/de.json
+++ b/static/locales/de.json
@@ -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",
diff --git a/static/locales/en.json b/static/locales/en.json
index 67d538f..639b3ee 100644
--- a/static/locales/en.json
+++ b/static/locales/en.json
@@ -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",
diff --git a/static/style.css b/static/style.css
index b7c14cc..3058a48 100644
--- a/static/style.css
+++ b/static/style.css
@@ -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;