diff --git a/app.py b/app.py index ed561af..dd59ca6 100644 --- a/app.py +++ b/app.py @@ -14,12 +14,18 @@ from werkzeug.utils import secure_filename from db import ( DEFAULT_DB, + create_goal, + create_goal_group, + delete_goal, + delete_goal_group, diff_snapshots, get_latest_snapshot, get_snapshot, import_save, init_db, inventory_timeline, + list_goal_groups, + list_goals_structured, list_snapshots, get_connection, timeline, @@ -121,6 +127,81 @@ def api_inventory_timeline(viewer_id: str): 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/", 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/", 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"]) @limiter.limit(IMPORT_LIMIT) def api_import(viewer_id: str): diff --git a/db.py b/db.py index 8f9671f..db3d4bc 100644 --- a/db.py +++ b/db.py @@ -57,6 +57,21 @@ def init_db(conn: sqlite3.Connection) -> None: FOREIGN KEY (snapshot_id) REFERENCES snapshots(id) ON DELETE CASCADE ); 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() @@ -143,6 +158,8 @@ def import_save( ) conn.commit() + goals_result = check_goals_after_import(snapshot_id, conn=conn, db_path=db_path) + if own_conn: conn.close() @@ -151,6 +168,7 @@ def import_save( "snapshot_id": snapshot_id, "import_report": import_report, "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"] 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, + } diff --git a/static/app.js b/static/app.js index 5020b50..70bd160 100644 --- a/static/app.js +++ b/static/app.js @@ -7,6 +7,8 @@ let state = { inventory: { search: "", categories: new Set(), sort: "category", highlightEquipped: false, collapsedGroups: new Set() }, skills: { search: "", sort: "level", sortAsc: false }, quests: { tab: "story", filter: "all" }, + goals: { filter: "all", collapsedGroups: new Set(), data: { groups: [], ungrouped: [] } }, + goalModalItem: null, history: { olderId: null, newerId: null, diff: null }, charts: {}, inventoryTimeline: null, @@ -67,6 +69,7 @@ async function init() { setupViewerBanner(); setupNav(); setupUpload(); + setupGoalModal(); await loadData(); } @@ -154,6 +157,7 @@ function setupUpload() { if (result.imported) { await loadData(); notifyImportSuccess(result); + showGoalsCompletedBanner(result); } else if (result.reason === "duplicate") { alert(t("import.duplicate")); } @@ -233,9 +237,13 @@ function renderImportReport(meta) { async function loadData() { try { - const res = await fetch(`${apiBase()}/snapshot/latest`); + const [res] = await Promise.all([ + fetch(`${apiBase()}/snapshot/latest`), + loadGoals(), + ]); if (!res.ok) { showEmpty(getViewerId() ? t("empty.noSaveWeb") : t("empty.noSave")); + renderGoals(); return; } state.data = await res.json(); @@ -243,6 +251,7 @@ async function loadData() { renderAll(); } catch (err) { showEmpty(t("empty.loadError", { message: err.message })); + renderGoals(); } } @@ -259,6 +268,7 @@ function renderAll() { renderOverview(d); renderSkills(d); renderInventory(d); + renderGoals(); renderEquipment(d); renderQuests(d); renderCombat(d); @@ -560,7 +570,7 @@ function bindInventorySparklines(container) { function renderInventoryTable(d, inv) { const items = getFilteredInventoryItems(d, inv); const showTrend = inventoryTrendEnabled(); - const colSpan = showTrend ? 4 : 3; + const colSpan = showTrend ? 5 : 4; const grouped = {}; for (const item of items) { if (!grouped[item.category]) grouped[item.category] = []; @@ -586,6 +596,9 @@ function renderInventoryTable(d, inv) { ${showTrend ? renderItemSparkCell(i) : ""} ${fmt(i.qty)} ${esc(i.key)} + + + `).join(""); return header + rows; }).join(""); @@ -611,6 +624,7 @@ function renderInventoryTable(d, inv) { ${trendCol} + @@ -618,12 +632,23 @@ function renderInventoryTable(d, inv) { ${trendHeader} ${esc(t("inventory.qty"))} ${esc(t("inventory.id"))} + ${esc(t("goals.actions"))} ${groupRows} `; + 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) => { btn.addEventListener("click", () => { const group = btn.dataset.group; @@ -705,6 +730,332 @@ function renderInventory(d) { 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 + ? `` + : ""; + return ` + ${esc(goal.item_name)} + +
${fmt(goal.current_qty)} / ${fmt(goal.target_qty)}
+
+ + ${esc(done ? t("goals.done") : t("goals.open"))} + ${deleteBtn} + `; +} + +function renderGoalsTableBody(goals) { + if (!goals.length) { + return `${esc(t("goals.empty"))}`; + } + 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 ? `` : ""} + `; + const rows = goals.length + ? goals.map((goal) => { + const row = renderGoalRow(goal); + return row.replace("", ``); + }).join("") + : `${esc(t("goals.empty"))}`; + + return ` +
+ + + + + + ${rows} + +
+ + ${headerActions} +
+
`; + }).join(""); + + const ungrouped = (data.ungrouped || []).filter((goal) => goalMatchesFilter(goal, filter)); + const ungroupedSection = (filter === "all" || ungrouped.length) ? ` +
+

${esc(t("goals.ungrouped"))}

+ + + + + + + + + + ${renderGoalsTableBody(ungrouped)} +
${esc(t("goals.item"))}${esc(t("goals.progress"))}${esc(t("goals.status"))}${esc(t("goals.actions"))}
+
` : ""; + + const hasAny = groupSections || ungrouped.length || filter === "all"; + + panel.innerHTML = ` +
+ + +
+ ${hasAny ? groupSections + ungroupedSection : `

