diff --git a/app.py b/app.py index dd59ca6..083008c 100644 --- a/app.py +++ b/app.py @@ -9,18 +9,21 @@ import sys import webbrowser from pathlib import Path -from flask import Blueprint, Flask, abort, jsonify, render_template, request +from flask import Blueprint, Flask, abort, jsonify, render_template, request, send_file from werkzeug.utils import secure_filename from db import ( DEFAULT_DB, create_goal, create_goal_group, + create_skill_goal, delete_goal, delete_goal_group, + delete_snapshot, diff_snapshots, get_latest_snapshot, get_snapshot, + goals_overview, import_save, init_db, inventory_timeline, @@ -28,6 +31,8 @@ from db import ( list_goals_structured, list_snapshots, get_connection, + rename_goal_group, + skill_timeline, timeline, ) from security import ( @@ -106,6 +111,14 @@ def api_snapshots(viewer_id: str): return jsonify(list_snapshots(db_path=db_path)) +@viewer_bp.route("/api/snapshots/", methods=["DELETE"]) +def api_delete_snapshot(viewer_id: str, snapshot_id: int): + db_path = _resolve_viewer_db(viewer_id) + if not delete_snapshot(snapshot_id, db_path=db_path): + return jsonify({"error": "Snapshot not found"}), 404 + return jsonify({"deleted": True}) + + @viewer_bp.route("/api/snapshots//diff/") def api_diff(viewer_id: str, older_id: int, newer_id: int): db_path = _resolve_viewer_db(viewer_id) @@ -127,6 +140,29 @@ def api_inventory_timeline(viewer_id: str): return jsonify(inventory_timeline(db_path=db_path)) +@viewer_bp.route("/api/skills/timeline") +def api_skill_timeline(viewer_id: str): + db_path = _resolve_viewer_db(viewer_id) + return jsonify(skill_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) + return jsonify(goals_overview(db_path=db_path)) + + +@viewer_bp.route("/api/export") +def api_export_viewer(viewer_id: str): + db_path = _resolve_viewer_db(viewer_id) + return send_file( + db_path, + as_attachment=True, + download_name=f"idle-fantasy-viewer-{viewer_id}.db", + mimetype="application/octet-stream", + ) + + @viewer_bp.route("/api/goal-groups") def api_goal_groups(viewer_id: str): db_path = _resolve_viewer_db(viewer_id) @@ -155,6 +191,21 @@ def api_delete_goal_group(viewer_id: str, group_id: int): return jsonify({"deleted": True}) +@viewer_bp.route("/api/goal-groups/", methods=["PATCH"]) +def api_rename_goal_group(viewer_id: str, group_id: int): + db_path = _resolve_viewer_db(viewer_id) + body = request.get_json(silent=True) or {} + name = (body.get("name") or "").strip() + if not name: + return jsonify({"error": "Group name is required"}), 400 + try: + if not rename_goal_group(group_id, name, db_path=db_path): + return jsonify({"error": "Goal group not found"}), 404 + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + return jsonify({"id": group_id, "name": name}) + + @viewer_bp.route("/api/goals") def api_goals(viewer_id: str): db_path = _resolve_viewer_db(viewer_id) @@ -165,16 +216,10 @@ def api_goals(viewer_id: str): def api_create_goal(viewer_id: str): db_path = _resolve_viewer_db(viewer_id) body = request.get_json(silent=True) or {} - item_key = (body.get("item_key") or "").strip() - target_qty = body.get("target_qty") + goal_type = (body.get("goal_type") or "item").strip().lower() + mode = (body.get("mode") or "absolute").strip().lower() group_id = body.get("group_id") - if not item_key: - return jsonify({"error": "item_key is required"}), 400 - try: - target_qty = int(target_qty) - except (TypeError, ValueError): - return jsonify({"error": "target_qty must be a positive integer"}), 400 if group_id is not None: try: group_id = int(group_id) @@ -185,7 +230,30 @@ def api_create_goal(viewer_id: str): return jsonify({"error": "No snapshots imported yet"}), 404 try: - goal = create_goal(item_key, target_qty, group_id=group_id, db_path=db_path) + if goal_type == "skill": + skill_key = (body.get("skill_key") or "").strip() + target_level = body.get("target_level", body.get("target_qty")) + if not skill_key: + return jsonify({"error": "skill_key is required"}), 400 + try: + target_level = int(target_level) + except (TypeError, ValueError): + return jsonify({"error": "target_level must be a positive integer"}), 400 + goal = create_skill_goal( + skill_key, target_level, group_id=group_id, mode=mode, db_path=db_path + ) + else: + item_key = (body.get("item_key") or "").strip() + target_qty = body.get("target_qty") + if not item_key: + return jsonify({"error": "item_key is required"}), 400 + try: + target_qty = int(target_qty) + except (TypeError, ValueError): + return jsonify({"error": "target_qty must be a positive integer"}), 400 + goal = create_goal( + item_key, target_qty, group_id=group_id, mode=mode, db_path=db_path + ) except ValueError as exc: msg = str(exc) status = 404 if "not found" in msg.lower() else 409 if "already exists" in msg.lower() else 400 diff --git a/db.py b/db.py index 1766315..d3fbd29 100644 --- a/db.py +++ b/db.py @@ -12,6 +12,7 @@ from typing import Any from parser import SaveParseError, normalize_save, load_save DEFAULT_DB = Path(__file__).parent / "data" / "history.db" +SKILL_GOAL_PREFIX = "__skill__:" def _utc_now_iso() -> str: @@ -65,17 +66,32 @@ def init_db(conn: sqlite3.Connection) -> None: CREATE TABLE IF NOT EXISTS goals ( id INTEGER PRIMARY KEY AUTOINCREMENT, group_id INTEGER REFERENCES goal_groups(id) ON DELETE SET NULL, + goal_type TEXT NOT NULL DEFAULT 'item', + mode TEXT NOT NULL DEFAULT 'absolute', item_key TEXT NOT NULL UNIQUE, item_name TEXT NOT NULL, category TEXT NOT NULL, target_qty INTEGER NOT NULL, + baseline_qty INTEGER, created_at TEXT NOT NULL, completed_at TEXT ); """) + _migrate_schema(conn) conn.commit() +def _migrate_schema(conn: sqlite3.Connection) -> None: + """Add columns introduced after initial goals release.""" + cols = {row[1] for row in conn.execute("PRAGMA table_info(goals)")} + if "goal_type" not in cols: + conn.execute("ALTER TABLE goals ADD COLUMN goal_type TEXT NOT NULL DEFAULT 'item'") + if "mode" not in cols: + conn.execute("ALTER TABLE goals ADD COLUMN mode TEXT NOT NULL DEFAULT 'absolute'") + if "baseline_qty" not in cols: + conn.execute("ALTER TABLE goals ADD COLUMN baseline_qty INTEGER") + + def file_hash(path: Path) -> str: h = hashlib.sha256() with path.open("rb") as f: @@ -159,6 +175,7 @@ def import_save( conn.commit() goals_result = check_goals_after_import(snapshot_id, conn=conn, db_path=db_path) + import_changes = summarize_import_changes(snapshot_id, conn=conn) if own_conn: conn.close() @@ -168,6 +185,7 @@ def import_save( "snapshot_id": snapshot_id, "import_report": import_report, "import_summary": meta.get("import_summary"), + "import_changes": import_changes, **goals_result, } @@ -377,19 +395,122 @@ def _inventory_qty_for_snapshot(conn: sqlite3.Connection, snapshot_id: int, item return row["qty"] if row else 0 -def _goal_row_to_dict(row: sqlite3.Row, current_qty: int) -> dict[str, Any]: +def _skill_goal_storage_key(skill_key: str) -> str: + return f"{SKILL_GOAL_PREFIX}{skill_key}" + + +def _skill_key_from_storage(storage_key: str) -> str | None: + if storage_key.startswith(SKILL_GOAL_PREFIX): + return storage_key[len(SKILL_GOAL_PREFIX):] + return None + + +def _skill_level_for_snapshot(conn: sqlite3.Connection, snapshot_id: int, skill_key: str) -> int: + row = conn.execute( + "SELECT level FROM skill_snapshots WHERE snapshot_id = ? AND skill_key = ?", + (snapshot_id, skill_key), + ).fetchone() + return row["level"] if row else 0 + + +def _goal_is_complete( + goal_type: str, + mode: str, + target: int, + baseline: int | None, + *, + current_item_qty: int = 0, + current_skill_level: int = 0, +) -> bool: + base = baseline or 0 + if goal_type == "skill": + current = current_skill_level + else: + current = current_item_qty + if mode == "relative": + return max(0, current - base) >= target + return current >= target + + +def _goal_progress_values( + row: sqlite3.Row, + *, + current_item_qty: int = 0, + current_skill_level: int = 0, +) -> tuple[int, int, int, bool]: + goal_type = row["goal_type"] if row["goal_type"] else "item" + mode = row["mode"] if row["mode"] else "absolute" target = row["target_qty"] - progress_pct = min(100, int(current_qty / target * 100)) if target > 0 else 0 + baseline = row["baseline_qty"] if row["baseline_qty"] is not None else 0 + + if goal_type == "skill": + current = current_skill_level + if mode == "relative": + progress_val = max(0, current - baseline) + completed = progress_val >= target + display_current = progress_val + display_target = target + else: + progress_val = current + completed = current >= target + display_current = current + display_target = target + else: + current = current_item_qty + if mode == "relative": + progress_val = max(0, current - baseline) + completed = progress_val >= target + display_current = progress_val + display_target = target + else: + progress_val = current + completed = current >= target + display_current = current + display_target = target + + progress_pct = min(100, int(progress_val / target * 100)) if target > 0 else (100 if completed else 0) + return display_current, display_target, progress_pct, completed + + +def _goal_row_to_dict( + row: sqlite3.Row, + *, + current_item_qty: int = 0, + current_skill_level: int = 0, + rate_per_snapshot: float | None = None, +) -> dict[str, Any]: + display_current, display_target, progress_pct, completed = _goal_progress_values( + row, + current_item_qty=current_item_qty, + current_skill_level=current_skill_level, + ) + goal_type = row["goal_type"] if row["goal_type"] else "item" + mode = row["mode"] if row["mode"] else "absolute" + skill_key = _skill_key_from_storage(row["item_key"]) if goal_type == "skill" else None + missing = max(0, display_target - display_current) if not row["completed_at"] else 0 + eta_snapshots = None + if missing > 0 and rate_per_snapshot and rate_per_snapshot > 0: + eta_snapshots = max(1, int((missing + rate_per_snapshot - 1) // rate_per_snapshot)) + return { "id": row["id"], "group_id": row["group_id"], "group_name": row["group_name"], - "item_key": row["item_key"], + "goal_type": goal_type, + "mode": mode, + "item_key": row["item_key"] if goal_type == "item" else skill_key, + "skill_key": skill_key, "item_name": row["item_name"], "category": row["category"], - "target_qty": target, - "current_qty": current_qty, + "target_qty": row["target_qty"], + "baseline_qty": row["baseline_qty"], + "current_qty": display_current, + "target_display": display_target, + "raw_current_qty": current_item_qty, + "raw_skill_level": current_skill_level, + "missing_qty": missing, "progress_pct": progress_pct, + "eta_snapshots": eta_snapshots, "completed_at": row["completed_at"], "created_at": row["created_at"], } @@ -455,20 +576,81 @@ def delete_goal_group(group_id: int, db_path: Path | str = DEFAULT_DB) -> bool: return True +def rename_goal_group(group_id: int, name: str, db_path: Path | str = DEFAULT_DB) -> bool: + name = name.strip() + if not name: + raise ValueError("Group name is required") + conn = get_connection(db_path) + init_db(conn) + cur = conn.execute( + "UPDATE goal_groups SET name = ? WHERE id = ?", + (name, group_id), + ) + conn.commit() + updated = cur.rowcount > 0 + conn.close() + return updated + + +def _goal_item_rates(conn: sqlite3.Connection) -> dict[str, float]: + """Average qty gain per snapshot for item goals (last segments).""" + snap_rows = conn.execute( + "SELECT id FROM snapshots ORDER BY exported_at ASC, id ASC" + ).fetchall() + if len(snap_rows) < 2: + return {} + snap_index = {row["id"]: idx for idx, row in enumerate(snap_rows)} + inv_rows = conn.execute( + "SELECT snapshot_id, item_key, qty FROM inventory_snapshots" + ).fetchall() + series: dict[str, list[int]] = {} + n = len(snap_rows) + 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"] + rates: dict[str, float] = {} + for key, values in series.items(): + deltas = [values[i] - values[i - 1] for i in range(1, len(values))] + positive = [d for d in deltas if d > 0] + if positive: + rates[key] = sum(positive) / len(positive) + return rates + + def _fetch_goals_with_qty(conn: sqlite3.Connection, snapshot_id: int | None) -> list[dict[str, Any]]: rows = conn.execute( """ - SELECT go.id, go.group_id, gg.name AS group_name, go.item_key, go.item_name, - go.category, go.target_qty, go.created_at, go.completed_at + SELECT go.id, go.group_id, gg.name AS group_name, go.goal_type, go.mode, + go.item_key, go.item_name, go.category, go.target_qty, go.baseline_qty, + go.created_at, go.completed_at FROM goals go LEFT JOIN goal_groups gg ON gg.id = go.group_id ORDER BY go.created_at ASC, go.id ASC """ ).fetchall() + rates = _goal_item_rates(conn) if snapshot_id else {} goals = [] for row in rows: - current_qty = _inventory_qty_for_snapshot(conn, snapshot_id, row["item_key"]) if snapshot_id else 0 - goals.append(_goal_row_to_dict(row, current_qty)) + goal_type = row["goal_type"] if row["goal_type"] else "item" + rate = None + if goal_type == "item": + current_item_qty = _inventory_qty_for_snapshot(conn, snapshot_id, row["item_key"]) if snapshot_id else 0 + current_skill_level = 0 + rate = rates.get(row["item_key"]) + else: + skill_key = _skill_key_from_storage(row["item_key"]) or "" + current_item_qty = 0 + current_skill_level = _skill_level_for_snapshot(conn, snapshot_id, skill_key) if snapshot_id else 0 + goals.append(_goal_row_to_dict( + row, + current_item_qty=current_item_qty, + current_skill_level=current_skill_level, + rate_per_snapshot=rate, + )) return goals @@ -520,6 +702,16 @@ def list_goals_structured(db_path: Path | str = DEFAULT_DB) -> dict[str, Any]: } +def goals_overview(db_path: Path | str = DEFAULT_DB) -> dict[str, int]: + structured = list_goals_structured(db_path) + all_goals = structured["ungrouped"] + [ + g for group in structured["groups"] for g in group.get("goals", []) + ] + open_count = sum(1 for g in all_goals if not g["completed_at"]) + done_count = sum(1 for g in all_goals if g["completed_at"]) + return {"open": open_count, "completed": done_count, "total": len(all_goals)} + + def _resolve_item_from_latest( conn: sqlite3.Connection, item_key: str ) -> dict[str, str] | None: @@ -546,10 +738,13 @@ def create_goal( item_key: str, target_qty: int, group_id: int | None = None, + mode: str = "absolute", db_path: Path | str = DEFAULT_DB, ) -> dict[str, Any]: if target_qty <= 0: raise ValueError("Target quantity must be positive") + if mode not in ("absolute", "relative"): + raise ValueError("Invalid goal mode") conn = get_connection(db_path) init_db(conn) @@ -570,22 +765,28 @@ def create_goal( conn.close() raise ValueError("Item not found in current inventory") + baseline = item["qty"] if mode == "relative" else None now = _utc_now_iso() - completed_at = now if item["qty"] >= target_qty else None + completed = _goal_is_complete( + "item", mode, target_qty, baseline, current_item_qty=item["qty"] + ) + completed_at = now if completed else None cur = conn.execute( """ - INSERT INTO goals (group_id, item_key, item_name, category, target_qty, created_at, completed_at) - VALUES (?, ?, ?, ?, ?, ?, ?) + INSERT INTO goals + (group_id, goal_type, mode, item_key, item_name, category, target_qty, baseline_qty, created_at, completed_at) + VALUES (?, 'item', ?, ?, ?, ?, ?, ?, ?, ?) """, - (group_id, item_key, item["item_name"], item["category"], target_qty, now, completed_at), + (group_id, mode, item_key, item["item_name"], item["category"], target_qty, baseline, now, completed_at), ) goal_id = cur.lastrowid conn.commit() row = conn.execute( """ - SELECT go.id, go.group_id, gg.name AS group_name, go.item_key, go.item_name, - go.category, go.target_qty, go.created_at, go.completed_at + SELECT go.id, go.group_id, gg.name AS group_name, go.goal_type, go.mode, + go.item_key, go.item_name, go.category, go.target_qty, go.baseline_qty, + go.created_at, go.completed_at FROM goals go LEFT JOIN goal_groups gg ON gg.id = go.group_id WHERE go.id = ? @@ -593,7 +794,80 @@ def create_goal( (goal_id,), ).fetchone() conn.close() - return _goal_row_to_dict(row, item["qty"]) + return _goal_row_to_dict(row, current_item_qty=item["qty"]) + + +def create_skill_goal( + skill_key: str, + target_level: int, + group_id: int | None = None, + mode: str = "absolute", + db_path: Path | str = DEFAULT_DB, +) -> dict[str, Any]: + if target_level <= 0: + raise ValueError("Target level must be positive") + if mode not in ("absolute", "relative"): + raise ValueError("Invalid goal mode") + + conn = get_connection(db_path) + init_db(conn) + storage_key = _skill_goal_storage_key(skill_key) + + existing = conn.execute("SELECT id FROM goals WHERE item_key = ?", (storage_key,)).fetchone() + if existing: + conn.close() + raise ValueError("A goal for this skill already exists") + + if group_id is not None: + group = conn.execute("SELECT id FROM goal_groups WHERE id = ?", (group_id,)).fetchone() + if not group: + conn.close() + raise ValueError("Goal group not found") + + snapshot_id = _latest_snapshot_id(conn) + if not snapshot_id: + conn.close() + raise ValueError("No snapshots imported yet") + + current_level = _skill_level_for_snapshot(conn, snapshot_id, skill_key) + latest = get_latest_snapshot(conn=conn) + skill_name = skill_key.replace("_", " ").title() + if latest: + for sk in latest.get("skills", []): + if sk["key"] == skill_key: + skill_name = sk["name"] + break + + baseline = current_level if mode == "relative" else None + now = _utc_now_iso() + completed = _goal_is_complete( + "skill", mode, target_level, baseline, current_skill_level=current_level + ) + completed_at = now if completed else None + cur = conn.execute( + """ + INSERT INTO goals + (group_id, goal_type, mode, item_key, item_name, category, target_qty, baseline_qty, created_at, completed_at) + VALUES (?, 'skill', ?, ?, ?, 'Skill', ?, ?, ?, ?) + """, + (group_id, mode, storage_key, skill_name, target_level, baseline, now, completed_at), + ) + goal_id = cur.lastrowid + conn.commit() + + row = conn.execute( + """ + SELECT go.id, go.group_id, gg.name AS group_name, go.goal_type, go.mode, + go.item_key, go.item_name, go.category, go.target_qty, go.baseline_qty, + go.created_at, go.completed_at + FROM goals go + LEFT JOIN goal_groups gg ON gg.id = go.group_id + WHERE go.id = ? + """, + (goal_id,), + ).fetchone() + conn.close() + return _goal_row_to_dict(row, current_skill_level=current_level) def delete_goal(goal_id: int, db_path: Path | str = DEFAULT_DB) -> bool: @@ -618,8 +892,8 @@ def check_goals_after_import( open_goals = conn.execute( """ - SELECT go.id, go.group_id, gg.name AS group_name, go.item_key, go.item_name, - go.target_qty + SELECT go.id, go.group_id, gg.name AS group_name, go.goal_type, go.mode, + go.item_key, go.item_name, go.target_qty, go.baseline_qty FROM goals go LEFT JOIN goal_groups gg ON gg.id = go.group_id WHERE go.completed_at IS NULL @@ -631,24 +905,42 @@ def check_goals_after_import( groups_touched: set[int] = set() for goal in open_goals: - current_qty = _inventory_qty_for_snapshot(conn, snapshot_id, goal["item_key"]) - if current_qty >= goal["target_qty"]: - conn.execute( - "UPDATE goals SET completed_at = ? WHERE id = ?", - (now, goal["id"]), - ) - entry = { - "id": goal["id"], - "item_key": goal["item_key"], - "item_name": goal["item_name"], - "target_qty": goal["target_qty"], - "current_qty": current_qty, - "group_id": goal["group_id"], - "group_name": goal["group_name"], - } - goals_completed.append(entry) - if goal["group_id"] is not None: - groups_touched.add(goal["group_id"]) + goal_type = goal["goal_type"] if goal["goal_type"] else "item" + mode = goal["mode"] if goal["mode"] else "absolute" + if goal_type == "skill": + skill_key = _skill_key_from_storage(goal["item_key"]) or "" + current_item_qty = 0 + current_skill_level = _skill_level_for_snapshot(conn, snapshot_id, skill_key) + current_display = current_skill_level + else: + current_item_qty = _inventory_qty_for_snapshot(conn, snapshot_id, goal["item_key"]) + current_skill_level = 0 + current_display = current_item_qty + + if not _goal_is_complete( + goal_type, mode, goal["target_qty"], goal["baseline_qty"], + current_item_qty=current_item_qty, + current_skill_level=current_skill_level, + ): + continue + + conn.execute( + "UPDATE goals SET completed_at = ? WHERE id = ?", + (now, goal["id"]), + ) + entry = { + "id": goal["id"], + "goal_type": goal_type, + "item_key": goal["item_key"], + "item_name": goal["item_name"], + "target_qty": goal["target_qty"], + "current_qty": current_display, + "group_id": goal["group_id"], + "group_name": goal["group_name"], + } + goals_completed.append(entry) + if goal["group_id"] is not None: + groups_touched.add(goal["group_id"]) groups_completed: list[dict[str, Any]] = [] for gid in groups_touched: @@ -673,3 +965,121 @@ def check_goals_after_import( "goals_completed": goals_completed, "groups_completed": groups_completed, } + + +def summarize_import_changes( + snapshot_id: int, + conn: sqlite3.Connection | None = None, + db_path: Path | str = DEFAULT_DB, +) -> dict[str, Any]: + own_conn = conn is None + if own_conn: + conn = get_connection(db_path) + init_db(conn) + + rows = conn.execute( + """ + SELECT id FROM snapshots + WHERE id < ? + ORDER BY exported_at DESC, id DESC + LIMIT 1 + """, + (snapshot_id,), + ).fetchone() + + if not rows: + if own_conn: + conn.close() + return {"has_previous": False} + + older_id = rows["id"] + try: + diff = diff_snapshots(older_id, snapshot_id, conn=conn, db_path=db_path) + except ValueError: + if own_conn: + conn.close() + return {"has_previous": False} + + older_data = get_snapshot(older_id, conn=conn, db_path=db_path) or {} + newer_data = get_snapshot(snapshot_id, conn=conn, db_path=db_path) or {} + + quests_completed = 0 + old_story = {q.get("name"): q.get("completed") for q in (older_data.get("quests") or {}).get("story", [])} + for q in (newer_data.get("quests") or {}).get("story", []): + name = q.get("name") + if name and q.get("completed") and not old_story.get(name): + quests_completed += 1 + + slayer_delta = 0 + old_slayer = (older_data.get("combat") or {}).get("slayer_task") or {} + new_slayer = (newer_data.get("combat") or {}).get("slayer_task") or {} + if old_slayer.get("display_name") == new_slayer.get("display_name"): + slayer_delta = (new_slayer.get("kills_completed") or 0) - (old_slayer.get("kills_completed") or 0) + + dungeon_delta = 0 + old_runs = (older_data.get("combat") or {}).get("dungeon_runs") or {} + new_runs = (newer_data.get("combat") or {}).get("dungeon_runs") or {} + for key, val in new_runs.items(): + dungeon_delta += max(0, val - old_runs.get(key, 0)) + + if own_conn: + conn.close() + + return { + "has_previous": True, + "older_id": older_id, + "newer_id": snapshot_id, + "coins_delta": diff["summary"]["coins_delta"], + "total_level_delta": diff["summary"]["total_level_delta"], + "inventory_changes": len(diff["inventory_changes"]), + "skill_changes": len(diff["skill_changes"]), + "quests_completed": quests_completed, + "slayer_kills_delta": max(0, slayer_delta), + "dungeon_runs_delta": dungeon_delta, + "top_inventory": diff["inventory_changes"][:5], + "top_skills": diff["skill_changes"][:5], + } + + +def skill_timeline(db_path: Path | str = DEFAULT_DB) -> dict[str, Any]: + """Per-skill level 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)} + skill_rows = conn.execute( + "SELECT snapshot_id, skill_key, level FROM skill_snapshots" + ).fetchall() + conn.close() + + series: dict[str, list[int]] = {} + n = len(snapshots) + for row in skill_rows: + key = row["skill_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["level"] + + return {"snapshots": snapshots, "series": series} + + +def delete_snapshot(snapshot_id: int, db_path: Path | str = DEFAULT_DB) -> bool: + conn = get_connection(db_path) + init_db(conn) + cur = conn.execute("DELETE FROM snapshots WHERE id = ?", (snapshot_id,)) + conn.commit() + deleted = cur.rowcount > 0 + conn.close() + return deleted diff --git a/static/app.js b/static/app.js index 6aee162..c1b9301 100644 --- a/static/app.js +++ b/static/app.js @@ -8,10 +8,14 @@ let state = { skills: { search: "", sort: "level", sortAsc: false }, quests: { tab: "story", filter: "all" }, goals: { filter: "all", collapsedGroups: new Set(), data: { groups: [], ungrouped: [] } }, + goalsOverview: null, goalModalItem: null, history: { olderId: null, newerId: null, diff: null }, charts: {}, inventoryTimeline: null, + skillTimeline: null, + lastImportChanges: null, + globalSearch: "", }; const CATEGORY_ORDER = [ @@ -69,6 +73,8 @@ async function init() { setupViewerBanner(); setupNav(); setupUpload(); + setupExport(); + setupGlobalSearch(); setupGoalModal(); await loadData(); } @@ -116,6 +122,8 @@ function setupLanguage() { applyStaticI18n(); resetLocaleDependentPanels(); if (state.data) renderAll(); + const gs = document.getElementById("global-search"); + if (gs) gs.placeholder = t("search.global"); if (document.getElementById("tab-history").classList.contains("active")) { loadHistoryTab(); } @@ -128,15 +136,132 @@ function resetLocaleDependentPanels() { }); } +function activateTab(tab) { + const btn = document.querySelector(`.nav-btn[data-tab="${tab}"]`); + if (!btn) return; + document.querySelectorAll(".nav-btn").forEach((b) => b.classList.remove("active")); + document.querySelectorAll(".tab-panel").forEach((p) => p.classList.remove("active")); + btn.classList.add("active"); + document.getElementById(`tab-${tab}`).classList.add("active"); + if (tab === "history") loadHistoryTab(); + if (tab === "goals") trackEvent("Goals Tab"); +} + function setupNav() { document.querySelectorAll(".nav-btn").forEach((btn) => { btn.addEventListener("click", () => { - document.querySelectorAll(".nav-btn").forEach((b) => b.classList.remove("active")); - document.querySelectorAll(".tab-panel").forEach((p) => p.classList.remove("active")); - btn.classList.add("active"); - document.getElementById(`tab-${btn.dataset.tab}`).classList.add("active"); - if (btn.dataset.tab === "history") loadHistoryTab(); - if (btn.dataset.tab === "goals") trackEvent("Goals Tab"); + const tab = btn.dataset.tab; + activateTab(tab); + history.replaceState(null, "", `#${tab}`); + }); + }); + const hash = (location.hash || "#overview").slice(1).split("?")[0] || "overview"; + if (document.querySelector(`.nav-btn[data-tab="${hash}"]`)) activateTab(hash); +} + +function setupExport() { + const el = document.getElementById("export-viewer"); + if (!el) return; + el.href = `${apiBase()}/export`; + el.addEventListener("click", () => trackEvent("Viewer Export")); +} + +function setupGlobalSearch() { + const input = document.getElementById("global-search"); + if (!input) return; + input.placeholder = t("search.global"); + input.addEventListener("input", (e) => { + state.globalSearch = e.target.value; + renderGlobalSearchResults(); + }); + document.addEventListener("click", (e) => { + const wrap = document.querySelector(".global-search-wrap"); + const results = document.getElementById("global-search-results"); + if (!wrap || !results || wrap.contains(e.target) || results.contains(e.target)) return; + results.hidden = true; + }); +} + +function collectAllGoals() { + const data = state.goals.data || { groups: [], ungrouped: [] }; + return [ + ...(data.ungrouped || []), + ...(data.groups || []).flatMap((g) => g.goals || []), + ]; +} + +function collectOpenGoalKeys() { + const keys = { items: new Set(), skills: new Set() }; + for (const goal of collectAllGoals()) { + if (goal.completed_at) continue; + if (goal.goal_type === "skill") keys.skills.add(goal.skill_key || goal.item_key); + else keys.items.add(goal.item_key); + } + return keys; +} + +function renderGlobalSearchResults() { + const el = document.getElementById("global-search-results"); + const q = state.globalSearch.trim().toLowerCase(); + if (!el || !q || !state.data) { + if (el) el.hidden = true; + return; + } + + const hits = []; + for (const item of state.data.inventory) { + if (item.name.toLowerCase().includes(q) || item.key.toLowerCase().includes(q)) { + hits.push({ type: "item", tab: "inventory", key: item.key, name: item.name, sub: fmt(item.qty) }); + } + } + for (const sk of state.data.skills) { + if (sk.name.toLowerCase().includes(q) || sk.key.toLowerCase().includes(q)) { + hits.push({ type: "skill", tab: "skills", key: sk.key, name: sk.name, sub: `Lv ${sk.level}` }); + } + } + for (const goal of collectAllGoals()) { + if (goal.item_name.toLowerCase().includes(q)) { + hits.push({ + type: "goal", + tab: "goals", + key: String(goal.id), + name: goal.item_name, + sub: goal.completed_at ? t("goals.done") : t("goals.open"), + }); + } + } + + if (!hits.length) { + el.hidden = false; + el.innerHTML = `

${esc(t("search.noResults"))}

`; + return; + } + + el.hidden = false; + el.innerHTML = hits.slice(0, 15).map((hit) => ` + `).join(""); + + el.querySelectorAll(".global-search-hit").forEach((btn) => { + btn.addEventListener("click", () => { + const tab = btn.dataset.tab; + const type = btn.dataset.type; + const key = btn.dataset.key; + activateTab(tab); + history.replaceState(null, "", `#${tab}`); + if (type === "item") { + state.inventory.search = key; + renderInventory(state.data); + } else if (type === "skill") { + state.skills.search = key; + renderSkills(state.data); + } + el.hidden = true; + document.getElementById("global-search").value = ""; + state.globalSearch = ""; }); }); } @@ -156,6 +281,7 @@ function setupUpload() { } trackEvent("JSON Upload", { status: result.imported ? "imported" : result.reason || "ok" }); if (result.imported) { + state.lastImportChanges = result.import_changes || null; await loadData(); notifyImportSuccess(result); showGoalsCompletedBanner(result); @@ -238,10 +364,12 @@ function renderImportReport(meta) { async function loadData() { try { - const [res] = await Promise.all([ + const [res, , overviewRes] = await Promise.all([ fetch(`${apiBase()}/snapshot/latest`), loadGoals(), + fetch(`${apiBase()}/goals/overview`), ]); + state.goalsOverview = overviewRes.ok ? await overviewRes.json() : null; if (!res.ok) { showEmpty(getViewerId() ? t("empty.noSaveWeb") : t("empty.noSave")); renderGoals(); @@ -249,6 +377,7 @@ async function loadData() { } state.data = await res.json(); state.inventoryTimeline = null; + state.skillTimeline = null; renderAll(); } catch (err) { showEmpty(t("empty.loadError", { message: err.message })); @@ -267,6 +396,7 @@ function renderAll() { renderHeader(d); renderImportReport(d.meta); renderOverview(d); + bindImportChangesCard(); renderSkills(d); renderInventory(d); renderGoals(); @@ -288,7 +418,8 @@ function renderHeader(d) {
${esc(t("kpi.coins"))}
${fmt(m.coins)}
${esc(t("kpi.totalLevel"))}
${m.total_level}
${esc(t("kpi.items"))}
${m.item_count}
-
${esc(t("kpi.totalQty"))}
${fmt(m.total_items)}
`; +
${esc(t("kpi.totalQty"))}
${fmt(m.total_items)}
+ ${state.goalsOverview ? `
${esc(t("kpi.goalsOpen"))}
${state.goalsOverview.open}
` : ""}`; } function renderOverview(d) { @@ -311,6 +442,7 @@ function renderOverview(d) { ).join(""); document.getElementById("tab-overview").innerHTML = ` + ${renderImportChangesCard(state.lastImportChanges)}

${esc(t("overview.character"))}

@@ -346,6 +478,47 @@ function renderOverview(d) {
`; } +function renderImportChangesCard(changes) { + if (!changes?.has_previous) return ""; + const invTop = (changes.top_inventory || []).map((i) => + `
  • ${esc(i.name)}${i.delta >= 0 ? "+" : ""}${fmt(i.delta)}
  • ` + ).join(""); + const skTop = (changes.top_skills || []).map((s) => + `
  • ${esc(s.name)}+${fmt(s.xp_delta)} XP
  • ` + ).join(""); + + return ` +
    +
    +

    ${esc(t("import.changesTitle"))}

    + +
    +

    ${esc(t("import.changesSummary", { + coins: `${changes.coins_delta >= 0 ? "+" : ""}${fmt(changes.coins_delta)}`, + level: `${changes.total_level_delta >= 0 ? "+" : ""}${changes.total_level_delta}`, + inv: changes.inventory_changes, + skills: changes.skill_changes, + }))}

    +
      + ${changes.quests_completed ? `
    • ${esc(t("import.questsCompleted"))}${changes.quests_completed}
    • ` : ""} + ${changes.slayer_kills_delta ? `
    • ${esc(t("import.slayerKills"))}+${changes.slayer_kills_delta}
    • ` : ""} + ${changes.dungeon_runs_delta ? `
    • ${esc(t("import.dungeonRuns"))}+${changes.dungeon_runs_delta}
    • ` : ""} +
    + ${invTop ? `

    ${esc(t("import.topInventory"))}

      ${invTop}
    ` : ""} + ${skTop ? `

    ${esc(t("import.topSkills"))}

      ${skTop}
    ` : ""} +
    `; +} + +function bindImportChangesCard() { + const dismiss = document.getElementById("import-changes-dismiss"); + if (!dismiss) return; + dismiss.addEventListener("click", () => { + state.lastImportChanges = null; + const card = document.getElementById("import-changes-card"); + if (card) card.remove(); + }); +} + function renderSkills(d) { const panel = document.getElementById("tab-skills"); const s = state.skills; @@ -367,6 +540,7 @@ function renderSkills(d) { XP + @@ -396,7 +570,8 @@ function renderSkills(d) { document.getElementById("skill-sort").options[2].textContent = t("skills.sortName"); panel.querySelector('th[data-sort="name"]').textContent = t("skills.skill"); panel.querySelector('th[data-sort="level"]').textContent = t("skills.level"); - panel.querySelector("thead tr th:last-child").textContent = t("skills.progress"); + panel.querySelectorAll("thead tr th")[3].textContent = t("skills.progress"); + panel.querySelector("thead tr th.col-actions").textContent = t("goals.actions"); document.getElementById("skill-search").value = s.search; document.getElementById("skill-sort").value = s.sort; @@ -405,6 +580,7 @@ function renderSkills(d) { function renderSkillsBody(d) { const s = state.skills; + const openGoals = collectOpenGoalKeys(); let items = [...d.skills]; if (s.search) { const q = s.search.toLowerCase(); @@ -418,16 +594,33 @@ function renderSkillsBody(d) { return s.sortAsc ? cmp : -cmp; }); - document.getElementById("skill-tbody").innerHTML = items.map((sk) => ` - - ${esc(sk.name)} + document.getElementById("skill-tbody").innerHTML = items.map((sk) => { + const hasGoal = openGoals.skills.has(sk.key); + return ` + + ${esc(sk.name)}${hasGoal ? `🎯` : ""} ${sk.level} ${fmt(sk.xp)} ${sk.progress_pct}%
    - `).join(""); + + + + `; + }).join(""); + + document.getElementById("skill-tbody").querySelectorAll(".goal-add-btn").forEach((btn) => { + btn.addEventListener("click", () => { + openGoalModal({ + key: btn.dataset.skillKey, + name: btn.dataset.skillName, + level: Number(btn.dataset.skillLevel), + goalType: "skill", + }); + }); + }); } function getFilteredInventoryItems(d, inv) { @@ -572,6 +765,7 @@ function renderInventoryTable(d, inv) { const items = getFilteredInventoryItems(d, inv); const showTrend = inventoryTrendEnabled(); const colSpan = showTrend ? 5 : 4; + const openGoals = collectOpenGoalKeys(); const grouped = {}; for (const item of items) { if (!grouped[item.category]) grouped[item.category] = []; @@ -591,16 +785,19 @@ function renderInventoryTable(d, inv) { `; - const rows = catItems.map((i) => ` - - ${esc(i.name)}${i.equipped ? `` : ""} + const rows = catItems.map((i) => { + const hasGoal = openGoals.items.has(i.key); + return ` + + ${esc(i.name)}${i.equipped ? `` : ""}${hasGoal ? `🎯` : ""} ${showTrend ? renderItemSparkCell(i) : ""} ${fmt(i.qty)} ${esc(i.key)} - `).join(""); + `; + }).join(""); return header + rows; }).join(""); @@ -646,6 +843,7 @@ function renderInventoryTable(d, inv) { key: btn.dataset.itemKey, name: btn.dataset.itemName, qty: Number(btn.dataset.itemQty), + goalType: "item", }); }); }); @@ -748,14 +946,29 @@ function goalMatchesFilter(goal, filter) { function renderGoalRow(goal) { const done = !!goal.completed_at; + const isSkill = goal.goal_type === "skill"; + const modeHint = goal.mode === "relative" + ? `+${isSkill ? goal.target_qty : fmt(goal.target_qty)}` + : ""; + const typeBadge = isSkill ? `${esc(t("goals.typeSkill"))} ` : ""; + const progressText = isSkill + ? `${goal.current_qty} / ${goal.target_display}` + : `${fmt(goal.current_qty)} / ${fmt(goal.target_display)}`; + const eta = !done && goal.eta_snapshots + ? `
    ${esc(t("goals.etaSnapshots", { n: goal.eta_snapshots }))}
    ` + : ""; + const missing = !done && goal.missing_qty > 0 + ? `
    ${esc(t("goals.missing", { qty: isSkill ? goal.missing_qty : fmt(goal.missing_qty) }))}
    ` + : ""; const deleteBtn = done ? `` : ""; return ` - ${esc(goal.item_name)} + ${typeBadge}${esc(goal.item_name)} -
    ${fmt(goal.current_qty)} / ${fmt(goal.target_qty)}
    +
    ${progressText} ${modeHint}
    + ${missing}${eta} ${esc(done ? t("goals.done") : t("goals.open"))} ${deleteBtn} @@ -777,8 +990,35 @@ function bindGoalActions(panel) { if (res.ok) { trackEvent("Goal Delete"); await loadGoals(); + const overviewRes = await fetch(`${apiBase()}/goals/overview`); + if (overviewRes.ok) state.goalsOverview = await overviewRes.json(); } renderGoals(); + if (state.data) { + renderHeader(state.data); + renderSkills(state.data); + renderInventory(state.data); + } + }); + }); + + panel.querySelectorAll(".goal-group-rename").forEach((btn) => { + btn.addEventListener("click", async () => { + const name = prompt(t("goals.renameGroupPrompt"), btn.dataset.groupName); + if (!name || !name.trim() || name.trim() === btn.dataset.groupName) return; + const res = await fetch(`${apiBase()}/goal-groups/${btn.dataset.groupId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: name.trim() }), + }); + if (res.ok) { + trackEvent("Goal Group Rename"); + await loadGoals(); + const overviewRes = await fetch(`${apiBase()}/goals/overview`); + if (overviewRes.ok) state.goalsOverview = await overviewRes.json(); + } + renderGoals(); + if (state.data) renderHeader(state.data); }); }); @@ -837,8 +1077,16 @@ function renderGoals() { const sectionKey = `group-${group.id}`; const expanded = !g.collapsedGroups.has(sectionKey); const completedIds = goals.filter((goal) => goal.completed_at).map((goal) => goal.id); + const missingGoals = goals.filter((goal) => !goal.completed_at && goal.missing_qty > 0); + const missingSummary = missingGoals.length + ? `
    ${missingGoals.map((goal) => { + const qty = goal.goal_type === "skill" ? goal.missing_qty : fmt(goal.missing_qty); + return `${esc(goal.item_name)}: ${esc(t("goals.missingShort", { qty }))}`; + }).join(" · ")}
    ` + : ""; const headerActions = ` ${completedIds.length ? `` : ""} + `; const rows = goals.length ? goals.map((goal) => { @@ -858,6 +1106,7 @@ function renderGoals() { ${esc(t("goals.groupProgress", { completed, total }))} ${headerActions} + ${missingSummary} ${rows} @@ -885,8 +1134,17 @@ function renderGoals() { const hasAny = groupSections || ungrouped.length || filter === "all"; + const overview = state.goalsOverview; + const overviewHtml = overview ? ` +
    + ${overview.open} ${esc(t("goals.filterOpen"))} + ${overview.completed} ${esc(t("goals.filterDone"))} + ${overview.total} ${esc(t("goals.total"))} +
    ` : ""; + panel.innerHTML = `
    + ${overviewHtml} + Export viewer
    @@ -60,6 +61,10 @@
    +
    + +
    +
    Loading save…
    @@ -81,6 +86,13 @@