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.kills"))}
    +
    +
    +
    +

    ${esc(t("combat.dungeonRuns"))}

    +
    + + + + + + + +
    ${esc(t("combat.entry"))}${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;