${esc(t("goals.empty"))}

`}`; + + 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 = ` + + ${groups.map((g) => ``).join("")} + `; + + 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 `
  • ${esc(t("goals.completedItem", { + group: groupPrefix, + name: goal.item_name, + current: fmt(goal.current_qty), + target: fmt(goal.target_qty), + }))}
  • `; + }).join(""); + + const groupLines = groupsDone.map((g) => + `
  • ${esc(t("goals.groupCompleted", { name: g.name }))}
  • ` + ).join(""); + + el.hidden = false; + el.className = "goals-completed-banner import-report import-report-info"; + el.innerHTML = ` +
    + ${esc(t("goals.completedBannerTitle"))} + +
    + `; + + el.querySelector(".import-report-dismiss").addEventListener("click", () => { + el.hidden = true; + }); +} + function renderEquipment(d) { document.getElementById("tab-equipment").innerHTML = `
    diff --git a/static/locales/de.json b/static/locales/de.json index 98f6199..c33c709 100644 --- a/static/locales/de.json +++ b/static/locales/de.json @@ -8,6 +8,7 @@ "overview": "Übersicht", "skills": "Skills", "inventory": "Inventar", + "goals": "Ziele", "equipment": "Ausrüstung", "quests": "Quests", "combat": "Kampf", @@ -119,7 +120,9 @@ "groupMeta": "{count} Items · {qty} Stk.", "trend": "Verlauf", "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": { "title": "Ausrüstung" @@ -181,6 +184,7 @@ }, "viewer": { "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", "featureUpload": "Backups im Browser importieren", "featurePrivate": "Deine Daten bleiben nur in deinem Viewer", @@ -194,5 +198,41 @@ "copyLink": "Link kopieren", "copied": "Kopiert!", "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}" } } diff --git a/static/locales/en.json b/static/locales/en.json index a1a33f7..8fb4f0f 100644 --- a/static/locales/en.json +++ b/static/locales/en.json @@ -8,6 +8,7 @@ "overview": "Overview", "skills": "Skills", "inventory": "Inventory", + "goals": "Goals", "equipment": "Equipment", "quests": "Quests", "combat": "Combat", @@ -119,7 +120,9 @@ "groupMeta": "{count} items · {qty} pcs", "trend": "Trend", "trendExpand": "Click to enlarge chart", - "trendExpandFor": "Quantity history for {name}" + "trendExpandFor": "Quantity history for {name}", + "addGoal": "Add goal", + "addGoalFor": "Add goal for {name}" }, "equipment": { "title": "Equipment" @@ -181,6 +184,7 @@ }, "viewer": { "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", "featureUpload": "Import backups in the browser", "featurePrivate": "Your data stays in your viewer only", @@ -194,5 +198,41 @@ "copyLink": "Copy link", "copied": "Copied!", "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}" } } diff --git a/static/style.css b/static/style.css index 95ae3f6..083b2e0 100644 --- a/static/style.css +++ b/static/style.css @@ -732,7 +732,21 @@ body.inv-chart-modal-open { .landing-lead { color: var(--text-muted); 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 { @@ -855,6 +869,92 @@ body.inv-chart-modal-open { .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) { .sidebar { position: relative; diff --git a/templates/index.html b/templates/index.html index 78c8c11..53c3b77 100644 --- a/templates/index.html +++ b/templates/index.html @@ -24,6 +24,7 @@ + @@ -58,6 +59,7 @@
    +
    Loading save…
    @@ -67,12 +69,37 @@
    +
    + {% include '_footer.html' %} diff --git a/templates/landing.html b/templates/landing.html index c7ff830..0369b0f 100644 --- a/templates/landing.html +++ b/templates/landing.html @@ -23,6 +23,10 @@ Create your personal save viewer. No account – just a private link to your data.

    + +