Add relative/skill goals, import diffs, and history tooling.
Extends the goals system and viewer UX so players can track item and skill targets with groups, ETAs, global search, snapshot management, and DB export. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -9,18 +9,21 @@ import sys
|
|||||||
import webbrowser
|
import webbrowser
|
||||||
from pathlib import Path
|
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 werkzeug.utils import secure_filename
|
||||||
|
|
||||||
from db import (
|
from db import (
|
||||||
DEFAULT_DB,
|
DEFAULT_DB,
|
||||||
create_goal,
|
create_goal,
|
||||||
create_goal_group,
|
create_goal_group,
|
||||||
|
create_skill_goal,
|
||||||
delete_goal,
|
delete_goal,
|
||||||
delete_goal_group,
|
delete_goal_group,
|
||||||
|
delete_snapshot,
|
||||||
diff_snapshots,
|
diff_snapshots,
|
||||||
get_latest_snapshot,
|
get_latest_snapshot,
|
||||||
get_snapshot,
|
get_snapshot,
|
||||||
|
goals_overview,
|
||||||
import_save,
|
import_save,
|
||||||
init_db,
|
init_db,
|
||||||
inventory_timeline,
|
inventory_timeline,
|
||||||
@@ -28,6 +31,8 @@ from db import (
|
|||||||
list_goals_structured,
|
list_goals_structured,
|
||||||
list_snapshots,
|
list_snapshots,
|
||||||
get_connection,
|
get_connection,
|
||||||
|
rename_goal_group,
|
||||||
|
skill_timeline,
|
||||||
timeline,
|
timeline,
|
||||||
)
|
)
|
||||||
from security import (
|
from security import (
|
||||||
@@ -106,6 +111,14 @@ def api_snapshots(viewer_id: str):
|
|||||||
return jsonify(list_snapshots(db_path=db_path))
|
return jsonify(list_snapshots(db_path=db_path))
|
||||||
|
|
||||||
|
|
||||||
|
@viewer_bp.route("/api/snapshots/<int:snapshot_id>", 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/<int:older_id>/diff/<int:newer_id>")
|
@viewer_bp.route("/api/snapshots/<int:older_id>/diff/<int:newer_id>")
|
||||||
def api_diff(viewer_id: str, older_id: int, newer_id: int):
|
def api_diff(viewer_id: str, older_id: int, newer_id: int):
|
||||||
db_path = _resolve_viewer_db(viewer_id)
|
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))
|
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")
|
@viewer_bp.route("/api/goal-groups")
|
||||||
def api_goal_groups(viewer_id: str):
|
def api_goal_groups(viewer_id: str):
|
||||||
db_path = _resolve_viewer_db(viewer_id)
|
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})
|
return jsonify({"deleted": True})
|
||||||
|
|
||||||
|
|
||||||
|
@viewer_bp.route("/api/goal-groups/<int:group_id>", 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")
|
@viewer_bp.route("/api/goals")
|
||||||
def api_goals(viewer_id: str):
|
def api_goals(viewer_id: str):
|
||||||
db_path = _resolve_viewer_db(viewer_id)
|
db_path = _resolve_viewer_db(viewer_id)
|
||||||
@@ -165,16 +216,10 @@ def api_goals(viewer_id: str):
|
|||||||
def api_create_goal(viewer_id: str):
|
def api_create_goal(viewer_id: str):
|
||||||
db_path = _resolve_viewer_db(viewer_id)
|
db_path = _resolve_viewer_db(viewer_id)
|
||||||
body = request.get_json(silent=True) or {}
|
body = request.get_json(silent=True) or {}
|
||||||
item_key = (body.get("item_key") or "").strip()
|
goal_type = (body.get("goal_type") or "item").strip().lower()
|
||||||
target_qty = body.get("target_qty")
|
mode = (body.get("mode") or "absolute").strip().lower()
|
||||||
group_id = body.get("group_id")
|
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:
|
if group_id is not None:
|
||||||
try:
|
try:
|
||||||
group_id = int(group_id)
|
group_id = int(group_id)
|
||||||
@@ -185,7 +230,30 @@ def api_create_goal(viewer_id: str):
|
|||||||
return jsonify({"error": "No snapshots imported yet"}), 404
|
return jsonify({"error": "No snapshots imported yet"}), 404
|
||||||
|
|
||||||
try:
|
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:
|
except ValueError as exc:
|
||||||
msg = str(exc)
|
msg = str(exc)
|
||||||
status = 404 if "not found" in msg.lower() else 409 if "already exists" in msg.lower() else 400
|
status = 404 if "not found" in msg.lower() else 409 if "already exists" in msg.lower() else 400
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from typing import Any
|
|||||||
from parser import SaveParseError, normalize_save, load_save
|
from parser import SaveParseError, normalize_save, load_save
|
||||||
|
|
||||||
DEFAULT_DB = Path(__file__).parent / "data" / "history.db"
|
DEFAULT_DB = Path(__file__).parent / "data" / "history.db"
|
||||||
|
SKILL_GOAL_PREFIX = "__skill__:"
|
||||||
|
|
||||||
|
|
||||||
def _utc_now_iso() -> str:
|
def _utc_now_iso() -> str:
|
||||||
@@ -65,17 +66,32 @@ def init_db(conn: sqlite3.Connection) -> None:
|
|||||||
CREATE TABLE IF NOT EXISTS goals (
|
CREATE TABLE IF NOT EXISTS goals (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
group_id INTEGER REFERENCES goal_groups(id) ON DELETE SET NULL,
|
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_key TEXT NOT NULL UNIQUE,
|
||||||
item_name TEXT NOT NULL,
|
item_name TEXT NOT NULL,
|
||||||
category TEXT NOT NULL,
|
category TEXT NOT NULL,
|
||||||
target_qty INTEGER NOT NULL,
|
target_qty INTEGER NOT NULL,
|
||||||
|
baseline_qty INTEGER,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
completed_at TEXT
|
completed_at TEXT
|
||||||
);
|
);
|
||||||
""")
|
""")
|
||||||
|
_migrate_schema(conn)
|
||||||
conn.commit()
|
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:
|
def file_hash(path: Path) -> str:
|
||||||
h = hashlib.sha256()
|
h = hashlib.sha256()
|
||||||
with path.open("rb") as f:
|
with path.open("rb") as f:
|
||||||
@@ -159,6 +175,7 @@ def import_save(
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
goals_result = check_goals_after_import(snapshot_id, conn=conn, db_path=db_path)
|
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:
|
if own_conn:
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -168,6 +185,7 @@ def import_save(
|
|||||||
"snapshot_id": snapshot_id,
|
"snapshot_id": snapshot_id,
|
||||||
"import_report": import_report,
|
"import_report": import_report,
|
||||||
"import_summary": meta.get("import_summary"),
|
"import_summary": meta.get("import_summary"),
|
||||||
|
"import_changes": import_changes,
|
||||||
**goals_result,
|
**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
|
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"]
|
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 {
|
return {
|
||||||
"id": row["id"],
|
"id": row["id"],
|
||||||
"group_id": row["group_id"],
|
"group_id": row["group_id"],
|
||||||
"group_name": row["group_name"],
|
"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"],
|
"item_name": row["item_name"],
|
||||||
"category": row["category"],
|
"category": row["category"],
|
||||||
"target_qty": target,
|
"target_qty": row["target_qty"],
|
||||||
"current_qty": current_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,
|
"progress_pct": progress_pct,
|
||||||
|
"eta_snapshots": eta_snapshots,
|
||||||
"completed_at": row["completed_at"],
|
"completed_at": row["completed_at"],
|
||||||
"created_at": row["created_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
|
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]]:
|
def _fetch_goals_with_qty(conn: sqlite3.Connection, snapshot_id: int | None) -> list[dict[str, Any]]:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT go.id, go.group_id, gg.name AS group_name, go.item_key, go.item_name,
|
SELECT go.id, go.group_id, gg.name AS group_name, go.goal_type, go.mode,
|
||||||
go.category, go.target_qty, go.created_at, go.completed_at
|
go.item_key, go.item_name, go.category, go.target_qty, go.baseline_qty,
|
||||||
|
go.created_at, go.completed_at
|
||||||
FROM goals go
|
FROM goals go
|
||||||
LEFT JOIN goal_groups gg ON gg.id = go.group_id
|
LEFT JOIN goal_groups gg ON gg.id = go.group_id
|
||||||
ORDER BY go.created_at ASC, go.id ASC
|
ORDER BY go.created_at ASC, go.id ASC
|
||||||
"""
|
"""
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
rates = _goal_item_rates(conn) if snapshot_id else {}
|
||||||
goals = []
|
goals = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
current_qty = _inventory_qty_for_snapshot(conn, snapshot_id, row["item_key"]) if snapshot_id else 0
|
goal_type = row["goal_type"] if row["goal_type"] else "item"
|
||||||
goals.append(_goal_row_to_dict(row, current_qty))
|
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
|
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(
|
def _resolve_item_from_latest(
|
||||||
conn: sqlite3.Connection, item_key: str
|
conn: sqlite3.Connection, item_key: str
|
||||||
) -> dict[str, str] | None:
|
) -> dict[str, str] | None:
|
||||||
@@ -546,10 +738,13 @@ def create_goal(
|
|||||||
item_key: str,
|
item_key: str,
|
||||||
target_qty: int,
|
target_qty: int,
|
||||||
group_id: int | None = None,
|
group_id: int | None = None,
|
||||||
|
mode: str = "absolute",
|
||||||
db_path: Path | str = DEFAULT_DB,
|
db_path: Path | str = DEFAULT_DB,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
if target_qty <= 0:
|
if target_qty <= 0:
|
||||||
raise ValueError("Target quantity must be positive")
|
raise ValueError("Target quantity must be positive")
|
||||||
|
if mode not in ("absolute", "relative"):
|
||||||
|
raise ValueError("Invalid goal mode")
|
||||||
|
|
||||||
conn = get_connection(db_path)
|
conn = get_connection(db_path)
|
||||||
init_db(conn)
|
init_db(conn)
|
||||||
@@ -570,22 +765,28 @@ def create_goal(
|
|||||||
conn.close()
|
conn.close()
|
||||||
raise ValueError("Item not found in current inventory")
|
raise ValueError("Item not found in current inventory")
|
||||||
|
|
||||||
|
baseline = item["qty"] if mode == "relative" else None
|
||||||
now = _utc_now_iso()
|
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(
|
cur = conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO goals (group_id, item_key, item_name, category, target_qty, created_at, completed_at)
|
INSERT INTO goals
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
(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
|
goal_id = cur.lastrowid
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT go.id, go.group_id, gg.name AS group_name, go.item_key, go.item_name,
|
SELECT go.id, go.group_id, gg.name AS group_name, go.goal_type, go.mode,
|
||||||
go.category, go.target_qty, go.created_at, go.completed_at
|
go.item_key, go.item_name, go.category, go.target_qty, go.baseline_qty,
|
||||||
|
go.created_at, go.completed_at
|
||||||
FROM goals go
|
FROM goals go
|
||||||
LEFT JOIN goal_groups gg ON gg.id = go.group_id
|
LEFT JOIN goal_groups gg ON gg.id = go.group_id
|
||||||
WHERE go.id = ?
|
WHERE go.id = ?
|
||||||
@@ -593,7 +794,80 @@ def create_goal(
|
|||||||
(goal_id,),
|
(goal_id,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
conn.close()
|
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:
|
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(
|
open_goals = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT go.id, go.group_id, gg.name AS group_name, go.item_key, go.item_name,
|
SELECT go.id, go.group_id, gg.name AS group_name, go.goal_type, go.mode,
|
||||||
go.target_qty
|
go.item_key, go.item_name, go.target_qty, go.baseline_qty
|
||||||
FROM goals go
|
FROM goals go
|
||||||
LEFT JOIN goal_groups gg ON gg.id = go.group_id
|
LEFT JOIN goal_groups gg ON gg.id = go.group_id
|
||||||
WHERE go.completed_at IS NULL
|
WHERE go.completed_at IS NULL
|
||||||
@@ -631,24 +905,42 @@ def check_goals_after_import(
|
|||||||
groups_touched: set[int] = set()
|
groups_touched: set[int] = set()
|
||||||
|
|
||||||
for goal in open_goals:
|
for goal in open_goals:
|
||||||
current_qty = _inventory_qty_for_snapshot(conn, snapshot_id, goal["item_key"])
|
goal_type = goal["goal_type"] if goal["goal_type"] else "item"
|
||||||
if current_qty >= goal["target_qty"]:
|
mode = goal["mode"] if goal["mode"] else "absolute"
|
||||||
conn.execute(
|
if goal_type == "skill":
|
||||||
"UPDATE goals SET completed_at = ? WHERE id = ?",
|
skill_key = _skill_key_from_storage(goal["item_key"]) or ""
|
||||||
(now, goal["id"]),
|
current_item_qty = 0
|
||||||
)
|
current_skill_level = _skill_level_for_snapshot(conn, snapshot_id, skill_key)
|
||||||
entry = {
|
current_display = current_skill_level
|
||||||
"id": goal["id"],
|
else:
|
||||||
"item_key": goal["item_key"],
|
current_item_qty = _inventory_qty_for_snapshot(conn, snapshot_id, goal["item_key"])
|
||||||
"item_name": goal["item_name"],
|
current_skill_level = 0
|
||||||
"target_qty": goal["target_qty"],
|
current_display = current_item_qty
|
||||||
"current_qty": current_qty,
|
|
||||||
"group_id": goal["group_id"],
|
if not _goal_is_complete(
|
||||||
"group_name": goal["group_name"],
|
goal_type, mode, goal["target_qty"], goal["baseline_qty"],
|
||||||
}
|
current_item_qty=current_item_qty,
|
||||||
goals_completed.append(entry)
|
current_skill_level=current_skill_level,
|
||||||
if goal["group_id"] is not None:
|
):
|
||||||
groups_touched.add(goal["group_id"])
|
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]] = []
|
groups_completed: list[dict[str, Any]] = []
|
||||||
for gid in groups_touched:
|
for gid in groups_touched:
|
||||||
@@ -673,3 +965,121 @@ def check_goals_after_import(
|
|||||||
"goals_completed": goals_completed,
|
"goals_completed": goals_completed,
|
||||||
"groups_completed": groups_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
|
||||||
|
|||||||
+377
-26
@@ -8,10 +8,14 @@ let state = {
|
|||||||
skills: { search: "", sort: "level", sortAsc: false },
|
skills: { search: "", sort: "level", sortAsc: false },
|
||||||
quests: { tab: "story", filter: "all" },
|
quests: { tab: "story", filter: "all" },
|
||||||
goals: { filter: "all", collapsedGroups: new Set(), data: { groups: [], ungrouped: [] } },
|
goals: { filter: "all", collapsedGroups: new Set(), data: { groups: [], ungrouped: [] } },
|
||||||
|
goalsOverview: null,
|
||||||
goalModalItem: null,
|
goalModalItem: null,
|
||||||
history: { olderId: null, newerId: null, diff: null },
|
history: { olderId: null, newerId: null, diff: null },
|
||||||
charts: {},
|
charts: {},
|
||||||
inventoryTimeline: null,
|
inventoryTimeline: null,
|
||||||
|
skillTimeline: null,
|
||||||
|
lastImportChanges: null,
|
||||||
|
globalSearch: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
const CATEGORY_ORDER = [
|
const CATEGORY_ORDER = [
|
||||||
@@ -69,6 +73,8 @@ async function init() {
|
|||||||
setupViewerBanner();
|
setupViewerBanner();
|
||||||
setupNav();
|
setupNav();
|
||||||
setupUpload();
|
setupUpload();
|
||||||
|
setupExport();
|
||||||
|
setupGlobalSearch();
|
||||||
setupGoalModal();
|
setupGoalModal();
|
||||||
await loadData();
|
await loadData();
|
||||||
}
|
}
|
||||||
@@ -116,6 +122,8 @@ function setupLanguage() {
|
|||||||
applyStaticI18n();
|
applyStaticI18n();
|
||||||
resetLocaleDependentPanels();
|
resetLocaleDependentPanels();
|
||||||
if (state.data) renderAll();
|
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")) {
|
if (document.getElementById("tab-history").classList.contains("active")) {
|
||||||
loadHistoryTab();
|
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() {
|
function setupNav() {
|
||||||
document.querySelectorAll(".nav-btn").forEach((btn) => {
|
document.querySelectorAll(".nav-btn").forEach((btn) => {
|
||||||
btn.addEventListener("click", () => {
|
btn.addEventListener("click", () => {
|
||||||
document.querySelectorAll(".nav-btn").forEach((b) => b.classList.remove("active"));
|
const tab = btn.dataset.tab;
|
||||||
document.querySelectorAll(".tab-panel").forEach((p) => p.classList.remove("active"));
|
activateTab(tab);
|
||||||
btn.classList.add("active");
|
history.replaceState(null, "", `#${tab}`);
|
||||||
document.getElementById(`tab-${btn.dataset.tab}`).classList.add("active");
|
});
|
||||||
if (btn.dataset.tab === "history") loadHistoryTab();
|
});
|
||||||
if (btn.dataset.tab === "goals") trackEvent("Goals 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 = `<p class="global-search-empty">${esc(t("search.noResults"))}</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
el.hidden = false;
|
||||||
|
el.innerHTML = hits.slice(0, 15).map((hit) => `
|
||||||
|
<button type="button" class="global-search-hit" data-tab="${esc(hit.tab)}" data-type="${esc(hit.type)}" data-key="${esc(hit.key)}">
|
||||||
|
<span class="global-search-hit-type">${esc(t(`search.type.${hit.type}`))}</span>
|
||||||
|
<span class="global-search-hit-name">${esc(hit.name)}</span>
|
||||||
|
<span class="global-search-hit-sub">${esc(hit.sub)}</span>
|
||||||
|
</button>`).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" });
|
trackEvent("JSON Upload", { status: result.imported ? "imported" : result.reason || "ok" });
|
||||||
if (result.imported) {
|
if (result.imported) {
|
||||||
|
state.lastImportChanges = result.import_changes || null;
|
||||||
await loadData();
|
await loadData();
|
||||||
notifyImportSuccess(result);
|
notifyImportSuccess(result);
|
||||||
showGoalsCompletedBanner(result);
|
showGoalsCompletedBanner(result);
|
||||||
@@ -238,10 +364,12 @@ function renderImportReport(meta) {
|
|||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
try {
|
try {
|
||||||
const [res] = await Promise.all([
|
const [res, , overviewRes] = await Promise.all([
|
||||||
fetch(`${apiBase()}/snapshot/latest`),
|
fetch(`${apiBase()}/snapshot/latest`),
|
||||||
loadGoals(),
|
loadGoals(),
|
||||||
|
fetch(`${apiBase()}/goals/overview`),
|
||||||
]);
|
]);
|
||||||
|
state.goalsOverview = overviewRes.ok ? await overviewRes.json() : null;
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
showEmpty(getViewerId() ? t("empty.noSaveWeb") : t("empty.noSave"));
|
showEmpty(getViewerId() ? t("empty.noSaveWeb") : t("empty.noSave"));
|
||||||
renderGoals();
|
renderGoals();
|
||||||
@@ -249,6 +377,7 @@ async function loadData() {
|
|||||||
}
|
}
|
||||||
state.data = await res.json();
|
state.data = await res.json();
|
||||||
state.inventoryTimeline = null;
|
state.inventoryTimeline = null;
|
||||||
|
state.skillTimeline = null;
|
||||||
renderAll();
|
renderAll();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showEmpty(t("empty.loadError", { message: err.message }));
|
showEmpty(t("empty.loadError", { message: err.message }));
|
||||||
@@ -267,6 +396,7 @@ function renderAll() {
|
|||||||
renderHeader(d);
|
renderHeader(d);
|
||||||
renderImportReport(d.meta);
|
renderImportReport(d.meta);
|
||||||
renderOverview(d);
|
renderOverview(d);
|
||||||
|
bindImportChangesCard();
|
||||||
renderSkills(d);
|
renderSkills(d);
|
||||||
renderInventory(d);
|
renderInventory(d);
|
||||||
renderGoals();
|
renderGoals();
|
||||||
@@ -288,7 +418,8 @@ function renderHeader(d) {
|
|||||||
<div class="kpi"><div class="kpi-label">${esc(t("kpi.coins"))}</div><div class="kpi-value">${fmt(m.coins)}</div></div>
|
<div class="kpi"><div class="kpi-label">${esc(t("kpi.coins"))}</div><div class="kpi-value">${fmt(m.coins)}</div></div>
|
||||||
<div class="kpi"><div class="kpi-label">${esc(t("kpi.totalLevel"))}</div><div class="kpi-value">${m.total_level}</div></div>
|
<div class="kpi"><div class="kpi-label">${esc(t("kpi.totalLevel"))}</div><div class="kpi-value">${m.total_level}</div></div>
|
||||||
<div class="kpi"><div class="kpi-label">${esc(t("kpi.items"))}</div><div class="kpi-value">${m.item_count}</div></div>
|
<div class="kpi"><div class="kpi-label">${esc(t("kpi.items"))}</div><div class="kpi-value">${m.item_count}</div></div>
|
||||||
<div class="kpi"><div class="kpi-label">${esc(t("kpi.totalQty"))}</div><div class="kpi-value">${fmt(m.total_items)}</div></div>`;
|
<div class="kpi"><div class="kpi-label">${esc(t("kpi.totalQty"))}</div><div class="kpi-value">${fmt(m.total_items)}</div></div>
|
||||||
|
${state.goalsOverview ? `<div class="kpi kpi-goals"><div class="kpi-label">${esc(t("kpi.goalsOpen"))}</div><div class="kpi-value">${state.goalsOverview.open}</div></div>` : ""}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderOverview(d) {
|
function renderOverview(d) {
|
||||||
@@ -311,6 +442,7 @@ function renderOverview(d) {
|
|||||||
).join("");
|
).join("");
|
||||||
|
|
||||||
document.getElementById("tab-overview").innerHTML = `
|
document.getElementById("tab-overview").innerHTML = `
|
||||||
|
${renderImportChangesCard(state.lastImportChanges)}
|
||||||
<div class="grid-2">
|
<div class="grid-2">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3>${esc(t("overview.character"))}</h3>
|
<h3>${esc(t("overview.character"))}</h3>
|
||||||
@@ -346,6 +478,47 @@ function renderOverview(d) {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderImportChangesCard(changes) {
|
||||||
|
if (!changes?.has_previous) return "";
|
||||||
|
const invTop = (changes.top_inventory || []).map((i) =>
|
||||||
|
`<li><span>${esc(i.name)}</span><span class="${i.delta >= 0 ? "delta-pos" : "delta-neg"}">${i.delta >= 0 ? "+" : ""}${fmt(i.delta)}</span></li>`
|
||||||
|
).join("");
|
||||||
|
const skTop = (changes.top_skills || []).map((s) =>
|
||||||
|
`<li><span>${esc(s.name)}</span><span class="${s.xp_delta >= 0 ? "delta-pos" : "delta-neg"}">+${fmt(s.xp_delta)} XP</span></li>`
|
||||||
|
).join("");
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="card import-changes-card" id="import-changes-card">
|
||||||
|
<div class="import-report-header">
|
||||||
|
<h3>${esc(t("import.changesTitle"))}</h3>
|
||||||
|
<button type="button" class="import-report-dismiss" id="import-changes-dismiss" title="${esc(t("actions.dismiss"))}">×</button>
|
||||||
|
</div>
|
||||||
|
<p class="import-changes-summary">${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,
|
||||||
|
}))}</p>
|
||||||
|
<ul class="list-compact import-changes-stats">
|
||||||
|
${changes.quests_completed ? `<li><span>${esc(t("import.questsCompleted"))}</span><span>${changes.quests_completed}</span></li>` : ""}
|
||||||
|
${changes.slayer_kills_delta ? `<li><span>${esc(t("import.slayerKills"))}</span><span>+${changes.slayer_kills_delta}</span></li>` : ""}
|
||||||
|
${changes.dungeon_runs_delta ? `<li><span>${esc(t("import.dungeonRuns"))}</span><span>+${changes.dungeon_runs_delta}</span></li>` : ""}
|
||||||
|
</ul>
|
||||||
|
${invTop ? `<h4>${esc(t("import.topInventory"))}</h4><ul class="list-compact">${invTop}</ul>` : ""}
|
||||||
|
${skTop ? `<h4>${esc(t("import.topSkills"))}</h4><ul class="list-compact">${skTop}</ul>` : ""}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
function renderSkills(d) {
|
||||||
const panel = document.getElementById("tab-skills");
|
const panel = document.getElementById("tab-skills");
|
||||||
const s = state.skills;
|
const s = state.skills;
|
||||||
@@ -367,6 +540,7 @@ function renderSkills(d) {
|
|||||||
<th data-sort="level"></th>
|
<th data-sort="level"></th>
|
||||||
<th data-sort="xp">XP</th>
|
<th data-sort="xp">XP</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
|
<th class="col-actions"></th>
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
<tbody id="skill-tbody"></tbody>
|
<tbody id="skill-tbody"></tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -396,7 +570,8 @@ function renderSkills(d) {
|
|||||||
document.getElementById("skill-sort").options[2].textContent = t("skills.sortName");
|
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="name"]').textContent = t("skills.skill");
|
||||||
panel.querySelector('th[data-sort="level"]').textContent = t("skills.level");
|
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-search").value = s.search;
|
||||||
document.getElementById("skill-sort").value = s.sort;
|
document.getElementById("skill-sort").value = s.sort;
|
||||||
@@ -405,6 +580,7 @@ function renderSkills(d) {
|
|||||||
|
|
||||||
function renderSkillsBody(d) {
|
function renderSkillsBody(d) {
|
||||||
const s = state.skills;
|
const s = state.skills;
|
||||||
|
const openGoals = collectOpenGoalKeys();
|
||||||
let items = [...d.skills];
|
let items = [...d.skills];
|
||||||
if (s.search) {
|
if (s.search) {
|
||||||
const q = s.search.toLowerCase();
|
const q = s.search.toLowerCase();
|
||||||
@@ -418,16 +594,33 @@ function renderSkillsBody(d) {
|
|||||||
return s.sortAsc ? cmp : -cmp;
|
return s.sortAsc ? cmp : -cmp;
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById("skill-tbody").innerHTML = items.map((sk) => `
|
document.getElementById("skill-tbody").innerHTML = items.map((sk) => {
|
||||||
<tr>
|
const hasGoal = openGoals.skills.has(sk.key);
|
||||||
<td>${esc(sk.name)}</td>
|
return `
|
||||||
|
<tr class="${hasGoal ? "has-goal" : ""}">
|
||||||
|
<td>${esc(sk.name)}${hasGoal ? `<span class="goal-mark" title="${esc(t("goals.hasGoal"))}">🎯</span>` : ""}</td>
|
||||||
<td>${sk.level}</td>
|
<td>${sk.level}</td>
|
||||||
<td>${fmt(sk.xp)}</td>
|
<td>${fmt(sk.xp)}</td>
|
||||||
<td style="min-width:140px">
|
<td style="min-width:140px">
|
||||||
${sk.progress_pct}%
|
${sk.progress_pct}%
|
||||||
<div class="progress-bar"><div class="progress-fill" style="width:${sk.progress_pct}%"></div></div>
|
<div class="progress-bar"><div class="progress-fill" style="width:${sk.progress_pct}%"></div></div>
|
||||||
</td>
|
</td>
|
||||||
</tr>`).join("");
|
<td class="col-actions">
|
||||||
|
<button type="button" class="goal-add-btn" data-skill-key="${esc(sk.key)}" data-skill-name="${esc(sk.name)}" data-skill-level="${sk.level}" title="${esc(t("skills.addGoalFor", { name: sk.name }))}" aria-label="${esc(t("skills.addGoalFor", { name: sk.name }))}">+</button>
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
}).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) {
|
function getFilteredInventoryItems(d, inv) {
|
||||||
@@ -572,6 +765,7 @@ function renderInventoryTable(d, inv) {
|
|||||||
const items = getFilteredInventoryItems(d, inv);
|
const items = getFilteredInventoryItems(d, inv);
|
||||||
const showTrend = inventoryTrendEnabled();
|
const showTrend = inventoryTrendEnabled();
|
||||||
const colSpan = showTrend ? 5 : 4;
|
const colSpan = showTrend ? 5 : 4;
|
||||||
|
const openGoals = collectOpenGoalKeys();
|
||||||
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] = [];
|
||||||
@@ -591,16 +785,19 @@ function renderInventoryTable(d, inv) {
|
|||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
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)}">
|
const hasGoal = openGoals.items.has(i.key);
|
||||||
<td class="col-name">${esc(i.name)}${i.equipped ? `<span class="equipped-mark" title="${esc(t("inventory.equipped"))}">⚡</span>` : ""}</td>
|
return `
|
||||||
|
<tr class="inv-item-row ${i.equipped && inv.highlightEquipped ? "item-equipped" : ""} ${hasGoal ? "has-goal" : ""} ${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>` : ""}${hasGoal ? `<span class="goal-mark" title="${esc(t("goals.hasGoal"))}">🎯</span>` : ""}</td>
|
||||||
${showTrend ? renderItemSparkCell(i) : ""}
|
${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>
|
||||||
<td class="col-actions">
|
<td class="col-actions">
|
||||||
<button type="button" class="goal-add-btn" data-item-key="${esc(i.key)}" data-item-name="${esc(i.name)}" data-item-qty="${i.qty}" title="${esc(t("inventory.addGoalFor", { name: i.name }))}" aria-label="${esc(t("inventory.addGoalFor", { name: i.name }))}">+</button>
|
<button type="button" class="goal-add-btn" data-item-key="${esc(i.key)}" data-item-name="${esc(i.name)}" data-item-qty="${i.qty}" title="${esc(t("inventory.addGoalFor", { name: i.name }))}" aria-label="${esc(t("inventory.addGoalFor", { name: i.name }))}">+</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>`).join("");
|
</tr>`;
|
||||||
|
}).join("");
|
||||||
return header + rows;
|
return header + rows;
|
||||||
}).join("");
|
}).join("");
|
||||||
|
|
||||||
@@ -646,6 +843,7 @@ function renderInventoryTable(d, inv) {
|
|||||||
key: btn.dataset.itemKey,
|
key: btn.dataset.itemKey,
|
||||||
name: btn.dataset.itemName,
|
name: btn.dataset.itemName,
|
||||||
qty: Number(btn.dataset.itemQty),
|
qty: Number(btn.dataset.itemQty),
|
||||||
|
goalType: "item",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -748,14 +946,29 @@ function goalMatchesFilter(goal, filter) {
|
|||||||
|
|
||||||
function renderGoalRow(goal) {
|
function renderGoalRow(goal) {
|
||||||
const done = !!goal.completed_at;
|
const done = !!goal.completed_at;
|
||||||
|
const isSkill = goal.goal_type === "skill";
|
||||||
|
const modeHint = goal.mode === "relative"
|
||||||
|
? `<span class="goal-mode-badge" title="${esc(t("goals.modeRelativeHint"))}">+${isSkill ? goal.target_qty : fmt(goal.target_qty)}</span>`
|
||||||
|
: "";
|
||||||
|
const typeBadge = isSkill ? `<span class="goal-type-badge">${esc(t("goals.typeSkill"))}</span> ` : "";
|
||||||
|
const progressText = isSkill
|
||||||
|
? `${goal.current_qty} / ${goal.target_display}`
|
||||||
|
: `${fmt(goal.current_qty)} / ${fmt(goal.target_display)}`;
|
||||||
|
const eta = !done && goal.eta_snapshots
|
||||||
|
? `<div class="goal-eta">${esc(t("goals.etaSnapshots", { n: goal.eta_snapshots }))}</div>`
|
||||||
|
: "";
|
||||||
|
const missing = !done && goal.missing_qty > 0
|
||||||
|
? `<div class="goal-missing">${esc(t("goals.missing", { qty: isSkill ? goal.missing_qty : fmt(goal.missing_qty) }))}</div>`
|
||||||
|
: "";
|
||||||
const deleteBtn = done
|
const deleteBtn = done
|
||||||
? `<button type="button" class="goal-delete-btn" data-goal-id="${goal.id}">${esc(t("goals.delete"))}</button>`
|
? `<button type="button" class="goal-delete-btn" data-goal-id="${goal.id}">${esc(t("goals.delete"))}</button>`
|
||||||
: "";
|
: "";
|
||||||
return `<tr>
|
return `<tr>
|
||||||
<td>${esc(goal.item_name)}</td>
|
<td>${typeBadge}${esc(goal.item_name)}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="goal-progress-text">${fmt(goal.current_qty)} / ${fmt(goal.target_qty)}</div>
|
<div class="goal-progress-text">${progressText} ${modeHint}</div>
|
||||||
<div class="progress-bar"><div class="progress-fill" style="width:${goal.progress_pct}%"></div></div>
|
<div class="progress-bar"><div class="progress-fill" style="width:${goal.progress_pct}%"></div></div>
|
||||||
|
${missing}${eta}
|
||||||
</td>
|
</td>
|
||||||
<td><span class="badge ${done ? "badge-success" : "badge-warning"}">${esc(done ? t("goals.done") : t("goals.open"))}</span></td>
|
<td><span class="badge ${done ? "badge-success" : "badge-warning"}">${esc(done ? t("goals.done") : t("goals.open"))}</span></td>
|
||||||
<td class="goal-actions-cell">${deleteBtn}</td>
|
<td class="goal-actions-cell">${deleteBtn}</td>
|
||||||
@@ -777,8 +990,35 @@ function bindGoalActions(panel) {
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
trackEvent("Goal Delete");
|
trackEvent("Goal Delete");
|
||||||
await loadGoals();
|
await loadGoals();
|
||||||
|
const overviewRes = await fetch(`${apiBase()}/goals/overview`);
|
||||||
|
if (overviewRes.ok) state.goalsOverview = await overviewRes.json();
|
||||||
}
|
}
|
||||||
renderGoals();
|
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 sectionKey = `group-${group.id}`;
|
||||||
const expanded = !g.collapsedGroups.has(sectionKey);
|
const expanded = !g.collapsedGroups.has(sectionKey);
|
||||||
const completedIds = goals.filter((goal) => goal.completed_at).map((goal) => goal.id);
|
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
|
||||||
|
? `<div class="goal-missing-summary">${missingGoals.map((goal) => {
|
||||||
|
const qty = goal.goal_type === "skill" ? goal.missing_qty : fmt(goal.missing_qty);
|
||||||
|
return `<span>${esc(goal.item_name)}: ${esc(t("goals.missingShort", { qty }))}</span>`;
|
||||||
|
}).join(" · ")}</div>`
|
||||||
|
: "";
|
||||||
const headerActions = `
|
const headerActions = `
|
||||||
${completedIds.length ? `<button type="button" class="goal-clear-completed" data-goal-ids="${completedIds.join(",")}">${esc(t("goals.clearCompleted"))}</button>` : ""}
|
${completedIds.length ? `<button type="button" class="goal-clear-completed" data-goal-ids="${completedIds.join(",")}">${esc(t("goals.clearCompleted"))}</button>` : ""}
|
||||||
|
<button type="button" class="goal-group-rename" data-group-id="${group.id}" data-group-name="${esc(group.name)}">${esc(t("goals.renameGroup"))}</button>
|
||||||
<button type="button" class="goal-group-delete" data-group-id="${group.id}" data-group-name="${esc(group.name)}">${esc(t("goals.deleteGroup"))}</button>`;
|
<button type="button" class="goal-group-delete" data-group-id="${group.id}" data-group-name="${esc(group.name)}">${esc(t("goals.deleteGroup"))}</button>`;
|
||||||
const rows = goals.length
|
const rows = goals.length
|
||||||
? goals.map((goal) => {
|
? goals.map((goal) => {
|
||||||
@@ -858,6 +1106,7 @@ function renderGoals() {
|
|||||||
<span class="inv-group-meta">${esc(t("goals.groupProgress", { completed, total }))}</span>
|
<span class="inv-group-meta">${esc(t("goals.groupProgress", { completed, total }))}</span>
|
||||||
</button>
|
</button>
|
||||||
<span class="goal-group-actions">${headerActions}</span>
|
<span class="goal-group-actions">${headerActions}</span>
|
||||||
|
${missingSummary}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
${rows}
|
${rows}
|
||||||
@@ -885,8 +1134,17 @@ function renderGoals() {
|
|||||||
|
|
||||||
const hasAny = groupSections || ungrouped.length || filter === "all";
|
const hasAny = groupSections || ungrouped.length || filter === "all";
|
||||||
|
|
||||||
|
const overview = state.goalsOverview;
|
||||||
|
const overviewHtml = overview ? `
|
||||||
|
<div class="goals-overview-kpi">
|
||||||
|
<span class="goals-kpi-item"><strong>${overview.open}</strong> ${esc(t("goals.filterOpen"))}</span>
|
||||||
|
<span class="goals-kpi-item"><strong>${overview.completed}</strong> ${esc(t("goals.filterDone"))}</span>
|
||||||
|
<span class="goals-kpi-item"><strong>${overview.total}</strong> ${esc(t("goals.total"))}</span>
|
||||||
|
</div>` : "";
|
||||||
|
|
||||||
panel.innerHTML = `
|
panel.innerHTML = `
|
||||||
<div class="toolbar goals-toolbar">
|
<div class="toolbar goals-toolbar">
|
||||||
|
${overviewHtml}
|
||||||
<select class="select-input" id="goals-filter">
|
<select class="select-input" id="goals-filter">
|
||||||
<option value="all" ${filter === "all" ? "selected" : ""}>${esc(t("goals.filterAll"))}</option>
|
<option value="all" ${filter === "all" ? "selected" : ""}>${esc(t("goals.filterAll"))}</option>
|
||||||
<option value="open" ${filter === "open" ? "selected" : ""}>${esc(t("goals.filterOpen"))}</option>
|
<option value="open" ${filter === "open" ? "selected" : ""}>${esc(t("goals.filterOpen"))}</option>
|
||||||
@@ -936,12 +1194,17 @@ function setupGoalModal() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyGoalModalI18n() {
|
function applyGoalModalI18n() {
|
||||||
document.getElementById("goal-modal-title").textContent = t("goals.modalTitle");
|
const item = state.goalModalItem;
|
||||||
document.getElementById("goal-modal-qty-label").textContent = t("goals.targetQty");
|
const isSkill = item?.goalType === "skill";
|
||||||
|
document.getElementById("goal-modal-title").textContent = isSkill ? t("goals.modalTitleSkill") : t("goals.modalTitle");
|
||||||
|
document.getElementById("goal-modal-mode-label").textContent = t("goals.mode");
|
||||||
|
document.getElementById("goal-modal-mode").options[0].textContent = t("goals.modeAbsolute");
|
||||||
|
document.getElementById("goal-modal-mode").options[1].textContent = t("goals.modeRelative");
|
||||||
|
document.getElementById("goal-modal-qty-label").textContent = isSkill ? t("goals.targetLevel") : t("goals.targetQty");
|
||||||
document.getElementById("goal-modal-group-label").textContent = t("goals.selectGroup");
|
document.getElementById("goal-modal-group-label").textContent = t("goals.selectGroup");
|
||||||
document.getElementById("goal-modal-new-group-label").textContent = t("goals.newGroupName");
|
document.getElementById("goal-modal-new-group-label").textContent = t("goals.newGroupName");
|
||||||
document.getElementById("goal-modal-cancel").textContent = t("goals.cancel");
|
document.getElementById("goal-modal-cancel").textContent = t("goals.cancel");
|
||||||
document.getElementById("goal-modal-submit").textContent = t("inventory.addGoal");
|
document.getElementById("goal-modal-submit").textContent = isSkill ? t("skills.addGoal") : t("inventory.addGoal");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openGoalModal(item) {
|
async function openGoalModal(item) {
|
||||||
@@ -952,8 +1215,14 @@ async function openGoalModal(item) {
|
|||||||
errEl.hidden = true;
|
errEl.hidden = true;
|
||||||
errEl.textContent = "";
|
errEl.textContent = "";
|
||||||
|
|
||||||
document.getElementById("goal-modal-item").textContent = `${item.name} — ${t("goals.currentQty", { qty: fmt(item.qty) })}`;
|
const isSkill = item.goalType === "skill";
|
||||||
document.getElementById("goal-modal-qty").value = Math.max(item.qty + 1, 1);
|
const currentVal = isSkill ? item.level : item.qty;
|
||||||
|
const currentLabel = isSkill
|
||||||
|
? t("goals.currentLevel", { level: currentVal })
|
||||||
|
: t("goals.currentQty", { qty: fmt(currentVal) });
|
||||||
|
document.getElementById("goal-modal-item").textContent = `${item.name} — ${currentLabel}`;
|
||||||
|
document.getElementById("goal-modal-qty").value = Math.max(currentVal + 1, 1);
|
||||||
|
document.getElementById("goal-modal-mode").value = "absolute";
|
||||||
document.getElementById("goal-modal-new-group").value = "";
|
document.getElementById("goal-modal-new-group").value = "";
|
||||||
document.getElementById("goal-modal-new-group-wrap").hidden = true;
|
document.getElementById("goal-modal-new-group-wrap").hidden = true;
|
||||||
|
|
||||||
@@ -980,6 +1249,8 @@ async function submitGoalModal() {
|
|||||||
errEl.hidden = true;
|
errEl.hidden = true;
|
||||||
|
|
||||||
const targetQty = parseInt(document.getElementById("goal-modal-qty").value, 10);
|
const targetQty = parseInt(document.getElementById("goal-modal-qty").value, 10);
|
||||||
|
const mode = document.getElementById("goal-modal-mode").value;
|
||||||
|
const isSkill = item.goalType === "skill";
|
||||||
if (!targetQty || targetQty <= 0) {
|
if (!targetQty || targetQty <= 0) {
|
||||||
errEl.textContent = t("goals.createFailed");
|
errEl.textContent = t("goals.createFailed");
|
||||||
errEl.hidden = false;
|
errEl.hidden = false;
|
||||||
@@ -1013,10 +1284,14 @@ async function submitGoalModal() {
|
|||||||
groupId = parseInt(groupVal, 10);
|
groupId = parseInt(groupVal, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const body = isSkill
|
||||||
|
? { goal_type: "skill", skill_key: item.key, target_level: targetQty, group_id: groupId, mode }
|
||||||
|
: { item_key: item.key, target_qty: targetQty, group_id: groupId, mode };
|
||||||
|
|
||||||
const res = await fetch(`${apiBase()}/goals`, {
|
const res = await fetch(`${apiBase()}/goals`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ item_key: item.key, target_qty: targetQty, group_id: groupId }),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
const result = await res.json();
|
const result = await res.json();
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -1029,14 +1304,23 @@ async function submitGoalModal() {
|
|||||||
trackEvent("Goal Group Create", { source: "modal" });
|
trackEvent("Goal Group Create", { source: "modal" });
|
||||||
}
|
}
|
||||||
trackEvent("Goal Create", {
|
trackEvent("Goal Create", {
|
||||||
source: "inventory",
|
source: isSkill ? "skills" : "inventory",
|
||||||
|
type: isSkill ? "skill" : "item",
|
||||||
|
mode,
|
||||||
group: newGroupFromModal ? "new" : groupId ? "existing" : "none",
|
group: newGroupFromModal ? "new" : groupId ? "existing" : "none",
|
||||||
immediate: result.completed_at ? "true" : "false",
|
immediate: result.completed_at ? "true" : "false",
|
||||||
});
|
});
|
||||||
|
|
||||||
closeGoalModal();
|
closeGoalModal();
|
||||||
await loadGoals();
|
await loadGoals();
|
||||||
|
const overviewRes = await fetch(`${apiBase()}/goals/overview`);
|
||||||
|
if (overviewRes.ok) state.goalsOverview = await overviewRes.json();
|
||||||
renderGoals();
|
renderGoals();
|
||||||
|
if (state.data) {
|
||||||
|
renderHeader(state.data);
|
||||||
|
renderSkills(state.data);
|
||||||
|
renderInventory(state.data);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function showGoalsCompletedBanner(result) {
|
function showGoalsCompletedBanner(result) {
|
||||||
@@ -1184,6 +1468,17 @@ function renderCombat(d) {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function ensureSkillTimeline() {
|
||||||
|
if (state.skillTimeline) return state.skillTimeline;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${apiBase()}/skills/timeline`);
|
||||||
|
state.skillTimeline = res.ok ? await res.json() : { snapshots: [], series: {} };
|
||||||
|
} catch {
|
||||||
|
state.skillTimeline = { snapshots: [], series: {} };
|
||||||
|
}
|
||||||
|
return state.skillTimeline;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadHistoryTab() {
|
async function loadHistoryTab() {
|
||||||
const panel = document.getElementById("tab-history");
|
const panel = document.getElementById("tab-history");
|
||||||
panel.innerHTML = `<p class='loading'>${esc(t("history.loading"))}</p>`;
|
panel.innerHTML = `<p class='loading'>${esc(t("history.loading"))}</p>`;
|
||||||
@@ -1194,6 +1489,7 @@ async function loadHistoryTab() {
|
|||||||
]);
|
]);
|
||||||
state.snapshots = await snapRes.json();
|
state.snapshots = await snapRes.json();
|
||||||
state.timeline = await tlRes.json();
|
state.timeline = await tlRes.json();
|
||||||
|
await ensureSkillTimeline();
|
||||||
|
|
||||||
if (state.snapshots.length === 0) {
|
if (state.snapshots.length === 0) {
|
||||||
panel.innerHTML = `<p class='empty-state'>${esc(t("empty.noSnapshots"))}</p>`;
|
panel.innerHTML = `<p class='empty-state'>${esc(t("empty.noSnapshots"))}</p>`;
|
||||||
@@ -1215,6 +1511,10 @@ async function loadHistoryTab() {
|
|||||||
<div class="chart-wrap"><canvas id="chart-level"></canvas></div>
|
<div class="chart-wrap"><canvas id="chart-level"></canvas></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>${esc(t("history.skillLevelChart"))}</h3>
|
||||||
|
<div class="chart-wrap"><canvas id="chart-skills"></canvas></div>
|
||||||
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3>${esc(t("history.snapshotCompare"))}</h3>
|
<h3>${esc(t("history.snapshotCompare"))}</h3>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
@@ -1239,6 +1539,7 @@ async function loadHistoryTab() {
|
|||||||
<th>${esc(t("kpi.totalLevel"))}</th>
|
<th>${esc(t("kpi.totalLevel"))}</th>
|
||||||
<th>${esc(t("meta.export"))}</th>
|
<th>${esc(t("meta.export"))}</th>
|
||||||
<th>${esc(t("history.file"))}</th>
|
<th>${esc(t("history.file"))}</th>
|
||||||
|
<th>${esc(t("goals.actions"))}</th>
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
<tbody>${state.snapshots.map((s) => `
|
<tbody>${state.snapshots.map((s) => `
|
||||||
<tr>
|
<tr>
|
||||||
@@ -1248,6 +1549,9 @@ async function loadHistoryTab() {
|
|||||||
<td>${s.total_level}</td>
|
<td>${s.total_level}</td>
|
||||||
<td>${formatTs(s.exported_at)}</td>
|
<td>${formatTs(s.exported_at)}</td>
|
||||||
<td>${esc(s.source_file)}</td>
|
<td>${esc(s.source_file)}</td>
|
||||||
|
<td>
|
||||||
|
<button type="button" class="snapshot-delete-btn" data-snapshot-id="${s.id}" ${state.snapshots.length <= 1 ? "disabled" : ""} title="${esc(t("history.deleteSnapshot"))}">${esc(t("history.delete"))}</button>
|
||||||
|
</td>
|
||||||
</tr>`).join("")}
|
</tr>`).join("")}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -1257,6 +1561,21 @@ async function loadHistoryTab() {
|
|||||||
document.getElementById("diff-older").addEventListener("change", (e) => { h.olderId = +e.target.value; });
|
document.getElementById("diff-older").addEventListener("change", (e) => { h.olderId = +e.target.value; });
|
||||||
document.getElementById("diff-newer").addEventListener("change", (e) => { h.newerId = +e.target.value; });
|
document.getElementById("diff-newer").addEventListener("change", (e) => { h.newerId = +e.target.value; });
|
||||||
document.getElementById("diff-run").addEventListener("click", runDiff);
|
document.getElementById("diff-run").addEventListener("click", runDiff);
|
||||||
|
panel.querySelectorAll(".snapshot-delete-btn").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", async () => {
|
||||||
|
if (!confirm(t("history.deleteSnapshotConfirm"))) return;
|
||||||
|
const res = await fetch(`${apiBase()}/snapshots/${btn.dataset.snapshotId}`, { method: "DELETE" });
|
||||||
|
if (!res.ok) {
|
||||||
|
alert(t("history.deleteSnapshotFailed"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
trackEvent("Snapshot Delete");
|
||||||
|
state.inventoryTimeline = null;
|
||||||
|
state.skillTimeline = null;
|
||||||
|
await loadData();
|
||||||
|
loadHistoryTab();
|
||||||
|
});
|
||||||
|
});
|
||||||
if (h.olderId && h.newerId && h.olderId !== h.newerId) runDiff();
|
if (h.olderId && h.newerId && h.olderId !== h.newerId) runDiff();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1275,6 +1594,7 @@ function renderTimelineCharts() {
|
|||||||
|
|
||||||
destroyChart("coins");
|
destroyChart("coins");
|
||||||
destroyChart("level");
|
destroyChart("level");
|
||||||
|
destroyChart("skills");
|
||||||
|
|
||||||
state.charts.coins = new Chart(document.getElementById("chart-coins"), {
|
state.charts.coins = new Chart(document.getElementById("chart-coins"), {
|
||||||
type: "line",
|
type: "line",
|
||||||
@@ -1286,6 +1606,37 @@ function renderTimelineCharts() {
|
|||||||
data: { labels, datasets: [{ label: t("kpi.totalLevel"), data: levels, borderColor: "#4ade80", tension: 0.3, fill: false }] },
|
data: { labels, datasets: [{ label: t("kpi.totalLevel"), data: levels, borderColor: "#4ade80", tension: 0.3, fill: false }] },
|
||||||
options: chartOpts(),
|
options: chartOpts(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const skillTl = state.skillTimeline;
|
||||||
|
const skillCanvas = document.getElementById("chart-skills");
|
||||||
|
if (!skillCanvas || !skillTl?.snapshots?.length) return;
|
||||||
|
|
||||||
|
const skillLabels = skillTl.snapshots.map((s) => formatTs(s.exported_at));
|
||||||
|
const skillEntries = Object.entries(skillTl.series || {})
|
||||||
|
.map(([key, values]) => ({ key, values, latest: values[values.length - 1] || 0 }))
|
||||||
|
.sort((a, b) => b.latest - a.latest)
|
||||||
|
.slice(0, 5);
|
||||||
|
|
||||||
|
const colors = ["#6c8cff", "#4ade80", "#fbbf24", "#f87171", "#a78bfa"];
|
||||||
|
const skillName = (key) => {
|
||||||
|
const sk = state.data?.skills?.find((s) => s.key === key);
|
||||||
|
return sk?.name || key.replace(/_/g, " ");
|
||||||
|
};
|
||||||
|
|
||||||
|
state.charts.skills = new Chart(skillCanvas, {
|
||||||
|
type: "line",
|
||||||
|
data: {
|
||||||
|
labels: skillLabels,
|
||||||
|
datasets: skillEntries.map((entry, idx) => ({
|
||||||
|
label: skillName(entry.key),
|
||||||
|
data: entry.values,
|
||||||
|
borderColor: colors[idx % colors.length],
|
||||||
|
tension: 0.3,
|
||||||
|
fill: false,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
options: chartOpts(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function chartOpts() {
|
function chartOpts() {
|
||||||
|
|||||||
+44
-4
@@ -22,6 +22,7 @@
|
|||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"importBackup": "Backup importieren",
|
"importBackup": "Backup importieren",
|
||||||
|
"exportViewer": "Viewer exportieren",
|
||||||
"compare": "Vergleichen",
|
"compare": "Vergleichen",
|
||||||
"dismiss": "Schließen"
|
"dismiss": "Schließen"
|
||||||
},
|
},
|
||||||
@@ -42,6 +43,13 @@
|
|||||||
"duplicate": "Backup bereits vorhanden (Duplikat).",
|
"duplicate": "Backup bereits vorhanden (Duplikat).",
|
||||||
"success": "Importiert: Snapshot #{id}",
|
"success": "Importiert: Snapshot #{id}",
|
||||||
"successWithNotes": "Importiert: Snapshot #{id}\n\n{warnings} Warnung(en), {infos} Hinweis(e) – Details im Dashboard-Banner.",
|
"successWithNotes": "Importiert: Snapshot #{id}\n\n{warnings} Warnung(en), {infos} Hinweis(e) – Details im Dashboard-Banner.",
|
||||||
|
"changesTitle": "Änderungen seit letztem Import",
|
||||||
|
"changesSummary": "Münzen {coins} · Level {level} · {inv} Inventar-Änderungen · {skills} Skill-Änderungen",
|
||||||
|
"questsCompleted": "Story-Quests abgeschlossen",
|
||||||
|
"slayerKills": "Slayer-Kills",
|
||||||
|
"dungeonRuns": "Dungeon-Läufe",
|
||||||
|
"topInventory": "Größte Inventar-Änderungen",
|
||||||
|
"topSkills": "Größte Skill-Änderungen",
|
||||||
"titleError": "Import-Fehler",
|
"titleError": "Import-Fehler",
|
||||||
"titleWarning": "Import-Warnungen",
|
"titleWarning": "Import-Warnungen",
|
||||||
"titleInfo": "Import-Hinweise",
|
"titleInfo": "Import-Hinweise",
|
||||||
@@ -81,7 +89,8 @@
|
|||||||
"coins": "Münzen",
|
"coins": "Münzen",
|
||||||
"totalLevel": "Gesamtlevel",
|
"totalLevel": "Gesamtlevel",
|
||||||
"items": "Items",
|
"items": "Items",
|
||||||
"totalQty": "Gesamtmenge"
|
"totalQty": "Gesamtmenge",
|
||||||
|
"goalsOpen": "Offene Ziele"
|
||||||
},
|
},
|
||||||
"overview": {
|
"overview": {
|
||||||
"character": "Charakter",
|
"character": "Charakter",
|
||||||
@@ -105,7 +114,9 @@
|
|||||||
"sortName": "Nach Name",
|
"sortName": "Nach Name",
|
||||||
"skill": "Skill",
|
"skill": "Skill",
|
||||||
"level": "Level",
|
"level": "Level",
|
||||||
"progress": "Fortschritt"
|
"progress": "Fortschritt",
|
||||||
|
"addGoal": "Ziel hinzufügen",
|
||||||
|
"addGoalFor": "Ziel für {name} hinzufügen"
|
||||||
},
|
},
|
||||||
"inventory": {
|
"inventory": {
|
||||||
"search": "Item suchen…",
|
"search": "Item suchen…",
|
||||||
@@ -162,8 +173,22 @@
|
|||||||
"skillChanges": "Skill-Änderungen ({count})",
|
"skillChanges": "Skill-Änderungen ({count})",
|
||||||
"delta": "Delta",
|
"delta": "Delta",
|
||||||
"xpDelta": "XP-Delta",
|
"xpDelta": "XP-Delta",
|
||||||
|
"skillLevelChart": "Top-Skills über Zeit",
|
||||||
|
"delete": "Löschen",
|
||||||
|
"deleteSnapshot": "Snapshot löschen",
|
||||||
|
"deleteSnapshotConfirm": "Diesen Snapshot wirklich löschen?",
|
||||||
|
"deleteSnapshotFailed": "Snapshot konnte nicht gelöscht werden",
|
||||||
"coinsSummary": "Münzen: {delta} · Gesamtlevel: {levelDelta}"
|
"coinsSummary": "Münzen: {delta} · Gesamtlevel: {levelDelta}"
|
||||||
},
|
},
|
||||||
|
"search": {
|
||||||
|
"global": "Items, Skills und Ziele suchen…",
|
||||||
|
"noResults": "Keine Treffer",
|
||||||
|
"type": {
|
||||||
|
"item": "Item",
|
||||||
|
"skill": "Skill",
|
||||||
|
"goal": "Ziel"
|
||||||
|
}
|
||||||
|
},
|
||||||
"category": {
|
"category": {
|
||||||
"currency": "Währung",
|
"currency": "Währung",
|
||||||
"ores_mining": "Erze & Mining",
|
"ores_mining": "Erze & Mining",
|
||||||
@@ -218,13 +243,28 @@
|
|||||||
"deleteGroupConfirm": "Gruppe „{name}“ löschen? Ziele bleiben ohne Gruppe erhalten.",
|
"deleteGroupConfirm": "Gruppe „{name}“ löschen? Ziele bleiben ohne Gruppe erhalten.",
|
||||||
"deleteConfirm": "Dieses Ziel löschen?",
|
"deleteConfirm": "Dieses Ziel löschen?",
|
||||||
"clearCompleted": "Erledigte entfernen",
|
"clearCompleted": "Erledigte entfernen",
|
||||||
"empty": "Noch keine Ziele. Lege Ziele im Inventar-Tab an.",
|
"empty": "Noch keine Ziele. Lege Ziele im Inventar- oder Skills-Tab an.",
|
||||||
"loadError": "Ziele konnten nicht geladen werden",
|
"loadError": "Ziele konnten nicht geladen werden",
|
||||||
"createFailed": "Ziel konnte nicht angelegt werden",
|
"createFailed": "Ziel konnte nicht angelegt werden",
|
||||||
"groupCreateFailed": "Gruppe konnte nicht angelegt werden",
|
"groupCreateFailed": "Gruppe konnte nicht angelegt werden",
|
||||||
"modalTitle": "Ziel hinzufügen",
|
"modalTitle": "Item-Ziel hinzufügen",
|
||||||
|
"modalTitleSkill": "Skill-Ziel hinzufügen",
|
||||||
"cancel": "Abbrechen",
|
"cancel": "Abbrechen",
|
||||||
"targetQty": "Zielmenge",
|
"targetQty": "Zielmenge",
|
||||||
|
"targetLevel": "Ziel-Level",
|
||||||
|
"mode": "Modus",
|
||||||
|
"modeAbsolute": "Absolut (Gesamtmenge/Level)",
|
||||||
|
"modeRelative": "Relativ (seit Anlegen)",
|
||||||
|
"modeRelativeHint": "Relatives Ziel – Fortschritt seit Anlegen",
|
||||||
|
"typeSkill": "Skill",
|
||||||
|
"currentLevel": "Aktuell: Level {level}",
|
||||||
|
"missing": "Noch {qty} fehlen",
|
||||||
|
"missingShort": "−{qty}",
|
||||||
|
"etaSnapshots": "ca. {n} Import(e)",
|
||||||
|
"hasGoal": "Offenes Ziel",
|
||||||
|
"total": "gesamt",
|
||||||
|
"renameGroup": "Umbenennen",
|
||||||
|
"renameGroupPrompt": "Neuer Gruppenname:",
|
||||||
"selectGroup": "Gruppe",
|
"selectGroup": "Gruppe",
|
||||||
"noGroup": "Keine Gruppe",
|
"noGroup": "Keine Gruppe",
|
||||||
"newGroup": "Neue Gruppe…",
|
"newGroup": "Neue Gruppe…",
|
||||||
|
|||||||
+44
-4
@@ -22,6 +22,7 @@
|
|||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"importBackup": "Import backup",
|
"importBackup": "Import backup",
|
||||||
|
"exportViewer": "Export viewer",
|
||||||
"compare": "Compare",
|
"compare": "Compare",
|
||||||
"dismiss": "Dismiss"
|
"dismiss": "Dismiss"
|
||||||
},
|
},
|
||||||
@@ -42,6 +43,13 @@
|
|||||||
"duplicate": "Backup already exists (duplicate).",
|
"duplicate": "Backup already exists (duplicate).",
|
||||||
"success": "Imported: Snapshot #{id}",
|
"success": "Imported: Snapshot #{id}",
|
||||||
"successWithNotes": "Imported: Snapshot #{id}\n\n{warnings} warning(s), {infos} note(s) – see dashboard banner for details.",
|
"successWithNotes": "Imported: Snapshot #{id}\n\n{warnings} warning(s), {infos} note(s) – see dashboard banner for details.",
|
||||||
|
"changesTitle": "Changes since last import",
|
||||||
|
"changesSummary": "Coins {coins} · Level {level} · {inv} inventory changes · {skills} skill changes",
|
||||||
|
"questsCompleted": "Story quests completed",
|
||||||
|
"slayerKills": "Slayer kills",
|
||||||
|
"dungeonRuns": "Dungeon runs",
|
||||||
|
"topInventory": "Largest inventory changes",
|
||||||
|
"topSkills": "Largest skill changes",
|
||||||
"titleError": "Import errors",
|
"titleError": "Import errors",
|
||||||
"titleWarning": "Import warnings",
|
"titleWarning": "Import warnings",
|
||||||
"titleInfo": "Import notes",
|
"titleInfo": "Import notes",
|
||||||
@@ -81,7 +89,8 @@
|
|||||||
"coins": "Coins",
|
"coins": "Coins",
|
||||||
"totalLevel": "Total level",
|
"totalLevel": "Total level",
|
||||||
"items": "Items",
|
"items": "Items",
|
||||||
"totalQty": "Total quantity"
|
"totalQty": "Total quantity",
|
||||||
|
"goalsOpen": "Open goals"
|
||||||
},
|
},
|
||||||
"overview": {
|
"overview": {
|
||||||
"character": "Character",
|
"character": "Character",
|
||||||
@@ -105,7 +114,9 @@
|
|||||||
"sortName": "By name",
|
"sortName": "By name",
|
||||||
"skill": "Skill",
|
"skill": "Skill",
|
||||||
"level": "Level",
|
"level": "Level",
|
||||||
"progress": "Progress"
|
"progress": "Progress",
|
||||||
|
"addGoal": "Add goal",
|
||||||
|
"addGoalFor": "Add goal for {name}"
|
||||||
},
|
},
|
||||||
"inventory": {
|
"inventory": {
|
||||||
"search": "Search items…",
|
"search": "Search items…",
|
||||||
@@ -162,8 +173,22 @@
|
|||||||
"skillChanges": "Skill changes ({count})",
|
"skillChanges": "Skill changes ({count})",
|
||||||
"delta": "Delta",
|
"delta": "Delta",
|
||||||
"xpDelta": "XP delta",
|
"xpDelta": "XP delta",
|
||||||
|
"skillLevelChart": "Top skills over time",
|
||||||
|
"delete": "Delete",
|
||||||
|
"deleteSnapshot": "Delete snapshot",
|
||||||
|
"deleteSnapshotConfirm": "Really delete this snapshot?",
|
||||||
|
"deleteSnapshotFailed": "Could not delete snapshot",
|
||||||
"coinsSummary": "Coins: {delta} · Total level: {levelDelta}"
|
"coinsSummary": "Coins: {delta} · Total level: {levelDelta}"
|
||||||
},
|
},
|
||||||
|
"search": {
|
||||||
|
"global": "Search items, skills and goals…",
|
||||||
|
"noResults": "No results",
|
||||||
|
"type": {
|
||||||
|
"item": "Item",
|
||||||
|
"skill": "Skill",
|
||||||
|
"goal": "Goal"
|
||||||
|
}
|
||||||
|
},
|
||||||
"category": {
|
"category": {
|
||||||
"currency": "Currency",
|
"currency": "Currency",
|
||||||
"ores_mining": "Ores & Mining",
|
"ores_mining": "Ores & Mining",
|
||||||
@@ -218,13 +243,28 @@
|
|||||||
"deleteGroupConfirm": "Delete group \"{name}\"? Goals will be kept as ungrouped.",
|
"deleteGroupConfirm": "Delete group \"{name}\"? Goals will be kept as ungrouped.",
|
||||||
"deleteConfirm": "Delete this goal?",
|
"deleteConfirm": "Delete this goal?",
|
||||||
"clearCompleted": "Remove completed",
|
"clearCompleted": "Remove completed",
|
||||||
"empty": "No goals yet. Add goals from the Inventory tab.",
|
"empty": "No goals yet. Add goals from the Inventory or Skills tab.",
|
||||||
"loadError": "Failed to load goals",
|
"loadError": "Failed to load goals",
|
||||||
"createFailed": "Could not create goal",
|
"createFailed": "Could not create goal",
|
||||||
"groupCreateFailed": "Could not create group",
|
"groupCreateFailed": "Could not create group",
|
||||||
"modalTitle": "Add goal",
|
"modalTitle": "Add item goal",
|
||||||
|
"modalTitleSkill": "Add skill goal",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"targetQty": "Target quantity",
|
"targetQty": "Target quantity",
|
||||||
|
"targetLevel": "Target level",
|
||||||
|
"mode": "Mode",
|
||||||
|
"modeAbsolute": "Absolute (total qty/level)",
|
||||||
|
"modeRelative": "Relative (since creation)",
|
||||||
|
"modeRelativeHint": "Relative goal – progress since creation",
|
||||||
|
"typeSkill": "Skill",
|
||||||
|
"currentLevel": "Current: level {level}",
|
||||||
|
"missing": "{qty} still needed",
|
||||||
|
"missingShort": "−{qty}",
|
||||||
|
"etaSnapshots": "~{n} import(s)",
|
||||||
|
"hasGoal": "Open goal",
|
||||||
|
"total": "total",
|
||||||
|
"renameGroup": "Rename",
|
||||||
|
"renameGroupPrompt": "New group name:",
|
||||||
"selectGroup": "Group",
|
"selectGroup": "Group",
|
||||||
"noGroup": "No group",
|
"noGroup": "No group",
|
||||||
"newGroup": "New group…",
|
"newGroup": "New group…",
|
||||||
|
|||||||
@@ -918,6 +918,116 @@ body.inv-chart-modal-open {
|
|||||||
.col-actions { width: 48px; text-align: center; }
|
.col-actions { width: 48px; text-align: center; }
|
||||||
.goals-completed-banner { margin-bottom: 12px; }
|
.goals-completed-banner { margin-bottom: 12px; }
|
||||||
.goal-group-completed-line { font-weight: 600; }
|
.goal-group-completed-line { font-weight: 600; }
|
||||||
|
.goal-mode-badge,
|
||||||
|
.goal-type-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
.goal-eta,
|
||||||
|
.goal-missing {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.goal-missing-summary {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.goal-missing-summary span + span::before { content: " · "; }
|
||||||
|
.goals-overview-kpi {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
.goals-kpi-item { font-size: 0.85rem; color: var(--text-muted); }
|
||||||
|
.goals-kpi-item strong { color: var(--text); margin-right: 4px; }
|
||||||
|
.goal-group-rename,
|
||||||
|
.snapshot-delete-btn {
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
.goal-group-rename:hover,
|
||||||
|
.snapshot-delete-btn:hover:not(:disabled) { background: var(--accent-dim); color: #fff; }
|
||||||
|
.snapshot-delete-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
.goal-mark {
|
||||||
|
margin-left: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
tr.has-goal .col-name,
|
||||||
|
tr.has-goal td:first-child { font-weight: 600; }
|
||||||
|
.kpi-goals .kpi-value { color: var(--accent); }
|
||||||
|
|
||||||
|
.global-search-wrap {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
max-width: 480px;
|
||||||
|
}
|
||||||
|
.global-search-results {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 6px;
|
||||||
|
max-width: 480px;
|
||||||
|
}
|
||||||
|
.global-search-hit {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 6px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.global-search-hit:hover { background: var(--bg-hover); }
|
||||||
|
.global-search-hit-type {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
min-width: 48px;
|
||||||
|
}
|
||||||
|
.global-search-hit-name { flex: 1; font-weight: 600; }
|
||||||
|
.global-search-hit-sub { color: var(--text-muted); font-size: 0.8rem; }
|
||||||
|
.global-search-empty {
|
||||||
|
margin: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-changes-card { margin-bottom: 16px; }
|
||||||
|
.import-changes-card h3 { margin: 0; font-size: 1rem; }
|
||||||
|
.import-changes-summary { margin: 8px 0; font-size: 0.9rem; }
|
||||||
|
.import-changes-stats { margin-bottom: 8px; }
|
||||||
|
.import-changes-card h4 {
|
||||||
|
margin: 12px 0 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-btn {
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.goal-modal {
|
.goal-modal {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@@ -43,6 +43,7 @@
|
|||||||
<span data-i18n="actions.importBackup">Import backup</span>
|
<span data-i18n="actions.importBackup">Import backup</span>
|
||||||
<input type="file" id="file-upload" accept=".json" hidden>
|
<input type="file" id="file-upload" accept=".json" hidden>
|
||||||
</label>
|
</label>
|
||||||
|
<a class="upload-btn export-btn" id="export-viewer" href="#" data-i18n="actions.exportViewer">Export viewer</a>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -60,6 +61,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="import-report" class="import-report" hidden></div>
|
<div id="import-report" class="import-report" hidden></div>
|
||||||
<div id="goals-completed-banner" class="goals-completed-banner" hidden></div>
|
<div id="goals-completed-banner" class="goals-completed-banner" hidden></div>
|
||||||
|
<div class="global-search-wrap">
|
||||||
|
<input type="search" class="search-input global-search" id="global-search" placeholder="">
|
||||||
|
</div>
|
||||||
|
<div id="global-search-results" class="global-search-results" hidden></div>
|
||||||
<div id="character-header" class="character-header">
|
<div id="character-header" class="character-header">
|
||||||
<span class="loading" data-i18n="app.loading">Loading save…</span>
|
<span class="loading" data-i18n="app.loading">Loading save…</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -81,6 +86,13 @@
|
|||||||
<div class="goal-modal-card" role="dialog" aria-labelledby="goal-modal-title">
|
<div class="goal-modal-card" role="dialog" aria-labelledby="goal-modal-title">
|
||||||
<h3 id="goal-modal-title"></h3>
|
<h3 id="goal-modal-title"></h3>
|
||||||
<p class="goal-modal-item" id="goal-modal-item"></p>
|
<p class="goal-modal-item" id="goal-modal-item"></p>
|
||||||
|
<label class="goal-modal-field">
|
||||||
|
<span id="goal-modal-mode-label"></span>
|
||||||
|
<select class="select-input" id="goal-modal-mode">
|
||||||
|
<option value="absolute"></option>
|
||||||
|
<option value="relative"></option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<label class="goal-modal-field">
|
<label class="goal-modal-field">
|
||||||
<span id="goal-modal-qty-label"></span>
|
<span id="goal-modal-qty-label"></span>
|
||||||
<input type="number" class="search-input" id="goal-modal-qty" min="1" step="1">
|
<input type="number" class="search-input" id="goal-modal-qty" min="1" step="1">
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""Smoke tests for extended goals / import helpers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from db import (
|
||||||
|
create_goal,
|
||||||
|
create_goal_group,
|
||||||
|
create_skill_goal,
|
||||||
|
delete_snapshot,
|
||||||
|
get_connection,
|
||||||
|
goals_overview,
|
||||||
|
import_save,
|
||||||
|
init_db,
|
||||||
|
list_goals_structured,
|
||||||
|
rename_goal_group,
|
||||||
|
skill_timeline,
|
||||||
|
summarize_import_changes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _minimal_save(coins: int = 100, level: int = 5) -> dict:
|
||||||
|
return {
|
||||||
|
"exported_at": 1_700_000_000_000 + coins,
|
||||||
|
"coins": coins,
|
||||||
|
"character_name": "Tester",
|
||||||
|
"skillLevels": {"mining": 10, "woodcutting": 5},
|
||||||
|
"skillXp": {"mining": 1000, "woodcutting": 200},
|
||||||
|
"inventory": {"iron_ore": 50, "logs": 20},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_relative_and_skill_goals() -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
db = Path(td) / "test.db"
|
||||||
|
conn = get_connection(db)
|
||||||
|
init_db(conn)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
save_path = Path(td) / "save.json"
|
||||||
|
save_path.write_text(json.dumps(_minimal_save()), encoding="utf-8")
|
||||||
|
r1 = import_save(save_path, db_path=db)
|
||||||
|
assert r1["imported"]
|
||||||
|
|
||||||
|
gid = create_goal_group("Mining", db_path=db)["id"]
|
||||||
|
goal = create_goal("iron_ore", 20, group_id=gid, mode="relative", db_path=db)
|
||||||
|
assert goal["mode"] == "relative"
|
||||||
|
assert goal["baseline_qty"] == 50
|
||||||
|
assert not goal["completed_at"]
|
||||||
|
|
||||||
|
skill_goal = create_skill_goal("mining", 15, mode="absolute", db_path=db)
|
||||||
|
assert skill_goal["goal_type"] == "skill"
|
||||||
|
assert skill_goal["skill_key"] == "mining"
|
||||||
|
|
||||||
|
overview = goals_overview(db_path=db)
|
||||||
|
assert overview["total"] == 2
|
||||||
|
assert overview["open"] == 2
|
||||||
|
|
||||||
|
save_path.write_text(json.dumps(_minimal_save(coins=200, level=6)), encoding="utf-8")
|
||||||
|
r2 = import_save(save_path, db_path=db)
|
||||||
|
assert r2["imported"]
|
||||||
|
assert r2["import_changes"]["has_previous"]
|
||||||
|
assert r2["import_changes"]["coins_delta"] == 100
|
||||||
|
|
||||||
|
structured = list_goals_structured(db_path=db)
|
||||||
|
assert len(structured["groups"]) == 1
|
||||||
|
assert rename_goal_group(gid, "Mining Goals", db_path=db)
|
||||||
|
|
||||||
|
tl = skill_timeline(db_path=db)
|
||||||
|
assert len(tl["snapshots"]) == 2
|
||||||
|
assert "mining" in tl["series"]
|
||||||
|
|
||||||
|
snaps = r2.get("snapshot_id")
|
||||||
|
assert delete_snapshot(snaps - 1, db_path=db)
|
||||||
|
changes = summarize_import_changes(snaps, db_path=db)
|
||||||
|
assert changes["has_previous"] is False
|
||||||
|
|
||||||
|
print("all tests passed")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_relative_and_skill_goals()
|
||||||
Reference in New Issue
Block a user