Add grouped inventory goals with import completion notifications.
Players can create named goal groups, set absolute item targets from inventory, track progress in a new Goals tab, and get banners when uploads complete goals or entire groups. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -14,12 +14,18 @@ from werkzeug.utils import secure_filename
|
|||||||
|
|
||||||
from db import (
|
from db import (
|
||||||
DEFAULT_DB,
|
DEFAULT_DB,
|
||||||
|
create_goal,
|
||||||
|
create_goal_group,
|
||||||
|
delete_goal,
|
||||||
|
delete_goal_group,
|
||||||
diff_snapshots,
|
diff_snapshots,
|
||||||
get_latest_snapshot,
|
get_latest_snapshot,
|
||||||
get_snapshot,
|
get_snapshot,
|
||||||
import_save,
|
import_save,
|
||||||
init_db,
|
init_db,
|
||||||
inventory_timeline,
|
inventory_timeline,
|
||||||
|
list_goal_groups,
|
||||||
|
list_goals_structured,
|
||||||
list_snapshots,
|
list_snapshots,
|
||||||
get_connection,
|
get_connection,
|
||||||
timeline,
|
timeline,
|
||||||
@@ -121,6 +127,81 @@ 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/goal-groups")
|
||||||
|
def api_goal_groups(viewer_id: str):
|
||||||
|
db_path = _resolve_viewer_db(viewer_id)
|
||||||
|
return jsonify(list_goal_groups(db_path=db_path))
|
||||||
|
|
||||||
|
|
||||||
|
@viewer_bp.route("/api/goal-groups", methods=["POST"])
|
||||||
|
def api_create_goal_group(viewer_id: str):
|
||||||
|
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:
|
||||||
|
group = create_goal_group(name, db_path=db_path)
|
||||||
|
except ValueError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 400
|
||||||
|
return jsonify(group), 201
|
||||||
|
|
||||||
|
|
||||||
|
@viewer_bp.route("/api/goal-groups/<int:group_id>", methods=["DELETE"])
|
||||||
|
def api_delete_goal_group(viewer_id: str, group_id: int):
|
||||||
|
db_path = _resolve_viewer_db(viewer_id)
|
||||||
|
if not delete_goal_group(group_id, db_path=db_path):
|
||||||
|
return jsonify({"error": "Goal group not found"}), 404
|
||||||
|
return jsonify({"deleted": True})
|
||||||
|
|
||||||
|
|
||||||
|
@viewer_bp.route("/api/goals")
|
||||||
|
def api_goals(viewer_id: str):
|
||||||
|
db_path = _resolve_viewer_db(viewer_id)
|
||||||
|
return jsonify(list_goals_structured(db_path=db_path))
|
||||||
|
|
||||||
|
|
||||||
|
@viewer_bp.route("/api/goals", methods=["POST"])
|
||||||
|
def api_create_goal(viewer_id: str):
|
||||||
|
db_path = _resolve_viewer_db(viewer_id)
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
item_key = (body.get("item_key") or "").strip()
|
||||||
|
target_qty = body.get("target_qty")
|
||||||
|
group_id = body.get("group_id")
|
||||||
|
|
||||||
|
if not item_key:
|
||||||
|
return jsonify({"error": "item_key is required"}), 400
|
||||||
|
try:
|
||||||
|
target_qty = int(target_qty)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({"error": "target_qty must be a positive integer"}), 400
|
||||||
|
if group_id is not None:
|
||||||
|
try:
|
||||||
|
group_id = int(group_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({"error": "group_id must be an integer"}), 400
|
||||||
|
|
||||||
|
if not get_latest_snapshot(db_path=db_path):
|
||||||
|
return jsonify({"error": "No snapshots imported yet"}), 404
|
||||||
|
|
||||||
|
try:
|
||||||
|
goal = create_goal(item_key, target_qty, group_id=group_id, db_path=db_path)
|
||||||
|
except ValueError as exc:
|
||||||
|
msg = str(exc)
|
||||||
|
status = 404 if "not found" in msg.lower() else 409 if "already exists" in msg.lower() else 400
|
||||||
|
return jsonify({"error": msg}), status
|
||||||
|
|
||||||
|
return jsonify(goal), 201
|
||||||
|
|
||||||
|
|
||||||
|
@viewer_bp.route("/api/goals/<int:goal_id>", methods=["DELETE"])
|
||||||
|
def api_delete_goal(viewer_id: str, goal_id: int):
|
||||||
|
db_path = _resolve_viewer_db(viewer_id)
|
||||||
|
if not delete_goal(goal_id, db_path=db_path):
|
||||||
|
return jsonify({"error": "Goal not found"}), 404
|
||||||
|
return jsonify({"deleted": True})
|
||||||
|
|
||||||
|
|
||||||
@viewer_bp.route("/api/import", methods=["POST"])
|
@viewer_bp.route("/api/import", methods=["POST"])
|
||||||
@limiter.limit(IMPORT_LIMIT)
|
@limiter.limit(IMPORT_LIMIT)
|
||||||
def api_import(viewer_id: str):
|
def api_import(viewer_id: str):
|
||||||
|
|||||||
@@ -57,6 +57,21 @@ def init_db(conn: sqlite3.Connection) -> None:
|
|||||||
FOREIGN KEY (snapshot_id) REFERENCES snapshots(id) ON DELETE CASCADE
|
FOREIGN KEY (snapshot_id) REFERENCES snapshots(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_snapshots_exported ON snapshots(exported_at);
|
CREATE INDEX IF NOT EXISTS idx_snapshots_exported ON snapshots(exported_at);
|
||||||
|
CREATE TABLE IF NOT EXISTS goal_groups (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS goals (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
group_id INTEGER REFERENCES goal_groups(id) ON DELETE SET NULL,
|
||||||
|
item_key TEXT NOT NULL UNIQUE,
|
||||||
|
item_name TEXT NOT NULL,
|
||||||
|
category TEXT NOT NULL,
|
||||||
|
target_qty INTEGER NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
completed_at TEXT
|
||||||
|
);
|
||||||
""")
|
""")
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
@@ -143,6 +158,8 @@ def import_save(
|
|||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
goals_result = check_goals_after_import(snapshot_id, conn=conn, db_path=db_path)
|
||||||
|
|
||||||
if own_conn:
|
if own_conn:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@@ -151,6 +168,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"),
|
||||||
|
**goals_result,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -342,3 +360,302 @@ def inventory_timeline(db_path: Path | str = DEFAULT_DB) -> dict[str, Any]:
|
|||||||
series[key][idx] = row["qty"]
|
series[key][idx] = row["qty"]
|
||||||
|
|
||||||
return {"snapshots": snapshots, "series": series}
|
return {"snapshots": snapshots, "series": series}
|
||||||
|
|
||||||
|
|
||||||
|
def _latest_snapshot_id(conn: sqlite3.Connection) -> int | None:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT id FROM snapshots ORDER BY exported_at DESC, id DESC LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
return row["id"] if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def _inventory_qty_for_snapshot(conn: sqlite3.Connection, snapshot_id: int, item_key: str) -> int:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT qty FROM inventory_snapshots WHERE snapshot_id = ? AND item_key = ?",
|
||||||
|
(snapshot_id, item_key),
|
||||||
|
).fetchone()
|
||||||
|
return row["qty"] if row else 0
|
||||||
|
|
||||||
|
|
||||||
|
def _goal_row_to_dict(row: sqlite3.Row, current_qty: int) -> dict[str, Any]:
|
||||||
|
target = row["target_qty"]
|
||||||
|
progress_pct = min(100, int(current_qty / target * 100)) if target > 0 else 0
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"group_id": row["group_id"],
|
||||||
|
"group_name": row["group_name"],
|
||||||
|
"item_key": row["item_key"],
|
||||||
|
"item_name": row["item_name"],
|
||||||
|
"category": row["category"],
|
||||||
|
"target_qty": target,
|
||||||
|
"current_qty": current_qty,
|
||||||
|
"progress_pct": progress_pct,
|
||||||
|
"completed_at": row["completed_at"],
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_goal_groups(db_path: Path | str = DEFAULT_DB) -> list[dict[str, Any]]:
|
||||||
|
conn = get_connection(db_path)
|
||||||
|
init_db(conn)
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT g.id, g.name, g.created_at,
|
||||||
|
COUNT(go.id) AS total,
|
||||||
|
SUM(CASE WHEN go.completed_at IS NOT NULL THEN 1 ELSE 0 END) AS completed
|
||||||
|
FROM goal_groups g
|
||||||
|
LEFT JOIN goals go ON go.group_id = g.id
|
||||||
|
GROUP BY g.id
|
||||||
|
ORDER BY g.created_at ASC, g.id ASC
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
conn.close()
|
||||||
|
result = []
|
||||||
|
for r in rows:
|
||||||
|
total = r["total"] or 0
|
||||||
|
completed = r["completed"] or 0
|
||||||
|
result.append({
|
||||||
|
"id": r["id"],
|
||||||
|
"name": r["name"],
|
||||||
|
"created_at": r["created_at"],
|
||||||
|
"total": total,
|
||||||
|
"completed": completed,
|
||||||
|
"open": total - completed,
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def create_goal_group(name: str, db_path: Path | str = DEFAULT_DB) -> dict[str, Any]:
|
||||||
|
name = name.strip()
|
||||||
|
if not name:
|
||||||
|
raise ValueError("Group name is required")
|
||||||
|
conn = get_connection(db_path)
|
||||||
|
init_db(conn)
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO goal_groups (name, created_at) VALUES (?, ?)",
|
||||||
|
(name, _utc_now_iso()),
|
||||||
|
)
|
||||||
|
group_id = cur.lastrowid
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return {"id": group_id, "name": name, "total": 0, "completed": 0, "open": 0}
|
||||||
|
|
||||||
|
|
||||||
|
def delete_goal_group(group_id: int, db_path: Path | str = DEFAULT_DB) -> bool:
|
||||||
|
conn = get_connection(db_path)
|
||||||
|
init_db(conn)
|
||||||
|
row = conn.execute("SELECT id FROM goal_groups WHERE id = ?", (group_id,)).fetchone()
|
||||||
|
if not row:
|
||||||
|
conn.close()
|
||||||
|
return False
|
||||||
|
conn.execute("UPDATE goals SET group_id = NULL WHERE group_id = ?", (group_id,))
|
||||||
|
conn.execute("DELETE FROM goal_groups WHERE id = ?", (group_id,))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_goals_with_qty(conn: sqlite3.Connection, snapshot_id: int | None) -> list[dict[str, Any]]:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT go.id, go.group_id, gg.name AS group_name, go.item_key, go.item_name,
|
||||||
|
go.category, go.target_qty, go.created_at, go.completed_at
|
||||||
|
FROM goals go
|
||||||
|
LEFT JOIN goal_groups gg ON gg.id = go.group_id
|
||||||
|
ORDER BY go.created_at ASC, go.id ASC
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
goals = []
|
||||||
|
for row in rows:
|
||||||
|
current_qty = _inventory_qty_for_snapshot(conn, snapshot_id, row["item_key"]) if snapshot_id else 0
|
||||||
|
goals.append(_goal_row_to_dict(row, current_qty))
|
||||||
|
return goals
|
||||||
|
|
||||||
|
|
||||||
|
def list_goals_structured(db_path: Path | str = DEFAULT_DB) -> dict[str, Any]:
|
||||||
|
conn = get_connection(db_path)
|
||||||
|
init_db(conn)
|
||||||
|
snapshot_id = _latest_snapshot_id(conn)
|
||||||
|
all_goals = _fetch_goals_with_qty(conn, snapshot_id)
|
||||||
|
|
||||||
|
groups_map: dict[int, dict[str, Any]] = {}
|
||||||
|
ungrouped: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
for goal in all_goals:
|
||||||
|
if goal["group_id"] is None:
|
||||||
|
ungrouped.append(goal)
|
||||||
|
continue
|
||||||
|
gid = goal["group_id"]
|
||||||
|
if gid not in groups_map:
|
||||||
|
groups_map[gid] = {
|
||||||
|
"id": gid,
|
||||||
|
"name": goal["group_name"],
|
||||||
|
"total": 0,
|
||||||
|
"completed": 0,
|
||||||
|
"goals": [],
|
||||||
|
}
|
||||||
|
groups_map[gid]["goals"].append(goal)
|
||||||
|
groups_map[gid]["total"] += 1
|
||||||
|
if goal["completed_at"]:
|
||||||
|
groups_map[gid]["completed"] += 1
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
return {
|
||||||
|
"groups": list(groups_map.values()),
|
||||||
|
"ungrouped": ungrouped,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_item_from_latest(
|
||||||
|
conn: sqlite3.Connection, item_key: str
|
||||||
|
) -> dict[str, str] | None:
|
||||||
|
snapshot_id = _latest_snapshot_id(conn)
|
||||||
|
if not snapshot_id:
|
||||||
|
return None
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT item_key, qty, category FROM inventory_snapshots WHERE snapshot_id = ? AND item_key = ?",
|
||||||
|
(snapshot_id, item_key),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
latest = get_latest_snapshot(conn=conn)
|
||||||
|
item_name = item_key.replace("_", " ").title()
|
||||||
|
if latest:
|
||||||
|
for item in latest.get("inventory", []):
|
||||||
|
if item["key"] == item_key:
|
||||||
|
item_name = item["name"]
|
||||||
|
break
|
||||||
|
return {"item_key": item_key, "item_name": item_name, "category": row["category"], "qty": row["qty"]}
|
||||||
|
|
||||||
|
|
||||||
|
def create_goal(
|
||||||
|
item_key: str,
|
||||||
|
target_qty: int,
|
||||||
|
group_id: int | None = None,
|
||||||
|
db_path: Path | str = DEFAULT_DB,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if target_qty <= 0:
|
||||||
|
raise ValueError("Target quantity must be positive")
|
||||||
|
|
||||||
|
conn = get_connection(db_path)
|
||||||
|
init_db(conn)
|
||||||
|
|
||||||
|
existing = conn.execute("SELECT id FROM goals WHERE item_key = ?", (item_key,)).fetchone()
|
||||||
|
if existing:
|
||||||
|
conn.close()
|
||||||
|
raise ValueError("A goal for this item 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")
|
||||||
|
|
||||||
|
item = _resolve_item_from_latest(conn, item_key)
|
||||||
|
if not item:
|
||||||
|
conn.close()
|
||||||
|
raise ValueError("Item not found in current inventory")
|
||||||
|
|
||||||
|
now = _utc_now_iso()
|
||||||
|
completed_at = now if item["qty"] >= target_qty else None
|
||||||
|
cur = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO goals (group_id, item_key, item_name, category, target_qty, created_at, completed_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(group_id, item_key, item["item_name"], item["category"], target_qty, now, completed_at),
|
||||||
|
)
|
||||||
|
goal_id = cur.lastrowid
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT go.id, go.group_id, gg.name AS group_name, go.item_key, go.item_name,
|
||||||
|
go.category, go.target_qty, go.created_at, go.completed_at
|
||||||
|
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, item["qty"])
|
||||||
|
|
||||||
|
|
||||||
|
def delete_goal(goal_id: int, db_path: Path | str = DEFAULT_DB) -> bool:
|
||||||
|
conn = get_connection(db_path)
|
||||||
|
init_db(conn)
|
||||||
|
cur = conn.execute("DELETE FROM goals WHERE id = ?", (goal_id,))
|
||||||
|
conn.commit()
|
||||||
|
deleted = cur.rowcount > 0
|
||||||
|
conn.close()
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
|
||||||
|
def check_goals_after_import(
|
||||||
|
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)
|
||||||
|
|
||||||
|
open_goals = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT go.id, go.group_id, gg.name AS group_name, go.item_key, go.item_name,
|
||||||
|
go.target_qty
|
||||||
|
FROM goals go
|
||||||
|
LEFT JOIN goal_groups gg ON gg.id = go.group_id
|
||||||
|
WHERE go.completed_at IS NULL
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
now = _utc_now_iso()
|
||||||
|
goals_completed: list[dict[str, Any]] = []
|
||||||
|
groups_touched: set[int] = set()
|
||||||
|
|
||||||
|
for goal in open_goals:
|
||||||
|
current_qty = _inventory_qty_for_snapshot(conn, snapshot_id, goal["item_key"])
|
||||||
|
if current_qty >= goal["target_qty"]:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE goals SET completed_at = ? WHERE id = ?",
|
||||||
|
(now, goal["id"]),
|
||||||
|
)
|
||||||
|
entry = {
|
||||||
|
"id": goal["id"],
|
||||||
|
"item_key": goal["item_key"],
|
||||||
|
"item_name": goal["item_name"],
|
||||||
|
"target_qty": goal["target_qty"],
|
||||||
|
"current_qty": current_qty,
|
||||||
|
"group_id": goal["group_id"],
|
||||||
|
"group_name": goal["group_name"],
|
||||||
|
}
|
||||||
|
goals_completed.append(entry)
|
||||||
|
if goal["group_id"] is not None:
|
||||||
|
groups_touched.add(goal["group_id"])
|
||||||
|
|
||||||
|
groups_completed: list[dict[str, Any]] = []
|
||||||
|
for gid in groups_touched:
|
||||||
|
stats = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS total,
|
||||||
|
SUM(CASE WHEN completed_at IS NOT NULL THEN 1 ELSE 0 END) AS completed
|
||||||
|
FROM goals WHERE group_id = ?
|
||||||
|
""",
|
||||||
|
(gid,),
|
||||||
|
).fetchone()
|
||||||
|
if stats and stats["total"] and stats["total"] == stats["completed"]:
|
||||||
|
g = conn.execute("SELECT id, name FROM goal_groups WHERE id = ?", (gid,)).fetchone()
|
||||||
|
if g:
|
||||||
|
groups_completed.append({"id": g["id"], "name": g["name"]})
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
if own_conn:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"goals_completed": goals_completed,
|
||||||
|
"groups_completed": groups_completed,
|
||||||
|
}
|
||||||
|
|||||||
+353
-2
@@ -7,6 +7,8 @@ let state = {
|
|||||||
inventory: { search: "", categories: new Set(), sort: "category", highlightEquipped: false, collapsedGroups: new Set() },
|
inventory: { search: "", categories: new Set(), sort: "category", highlightEquipped: false, collapsedGroups: new Set() },
|
||||||
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: [] } },
|
||||||
|
goalModalItem: null,
|
||||||
history: { olderId: null, newerId: null, diff: null },
|
history: { olderId: null, newerId: null, diff: null },
|
||||||
charts: {},
|
charts: {},
|
||||||
inventoryTimeline: null,
|
inventoryTimeline: null,
|
||||||
@@ -67,6 +69,7 @@ async function init() {
|
|||||||
setupViewerBanner();
|
setupViewerBanner();
|
||||||
setupNav();
|
setupNav();
|
||||||
setupUpload();
|
setupUpload();
|
||||||
|
setupGoalModal();
|
||||||
await loadData();
|
await loadData();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,6 +157,7 @@ function setupUpload() {
|
|||||||
if (result.imported) {
|
if (result.imported) {
|
||||||
await loadData();
|
await loadData();
|
||||||
notifyImportSuccess(result);
|
notifyImportSuccess(result);
|
||||||
|
showGoalsCompletedBanner(result);
|
||||||
} else if (result.reason === "duplicate") {
|
} else if (result.reason === "duplicate") {
|
||||||
alert(t("import.duplicate"));
|
alert(t("import.duplicate"));
|
||||||
}
|
}
|
||||||
@@ -233,9 +237,13 @@ function renderImportReport(meta) {
|
|||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${apiBase()}/snapshot/latest`);
|
const [res] = await Promise.all([
|
||||||
|
fetch(`${apiBase()}/snapshot/latest`),
|
||||||
|
loadGoals(),
|
||||||
|
]);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
showEmpty(getViewerId() ? t("empty.noSaveWeb") : t("empty.noSave"));
|
showEmpty(getViewerId() ? t("empty.noSaveWeb") : t("empty.noSave"));
|
||||||
|
renderGoals();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
state.data = await res.json();
|
state.data = await res.json();
|
||||||
@@ -243,6 +251,7 @@ async function loadData() {
|
|||||||
renderAll();
|
renderAll();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showEmpty(t("empty.loadError", { message: err.message }));
|
showEmpty(t("empty.loadError", { message: err.message }));
|
||||||
|
renderGoals();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,6 +268,7 @@ function renderAll() {
|
|||||||
renderOverview(d);
|
renderOverview(d);
|
||||||
renderSkills(d);
|
renderSkills(d);
|
||||||
renderInventory(d);
|
renderInventory(d);
|
||||||
|
renderGoals();
|
||||||
renderEquipment(d);
|
renderEquipment(d);
|
||||||
renderQuests(d);
|
renderQuests(d);
|
||||||
renderCombat(d);
|
renderCombat(d);
|
||||||
@@ -560,7 +570,7 @@ function bindInventorySparklines(container) {
|
|||||||
function renderInventoryTable(d, inv) {
|
function renderInventoryTable(d, inv) {
|
||||||
const items = getFilteredInventoryItems(d, inv);
|
const items = getFilteredInventoryItems(d, inv);
|
||||||
const showTrend = inventoryTrendEnabled();
|
const showTrend = inventoryTrendEnabled();
|
||||||
const colSpan = showTrend ? 4 : 3;
|
const colSpan = showTrend ? 5 : 4;
|
||||||
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] = [];
|
||||||
@@ -586,6 +596,9 @@ function renderInventoryTable(d, inv) {
|
|||||||
${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">
|
||||||
|
<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>
|
||||||
</tr>`).join("");
|
</tr>`).join("");
|
||||||
return header + rows;
|
return header + rows;
|
||||||
}).join("");
|
}).join("");
|
||||||
@@ -611,6 +624,7 @@ function renderInventoryTable(d, inv) {
|
|||||||
${trendCol}
|
${trendCol}
|
||||||
<col class="col-qty">
|
<col class="col-qty">
|
||||||
<col class="col-key">
|
<col class="col-key">
|
||||||
|
<col class="col-actions">
|
||||||
</colgroup>
|
</colgroup>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -618,12 +632,23 @@ function renderInventoryTable(d, inv) {
|
|||||||
${trendHeader}
|
${trendHeader}
|
||||||
<th class="col-qty">${esc(t("inventory.qty"))}</th>
|
<th class="col-qty">${esc(t("inventory.qty"))}</th>
|
||||||
<th class="col-key">${esc(t("inventory.id"))}</th>
|
<th class="col-key">${esc(t("inventory.id"))}</th>
|
||||||
|
<th class="col-actions">${esc(t("goals.actions"))}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>${groupRows}</tbody>
|
<tbody>${groupRows}</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
|
results.querySelectorAll(".goal-add-btn").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
openGoalModal({
|
||||||
|
key: btn.dataset.itemKey,
|
||||||
|
name: btn.dataset.itemName,
|
||||||
|
qty: Number(btn.dataset.itemQty),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
results.querySelectorAll(".inv-group-toggle").forEach((btn) => {
|
results.querySelectorAll(".inv-group-toggle").forEach((btn) => {
|
||||||
btn.addEventListener("click", () => {
|
btn.addEventListener("click", () => {
|
||||||
const group = btn.dataset.group;
|
const group = btn.dataset.group;
|
||||||
@@ -705,6 +730,332 @@ function renderInventory(d) {
|
|||||||
ensureInventoryTimeline().then(() => renderInventoryTable(d, inv));
|
ensureInventoryTimeline().then(() => renderInventoryTable(d, inv));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadGoals() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${apiBase()}/goals`);
|
||||||
|
state.goals.data = res.ok ? await res.json() : { groups: [], ungrouped: [] };
|
||||||
|
} catch {
|
||||||
|
state.goals.data = { groups: [], ungrouped: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goalMatchesFilter(goal, filter) {
|
||||||
|
if (filter === "open") return !goal.completed_at;
|
||||||
|
if (filter === "done") return !!goal.completed_at;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGoalRow(goal) {
|
||||||
|
const done = !!goal.completed_at;
|
||||||
|
const deleteBtn = done
|
||||||
|
? `<button type="button" class="goal-delete-btn" data-goal-id="${goal.id}">${esc(t("goals.delete"))}</button>`
|
||||||
|
: "";
|
||||||
|
return `<tr>
|
||||||
|
<td>${esc(goal.item_name)}</td>
|
||||||
|
<td>
|
||||||
|
<div class="goal-progress-text">${fmt(goal.current_qty)} / ${fmt(goal.target_qty)}</div>
|
||||||
|
<div class="progress-bar"><div class="progress-fill" style="width:${goal.progress_pct}%"></div></div>
|
||||||
|
</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>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGoalsTableBody(goals) {
|
||||||
|
if (!goals.length) {
|
||||||
|
return `<tr><td colspan="4" class="empty-state">${esc(t("goals.empty"))}</td></tr>`;
|
||||||
|
}
|
||||||
|
return goals.map(renderGoalRow).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindGoalActions(panel) {
|
||||||
|
panel.querySelectorAll(".goal-delete-btn").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", async () => {
|
||||||
|
if (!confirm(t("goals.deleteConfirm"))) return;
|
||||||
|
const res = await fetch(`${apiBase()}/goals/${btn.dataset.goalId}`, { method: "DELETE" });
|
||||||
|
if (res.ok) await loadGoals();
|
||||||
|
renderGoals();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
panel.querySelectorAll(".goal-group-delete").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", async () => {
|
||||||
|
const name = btn.dataset.groupName;
|
||||||
|
if (!confirm(t("goals.deleteGroupConfirm", { name }))) return;
|
||||||
|
const res = await fetch(`${apiBase()}/goal-groups/${btn.dataset.groupId}`, { method: "DELETE" });
|
||||||
|
if (res.ok) await loadGoals();
|
||||||
|
renderGoals();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
panel.querySelectorAll(".goal-clear-completed").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", async () => {
|
||||||
|
const ids = (btn.dataset.goalIds || "").split(",").filter(Boolean);
|
||||||
|
for (const id of ids) {
|
||||||
|
await fetch(`${apiBase()}/goals/${id}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
await loadGoals();
|
||||||
|
renderGoals();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
panel.querySelectorAll(".goal-group-toggle").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
const group = btn.dataset.group;
|
||||||
|
const expanded = btn.getAttribute("aria-expanded") === "true";
|
||||||
|
btn.setAttribute("aria-expanded", expanded ? "false" : "true");
|
||||||
|
if (expanded) state.goals.collapsedGroups.add(group);
|
||||||
|
else state.goals.collapsedGroups.delete(group);
|
||||||
|
panel.querySelectorAll(`.goal-item-row[data-group="${group}"]`).forEach((row) => {
|
||||||
|
row.classList.toggle("collapsed", expanded);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGoals() {
|
||||||
|
const panel = document.getElementById("tab-goals");
|
||||||
|
const g = state.goals;
|
||||||
|
const filter = g.filter;
|
||||||
|
const data = g.data || { groups: [], ungrouped: [] };
|
||||||
|
|
||||||
|
const groupSections = data.groups.map((group) => {
|
||||||
|
const goals = (group.goals || []).filter((goal) => goalMatchesFilter(goal, filter));
|
||||||
|
if (!goals.length && filter !== "all") return "";
|
||||||
|
const completed = goals.filter((goal) => goal.completed_at).length;
|
||||||
|
const total = goals.length;
|
||||||
|
const sectionKey = `group-${group.id}`;
|
||||||
|
const expanded = !g.collapsedGroups.has(sectionKey);
|
||||||
|
const completedIds = goals.filter((goal) => goal.completed_at).map((goal) => goal.id);
|
||||||
|
const headerActions = `
|
||||||
|
${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-delete" data-group-id="${group.id}" data-group-name="${esc(group.name)}">${esc(t("goals.deleteGroup"))}</button>`;
|
||||||
|
const rows = goals.length
|
||||||
|
? goals.map((goal) => {
|
||||||
|
const row = renderGoalRow(goal);
|
||||||
|
return row.replace("<tr>", `<tr class="goal-item-row ${expanded ? "" : "collapsed"}" data-group="${sectionKey}">`);
|
||||||
|
}).join("")
|
||||||
|
: `<tr class="goal-item-row ${expanded ? "" : "collapsed"}" data-group="${sectionKey}"><td colspan="4" class="empty-state">${esc(t("goals.empty"))}</td></tr>`;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="card goals-group-card">
|
||||||
|
<table class="goals-table">
|
||||||
|
<tbody>
|
||||||
|
<tr class="inv-group-row goal-group-header">
|
||||||
|
<td colspan="4">
|
||||||
|
<button type="button" class="inv-group-toggle goal-group-toggle" data-group="${sectionKey}" aria-expanded="${expanded}">
|
||||||
|
<span class="inv-group-title">${esc(group.name)}</span>
|
||||||
|
<span class="inv-group-meta">${esc(t("goals.groupProgress", { completed, total }))}</span>
|
||||||
|
</button>
|
||||||
|
<span class="goal-group-actions">${headerActions}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
${rows}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>`;
|
||||||
|
}).join("");
|
||||||
|
|
||||||
|
const ungrouped = (data.ungrouped || []).filter((goal) => goalMatchesFilter(goal, filter));
|
||||||
|
const ungroupedSection = (filter === "all" || ungrouped.length) ? `
|
||||||
|
<div class="card goals-group-card">
|
||||||
|
<h3 class="goals-ungrouped-title">${esc(t("goals.ungrouped"))}</h3>
|
||||||
|
<table class="goals-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>${esc(t("goals.item"))}</th>
|
||||||
|
<th>${esc(t("goals.progress"))}</th>
|
||||||
|
<th>${esc(t("goals.status"))}</th>
|
||||||
|
<th>${esc(t("goals.actions"))}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>${renderGoalsTableBody(ungrouped)}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>` : "";
|
||||||
|
|
||||||
|
const hasAny = groupSections || ungrouped.length || filter === "all";
|
||||||
|
|
||||||
|
panel.innerHTML = `
|
||||||
|
<div class="toolbar goals-toolbar">
|
||||||
|
<select class="select-input" id="goals-filter">
|
||||||
|
<option value="all" ${filter === "all" ? "selected" : ""}>${esc(t("goals.filterAll"))}</option>
|
||||||
|
<option value="open" ${filter === "open" ? "selected" : ""}>${esc(t("goals.filterOpen"))}</option>
|
||||||
|
<option value="done" ${filter === "done" ? "selected" : ""}>${esc(t("goals.filterDone"))}</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" class="upload-btn" id="goals-create-group">${esc(t("goals.createGroup"))}</button>
|
||||||
|
</div>
|
||||||
|
${hasAny ? groupSections + ungroupedSection : `<div class="card"><p class="empty-state">${esc(t("goals.empty"))}</p></div>`}`;
|
||||||
|
|
||||||
|
document.getElementById("goals-filter").addEventListener("change", (e) => {
|
||||||
|
state.goals.filter = e.target.value;
|
||||||
|
renderGoals();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("goals-create-group").addEventListener("click", async () => {
|
||||||
|
const name = prompt(t("goals.createGroupPrompt"));
|
||||||
|
if (!name || !name.trim()) return;
|
||||||
|
const res = await fetch(`${apiBase()}/goal-groups`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name: name.trim() }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
alert(t("goals.groupCreateFailed"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await loadGoals();
|
||||||
|
renderGoals();
|
||||||
|
});
|
||||||
|
|
||||||
|
bindGoalActions(panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchGoalGroups() {
|
||||||
|
const res = await fetch(`${apiBase()}/goal-groups`);
|
||||||
|
return res.ok ? await res.json() : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupGoalModal() {
|
||||||
|
document.getElementById("goal-modal-cancel").addEventListener("click", closeGoalModal);
|
||||||
|
document.getElementById("goal-modal-backdrop").addEventListener("click", closeGoalModal);
|
||||||
|
document.getElementById("goal-modal-group").addEventListener("change", (e) => {
|
||||||
|
document.getElementById("goal-modal-new-group-wrap").hidden = e.target.value !== "new";
|
||||||
|
});
|
||||||
|
document.getElementById("goal-modal-submit").addEventListener("click", submitGoalModal);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyGoalModalI18n() {
|
||||||
|
document.getElementById("goal-modal-title").textContent = t("goals.modalTitle");
|
||||||
|
document.getElementById("goal-modal-qty-label").textContent = t("goals.targetQty");
|
||||||
|
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-cancel").textContent = t("goals.cancel");
|
||||||
|
document.getElementById("goal-modal-submit").textContent = t("inventory.addGoal");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openGoalModal(item) {
|
||||||
|
state.goalModalItem = item;
|
||||||
|
applyGoalModalI18n();
|
||||||
|
const modal = document.getElementById("goal-modal");
|
||||||
|
const errEl = document.getElementById("goal-modal-error");
|
||||||
|
errEl.hidden = true;
|
||||||
|
errEl.textContent = "";
|
||||||
|
|
||||||
|
document.getElementById("goal-modal-item").textContent = `${item.name} — ${t("goals.currentQty", { qty: fmt(item.qty) })}`;
|
||||||
|
document.getElementById("goal-modal-qty").value = Math.max(item.qty + 1, 1);
|
||||||
|
document.getElementById("goal-modal-new-group").value = "";
|
||||||
|
document.getElementById("goal-modal-new-group-wrap").hidden = true;
|
||||||
|
|
||||||
|
const groups = await fetchGoalGroups();
|
||||||
|
const sel = document.getElementById("goal-modal-group");
|
||||||
|
sel.innerHTML = `
|
||||||
|
<option value="">${esc(t("goals.noGroup"))}</option>
|
||||||
|
${groups.map((g) => `<option value="${g.id}">${esc(g.name)}</option>`).join("")}
|
||||||
|
<option value="new">${esc(t("goals.newGroup"))}</option>`;
|
||||||
|
|
||||||
|
modal.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeGoalModal() {
|
||||||
|
document.getElementById("goal-modal").hidden = true;
|
||||||
|
state.goalModalItem = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitGoalModal() {
|
||||||
|
const item = state.goalModalItem;
|
||||||
|
if (!item) return;
|
||||||
|
const errEl = document.getElementById("goal-modal-error");
|
||||||
|
errEl.hidden = true;
|
||||||
|
|
||||||
|
const targetQty = parseInt(document.getElementById("goal-modal-qty").value, 10);
|
||||||
|
if (!targetQty || targetQty <= 0) {
|
||||||
|
errEl.textContent = t("goals.createFailed");
|
||||||
|
errEl.hidden = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let groupId = null;
|
||||||
|
const groupVal = document.getElementById("goal-modal-group").value;
|
||||||
|
if (groupVal === "new") {
|
||||||
|
const name = document.getElementById("goal-modal-new-group").value.trim();
|
||||||
|
if (!name) {
|
||||||
|
errEl.textContent = t("goals.groupCreateFailed");
|
||||||
|
errEl.hidden = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const gRes = await fetch(`${apiBase()}/goal-groups`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
});
|
||||||
|
if (!gRes.ok) {
|
||||||
|
errEl.textContent = t("goals.groupCreateFailed");
|
||||||
|
errEl.hidden = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const gData = await gRes.json();
|
||||||
|
groupId = gData.id;
|
||||||
|
} else if (groupVal) {
|
||||||
|
groupId = parseInt(groupVal, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${apiBase()}/goals`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ item_key: item.key, target_qty: targetQty, group_id: groupId }),
|
||||||
|
});
|
||||||
|
const result = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
errEl.textContent = result.error || t("goals.createFailed");
|
||||||
|
errEl.hidden = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeGoalModal();
|
||||||
|
await loadGoals();
|
||||||
|
renderGoals();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showGoalsCompletedBanner(result) {
|
||||||
|
const el = document.getElementById("goals-completed-banner");
|
||||||
|
const completed = result.goals_completed || [];
|
||||||
|
const groupsDone = result.groups_completed || [];
|
||||||
|
if (!completed.length && !groupsDone.length) {
|
||||||
|
el.hidden = true;
|
||||||
|
el.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = completed.map((goal) => {
|
||||||
|
const groupPrefix = goal.group_name
|
||||||
|
? t("goals.completedItemGroup", { name: goal.group_name })
|
||||||
|
: "";
|
||||||
|
return `<li>${esc(t("goals.completedItem", {
|
||||||
|
group: groupPrefix,
|
||||||
|
name: goal.item_name,
|
||||||
|
current: fmt(goal.current_qty),
|
||||||
|
target: fmt(goal.target_qty),
|
||||||
|
}))}</li>`;
|
||||||
|
}).join("");
|
||||||
|
|
||||||
|
const groupLines = groupsDone.map((g) =>
|
||||||
|
`<li class="goal-group-completed-line">${esc(t("goals.groupCompleted", { name: g.name }))}</li>`
|
||||||
|
).join("");
|
||||||
|
|
||||||
|
el.hidden = false;
|
||||||
|
el.className = "goals-completed-banner import-report import-report-info";
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="import-report-header">
|
||||||
|
<strong>${esc(t("goals.completedBannerTitle"))}</strong>
|
||||||
|
<button type="button" class="import-report-dismiss" title="${esc(t("actions.dismiss"))}">×</button>
|
||||||
|
</div>
|
||||||
|
<ul class="import-report-list">${items}${groupLines}</ul>`;
|
||||||
|
|
||||||
|
el.querySelector(".import-report-dismiss").addEventListener("click", () => {
|
||||||
|
el.hidden = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function renderEquipment(d) {
|
function renderEquipment(d) {
|
||||||
document.getElementById("tab-equipment").innerHTML = `
|
document.getElementById("tab-equipment").innerHTML = `
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|||||||
+41
-1
@@ -8,6 +8,7 @@
|
|||||||
"overview": "Übersicht",
|
"overview": "Übersicht",
|
||||||
"skills": "Skills",
|
"skills": "Skills",
|
||||||
"inventory": "Inventar",
|
"inventory": "Inventar",
|
||||||
|
"goals": "Ziele",
|
||||||
"equipment": "Ausrüstung",
|
"equipment": "Ausrüstung",
|
||||||
"quests": "Quests",
|
"quests": "Quests",
|
||||||
"combat": "Kampf",
|
"combat": "Kampf",
|
||||||
@@ -119,7 +120,9 @@
|
|||||||
"groupMeta": "{count} Items · {qty} Stk.",
|
"groupMeta": "{count} Items · {qty} Stk.",
|
||||||
"trend": "Verlauf",
|
"trend": "Verlauf",
|
||||||
"trendExpand": "Klicken für großes Diagramm",
|
"trendExpand": "Klicken für großes Diagramm",
|
||||||
"trendExpandFor": "Mengenverlauf für {name}"
|
"trendExpandFor": "Mengenverlauf für {name}",
|
||||||
|
"addGoal": "Ziel hinzufügen",
|
||||||
|
"addGoalFor": "Ziel für {name} hinzufügen"
|
||||||
},
|
},
|
||||||
"equipment": {
|
"equipment": {
|
||||||
"title": "Ausrüstung"
|
"title": "Ausrüstung"
|
||||||
@@ -181,6 +184,7 @@
|
|||||||
},
|
},
|
||||||
"viewer": {
|
"viewer": {
|
||||||
"landingLead": "Erstelle deinen persönlichen Save-Viewer. Kein Konto – nur ein privater Link zu deinen Daten.",
|
"landingLead": "Erstelle deinen persönlichen Save-Viewer. Kein Konto – nur ein privater Link zu deinen Daten.",
|
||||||
|
"gameLink": "Idle Fantasy auf GitHub",
|
||||||
"featureDashboard": "Skills, Inventar, Quests und Verlauf",
|
"featureDashboard": "Skills, Inventar, Quests und Verlauf",
|
||||||
"featureUpload": "Backups im Browser importieren",
|
"featureUpload": "Backups im Browser importieren",
|
||||||
"featurePrivate": "Deine Daten bleiben nur in deinem Viewer",
|
"featurePrivate": "Deine Daten bleiben nur in deinem Viewer",
|
||||||
@@ -194,5 +198,41 @@
|
|||||||
"copyLink": "Link kopieren",
|
"copyLink": "Link kopieren",
|
||||||
"copied": "Kopiert!",
|
"copied": "Kopiert!",
|
||||||
"copyPrompt": "Viewer-Link kopieren:"
|
"copyPrompt": "Viewer-Link kopieren:"
|
||||||
|
},
|
||||||
|
"goals": {
|
||||||
|
"filterAll": "Alle",
|
||||||
|
"filterOpen": "Offen",
|
||||||
|
"filterDone": "Erledigt",
|
||||||
|
"createGroup": "Gruppe anlegen",
|
||||||
|
"createGroupPrompt": "Gruppenname:",
|
||||||
|
"groupProgress": "{completed} / {total} erledigt",
|
||||||
|
"ungrouped": "Ohne Gruppe",
|
||||||
|
"item": "Item",
|
||||||
|
"progress": "Fortschritt",
|
||||||
|
"status": "Status",
|
||||||
|
"actions": "Aktionen",
|
||||||
|
"open": "Offen",
|
||||||
|
"done": "Erledigt",
|
||||||
|
"delete": "Löschen",
|
||||||
|
"deleteGroup": "Gruppe löschen",
|
||||||
|
"deleteGroupConfirm": "Gruppe „{name}“ löschen? Ziele bleiben ohne Gruppe erhalten.",
|
||||||
|
"deleteConfirm": "Dieses Ziel löschen?",
|
||||||
|
"clearCompleted": "Erledigte entfernen",
|
||||||
|
"empty": "Noch keine Ziele. Lege Ziele im Inventar-Tab an.",
|
||||||
|
"loadError": "Ziele konnten nicht geladen werden",
|
||||||
|
"createFailed": "Ziel konnte nicht angelegt werden",
|
||||||
|
"groupCreateFailed": "Gruppe konnte nicht angelegt werden",
|
||||||
|
"modalTitle": "Ziel hinzufügen",
|
||||||
|
"cancel": "Abbrechen",
|
||||||
|
"targetQty": "Zielmenge",
|
||||||
|
"selectGroup": "Gruppe",
|
||||||
|
"noGroup": "Keine Gruppe",
|
||||||
|
"newGroup": "Neue Gruppe…",
|
||||||
|
"newGroupName": "Name der neuen Gruppe",
|
||||||
|
"currentQty": "Aktuell: {qty}",
|
||||||
|
"completedBannerTitle": "Ziele erreicht",
|
||||||
|
"completedItem": "{group}{name}: {current} / {target}",
|
||||||
|
"completedItemGroup": "{name}: ",
|
||||||
|
"groupCompleted": "Gruppe erledigt: {name}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-1
@@ -8,6 +8,7 @@
|
|||||||
"overview": "Overview",
|
"overview": "Overview",
|
||||||
"skills": "Skills",
|
"skills": "Skills",
|
||||||
"inventory": "Inventory",
|
"inventory": "Inventory",
|
||||||
|
"goals": "Goals",
|
||||||
"equipment": "Equipment",
|
"equipment": "Equipment",
|
||||||
"quests": "Quests",
|
"quests": "Quests",
|
||||||
"combat": "Combat",
|
"combat": "Combat",
|
||||||
@@ -119,7 +120,9 @@
|
|||||||
"groupMeta": "{count} items · {qty} pcs",
|
"groupMeta": "{count} items · {qty} pcs",
|
||||||
"trend": "Trend",
|
"trend": "Trend",
|
||||||
"trendExpand": "Click to enlarge chart",
|
"trendExpand": "Click to enlarge chart",
|
||||||
"trendExpandFor": "Quantity history for {name}"
|
"trendExpandFor": "Quantity history for {name}",
|
||||||
|
"addGoal": "Add goal",
|
||||||
|
"addGoalFor": "Add goal for {name}"
|
||||||
},
|
},
|
||||||
"equipment": {
|
"equipment": {
|
||||||
"title": "Equipment"
|
"title": "Equipment"
|
||||||
@@ -181,6 +184,7 @@
|
|||||||
},
|
},
|
||||||
"viewer": {
|
"viewer": {
|
||||||
"landingLead": "Create your personal save viewer. No account – just a private link to your data.",
|
"landingLead": "Create your personal save viewer. No account – just a private link to your data.",
|
||||||
|
"gameLink": "Idle Fantasy on GitHub",
|
||||||
"featureDashboard": "Skills, inventory, quests and history",
|
"featureDashboard": "Skills, inventory, quests and history",
|
||||||
"featureUpload": "Import backups in the browser",
|
"featureUpload": "Import backups in the browser",
|
||||||
"featurePrivate": "Your data stays in your viewer only",
|
"featurePrivate": "Your data stays in your viewer only",
|
||||||
@@ -194,5 +198,41 @@
|
|||||||
"copyLink": "Copy link",
|
"copyLink": "Copy link",
|
||||||
"copied": "Copied!",
|
"copied": "Copied!",
|
||||||
"copyPrompt": "Copy your viewer link:"
|
"copyPrompt": "Copy your viewer link:"
|
||||||
|
},
|
||||||
|
"goals": {
|
||||||
|
"filterAll": "All",
|
||||||
|
"filterOpen": "Open",
|
||||||
|
"filterDone": "Completed",
|
||||||
|
"createGroup": "Create group",
|
||||||
|
"createGroupPrompt": "Group name:",
|
||||||
|
"groupProgress": "{completed} / {total} done",
|
||||||
|
"ungrouped": "Ungrouped",
|
||||||
|
"item": "Item",
|
||||||
|
"progress": "Progress",
|
||||||
|
"status": "Status",
|
||||||
|
"actions": "Actions",
|
||||||
|
"open": "Open",
|
||||||
|
"done": "Done",
|
||||||
|
"delete": "Delete",
|
||||||
|
"deleteGroup": "Delete group",
|
||||||
|
"deleteGroupConfirm": "Delete group \"{name}\"? Goals will be kept as ungrouped.",
|
||||||
|
"deleteConfirm": "Delete this goal?",
|
||||||
|
"clearCompleted": "Remove completed",
|
||||||
|
"empty": "No goals yet. Add goals from the Inventory tab.",
|
||||||
|
"loadError": "Failed to load goals",
|
||||||
|
"createFailed": "Could not create goal",
|
||||||
|
"groupCreateFailed": "Could not create group",
|
||||||
|
"modalTitle": "Add goal",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"targetQty": "Target quantity",
|
||||||
|
"selectGroup": "Group",
|
||||||
|
"noGroup": "No group",
|
||||||
|
"newGroup": "New group…",
|
||||||
|
"newGroupName": "New group name",
|
||||||
|
"currentQty": "Current: {qty}",
|
||||||
|
"completedBannerTitle": "Goals reached",
|
||||||
|
"completedItem": "{group}{name}: {current} / {target}",
|
||||||
|
"completedItemGroup": "{name}: ",
|
||||||
|
"groupCompleted": "Group completed: {name}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+101
-1
@@ -732,7 +732,21 @@ body.inv-chart-modal-open {
|
|||||||
.landing-lead {
|
.landing-lead {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
margin: 0 0 16px;
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-game-link {
|
||||||
|
margin: 0 0 20px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-game-link a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-game-link a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.landing-features {
|
.landing-features {
|
||||||
@@ -855,6 +869,92 @@ body.inv-chart-modal-open {
|
|||||||
|
|
||||||
.viewer-copy-btn:hover { background: var(--accent-dim); color: #fff; }
|
.viewer-copy-btn:hover { background: var(--accent-dim); color: #fff; }
|
||||||
|
|
||||||
|
/* Goals */
|
||||||
|
.goals-toolbar { gap: 12px; flex-wrap: wrap; }
|
||||||
|
.goals-group-card { margin-bottom: 16px; }
|
||||||
|
.goals-ungrouped-title { margin: 0 0 12px; font-size: 1rem; }
|
||||||
|
.goals-table { width: 100%; border-collapse: collapse; }
|
||||||
|
.goals-table th,
|
||||||
|
.goals-table td { padding: 8px 10px; text-align: left; border-bottom: 1px solid var(--border); vertical-align: middle; }
|
||||||
|
.goal-progress-text { font-size: 0.85rem; margin-bottom: 4px; }
|
||||||
|
.goal-actions-cell { white-space: nowrap; }
|
||||||
|
.goal-delete-btn,
|
||||||
|
.goal-group-delete,
|
||||||
|
.goal-clear-completed {
|
||||||
|
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-delete-btn:hover,
|
||||||
|
.goal-group-delete:hover,
|
||||||
|
.goal-clear-completed:hover { background: var(--accent-dim); color: #fff; }
|
||||||
|
.goal-group-header td { position: relative; }
|
||||||
|
.goal-group-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
margin-left: 12px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.goal-item-row.collapsed { display: none; }
|
||||||
|
.goal-add-btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.goal-add-btn:hover { background: var(--accent-dim); color: #fff; }
|
||||||
|
.col-actions { width: 48px; text-align: center; }
|
||||||
|
.goals-completed-banner { margin-bottom: 12px; }
|
||||||
|
.goal-group-completed-line { font-weight: 600; }
|
||||||
|
|
||||||
|
.goal-modal {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.goal-modal[hidden] { display: none; }
|
||||||
|
.goal-modal-backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
}
|
||||||
|
.goal-modal-card {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 24px;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.goal-modal-card h3 { margin: 0 0 8px; }
|
||||||
|
.goal-modal-item { color: var(--text-muted); margin: 0 0 16px; font-size: 0.9rem; }
|
||||||
|
.goal-modal-field { display: block; margin-bottom: 12px; }
|
||||||
|
.goal-modal-field span { display: block; margin-bottom: 4px; font-size: 0.85rem; color: var(--text-muted); }
|
||||||
|
.goal-modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.sidebar {
|
.sidebar {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
<button class="nav-btn active" data-tab="overview" data-i18n="nav.overview">Overview</button>
|
<button class="nav-btn active" data-tab="overview" data-i18n="nav.overview">Overview</button>
|
||||||
<button class="nav-btn" data-tab="skills" data-i18n="nav.skills">Skills</button>
|
<button class="nav-btn" data-tab="skills" data-i18n="nav.skills">Skills</button>
|
||||||
<button class="nav-btn" data-tab="inventory" data-i18n="nav.inventory">Inventory</button>
|
<button class="nav-btn" data-tab="inventory" data-i18n="nav.inventory">Inventory</button>
|
||||||
|
<button class="nav-btn" data-tab="goals" data-i18n="nav.goals">Goals</button>
|
||||||
<button class="nav-btn" data-tab="equipment" data-i18n="nav.equipment">Equipment</button>
|
<button class="nav-btn" data-tab="equipment" data-i18n="nav.equipment">Equipment</button>
|
||||||
<button class="nav-btn" data-tab="quests" data-i18n="nav.quests">Quests</button>
|
<button class="nav-btn" data-tab="quests" data-i18n="nav.quests">Quests</button>
|
||||||
<button class="nav-btn" data-tab="combat" data-i18n="nav.combat">Combat</button>
|
<button class="nav-btn" data-tab="combat" data-i18n="nav.combat">Combat</button>
|
||||||
@@ -58,6 +59,7 @@
|
|||||||
<button type="button" class="viewer-copy-btn" id="viewer-copy-link" data-i18n="viewer.copyLink">Copy link</button>
|
<button type="button" class="viewer-copy-btn" id="viewer-copy-link" data-i18n="viewer.copyLink">Copy link</button>
|
||||||
</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="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>
|
||||||
@@ -67,12 +69,37 @@
|
|||||||
<section class="tab-panel active" id="tab-overview"></section>
|
<section class="tab-panel active" id="tab-overview"></section>
|
||||||
<section class="tab-panel" id="tab-skills"></section>
|
<section class="tab-panel" id="tab-skills"></section>
|
||||||
<section class="tab-panel" id="tab-inventory"></section>
|
<section class="tab-panel" id="tab-inventory"></section>
|
||||||
|
<section class="tab-panel" id="tab-goals"></section>
|
||||||
<section class="tab-panel" id="tab-equipment"></section>
|
<section class="tab-panel" id="tab-equipment"></section>
|
||||||
<section class="tab-panel" id="tab-quests"></section>
|
<section class="tab-panel" id="tab-quests"></section>
|
||||||
<section class="tab-panel" id="tab-combat"></section>
|
<section class="tab-panel" id="tab-combat"></section>
|
||||||
<section class="tab-panel" id="tab-history"></section>
|
<section class="tab-panel" id="tab-history"></section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="goal-modal" class="goal-modal" hidden>
|
||||||
|
<div class="goal-modal-backdrop" id="goal-modal-backdrop"></div>
|
||||||
|
<div class="goal-modal-card" role="dialog" aria-labelledby="goal-modal-title">
|
||||||
|
<h3 id="goal-modal-title"></h3>
|
||||||
|
<p class="goal-modal-item" id="goal-modal-item"></p>
|
||||||
|
<label class="goal-modal-field">
|
||||||
|
<span id="goal-modal-qty-label"></span>
|
||||||
|
<input type="number" class="search-input" id="goal-modal-qty" min="1" step="1">
|
||||||
|
</label>
|
||||||
|
<label class="goal-modal-field">
|
||||||
|
<span id="goal-modal-group-label"></span>
|
||||||
|
<select class="select-input" id="goal-modal-group"></select>
|
||||||
|
</label>
|
||||||
|
<label class="goal-modal-field" id="goal-modal-new-group-wrap" hidden>
|
||||||
|
<span id="goal-modal-new-group-label"></span>
|
||||||
|
<input type="text" class="search-input" id="goal-modal-new-group">
|
||||||
|
</label>
|
||||||
|
<p class="goal-modal-error landing-hint landing-hint-error" id="goal-modal-error" hidden></p>
|
||||||
|
<div class="goal-modal-actions">
|
||||||
|
<button type="button" class="viewer-copy-btn" id="goal-modal-cancel"></button>
|
||||||
|
<button type="button" class="upload-btn" id="goal-modal-submit"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% include '_footer.html' %}
|
{% include '_footer.html' %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -23,6 +23,10 @@
|
|||||||
Create your personal save viewer. No account – just a private link to your data.
|
Create your personal save viewer. No account – just a private link to your data.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p class="landing-game-link">
|
||||||
|
<a href="https://github.com/tristinbaker/IdleFantasy" target="_blank" rel="noopener noreferrer" data-i18n="viewer.gameLink">Idle Fantasy on GitHub</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
<ul class="landing-features">
|
<ul class="landing-features">
|
||||||
<li data-i18n="viewer.featureDashboard">Skills, inventory, quests and history</li>
|
<li data-i18n="viewer.featureDashboard">Skills, inventory, quests and history</li>
|
||||||
<li data-i18n="viewer.featureUpload">Import backups in the browser</li>
|
<li data-i18n="viewer.featureUpload">Import backups in the browser</li>
|
||||||
|
|||||||
Reference in New Issue
Block a user