Add skill training advisor with recipe data and one-click goals.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-22 15:28:51 +02:00
parent e233e3c762
commit 567bbd3de0
21 changed files with 3447 additions and 18 deletions
+213 -3
View File
@@ -5,7 +5,7 @@ let state = {
snapshots: [],
timeline: [],
inventory: { search: "", categories: new Set(), sort: "category", highlightEquipped: false, collapsedGroups: new Set() },
skills: { search: "", sort: "level", sortAsc: false },
skills: { search: "", sort: "level", sortAsc: false, advisorKey: null, advisor: null, advisorLoading: false },
quests: { tab: "story", filter: "all" },
goals: { filter: "all", collapsedGroups: new Set(), data: { groups: [], ungrouped: [] } },
goalsOverview: null,
@@ -580,6 +580,11 @@ function renderSkills(d) {
<option value="name"></option>
</select>
</div>
<div class="card skill-advisor-card" id="skill-advisor-card">
<h3 id="skill-advisor-title"></h3>
<p class="skill-advisor-hint" id="skill-advisor-hint"></p>
<div id="skill-advisor-body"></div>
</div>
<div class="card">
<table class="skills-table" id="skills-table">
<thead><tr id="skills-thead-row">
@@ -628,6 +633,8 @@ function renderSkills(d) {
document.getElementById("skill-search").value = s.search;
document.getElementById("skill-sort").value = s.sort;
const advisorTitle = document.getElementById("skill-advisor-title");
if (advisorTitle) advisorTitle.textContent = t("skills.advisorTitle");
ensureSkillTimeline().then(() => renderSkillsBody(d));
}
@@ -670,8 +677,9 @@ function renderSkillsBody(d) {
document.getElementById("skill-tbody").innerHTML = items.map((sk) => {
const hasGoal = openGoals.skills.has(sk.key);
const selected = state.skills.advisorKey === sk.key;
return `
<tr class="${hasGoal ? "has-goal" : ""}">
<tr class="skill-row ${hasGoal ? "has-goal" : ""} ${selected ? "skill-row-selected" : ""}" data-skill-key="${esc(sk.key)}" tabindex="0" role="button">
<td>${esc(sk.name)}${hasGoal ? `<span class="goal-mark" title="${esc(t("goals.hasGoal"))}">🎯</span>` : ""}</td>
${showTrend ? renderSkillSparkCell(sk) : ""}
<td>${sk.level}</td>
@@ -689,7 +697,8 @@ function renderSkillsBody(d) {
bindSparklines(document.getElementById("skill-tbody"));
document.getElementById("skill-tbody").querySelectorAll(".goal-add-btn").forEach((btn) => {
btn.addEventListener("click", () => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
openGoalModal({
key: btn.dataset.skillKey,
name: btn.dataset.skillName,
@@ -698,6 +707,207 @@ function renderSkillsBody(d) {
});
});
});
document.getElementById("skill-tbody").querySelectorAll(".skill-row").forEach((row) => {
const openAdvisor = () => {
const key = row.dataset.skillKey;
if (!key) return;
state.skills.advisorKey = key;
loadSkillAdvisor(key).then(() => renderSkillsBody(state.data));
};
row.addEventListener("click", (e) => {
if (e.target.closest(".goal-add-btn, .inv-spark-btn")) return;
openAdvisor();
});
row.addEventListener("keydown", (e) => {
if (e.key !== "Enter" && e.key !== " ") return;
e.preventDefault();
openAdvisor();
});
});
renderSkillAdvisorCard();
}
async function loadSkillAdvisor(skillKey) {
state.skills.advisorLoading = true;
renderSkillAdvisorCard();
try {
const res = await fetch(`${apiBase()}/advisor/${encodeURIComponent(skillKey)}`);
state.skills.advisor = res.ok ? await res.json() : { supported: false, error: "load_failed" };
} catch {
state.skills.advisor = { supported: false, error: "load_failed" };
} finally {
state.skills.advisorLoading = false;
}
}
function formatAdvisorMaterials(materials) {
return Object.entries(materials || {})
.map(([key, qty]) => `${qty}× ${key.replace(/_/g, " ")}`)
.join(", ");
}
function formatAdvisorMissing(missing) {
return Object.entries(missing || {})
.map(([key, qty]) => `${qty}× ${key.replace(/_/g, " ")}`)
.join(", ");
}
function renderSkillAdvisorCard() {
const hint = document.getElementById("skill-advisor-hint");
const body = document.getElementById("skill-advisor-body");
if (!hint || !body) return;
const adv = state.skills.advisor;
const key = state.skills.advisorKey;
if (!key) {
hint.hidden = false;
hint.textContent = t("skills.advisorHint");
body.innerHTML = "";
return;
}
if (state.skills.advisorLoading) {
hint.hidden = true;
body.innerHTML = `<p class="loading">${esc(t("skills.advisorLoading"))}</p>`;
return;
}
if (!adv || !adv.supported) {
hint.hidden = false;
hint.textContent = adv?.error === "unsupported_skill"
? t("skills.advisorUnsupported")
: t("skills.advisorUnavailable");
body.innerHTML = "";
return;
}
hint.hidden = true;
const xpRemain = adv.xp_remaining_in_level ?? 0;
const summary = t("skills.advisorSummary", {
name: adv.skill_name,
level: adv.skill_level,
xp: fmt(xpRemain),
});
if (!adv.recommendations?.length) {
body.innerHTML = `<p class="skill-advisor-summary">${esc(summary)}</p><p class="empty-state">${esc(t("skills.advisorNoRecipes"))}</p>`;
return;
}
const rows = adv.recommendations.map((rec) => {
const mats = rec.can_craft
? formatAdvisorMaterials(rec.materials)
: t("skills.advisorMissing", { items: formatAdvisorMissing(rec.missing_materials) });
const eta = rec.eta_minutes_to_level > 0
? t("skills.advisorEta", { minutes: fmt(rec.eta_minutes_to_level) })
: "—";
const crafts = rec.crafts_to_next_level || 1;
const goalLabel = t("skills.advisorAdoptGoalFor", { name: rec.display_name, count: fmt(crafts) });
return `<tr>
<td>${esc(rec.display_name)}</td>
<td class="num">${rec.xp_per_minute.toFixed(1)}</td>
<td>${rec.level_required}</td>
<td class="skill-advisor-mats">${esc(mats)}</td>
<td class="num">${esc(eta)}</td>
<td class="col-actions">
<button type="button" class="goal-add-btn skill-advisor-goal-btn" data-activity-key="${esc(rec.activity_key)}" data-crafts="${crafts}" title="${esc(goalLabel)}" aria-label="${esc(goalLabel)}">+</button>
</td>
</tr>`;
}).join("");
const skillGoalLabel = t("skills.advisorAdoptSkillGoal", { level: adv.skill_level + 1 });
body.innerHTML = `
<div class="skill-advisor-summary-row">
<p class="skill-advisor-summary">${esc(summary)}</p>
<button type="button" class="btn-secondary skill-advisor-skill-goal-btn">${esc(skillGoalLabel)}</button>
</div>
<div class="table-wrap">
<table class="skill-advisor-table">
<thead><tr>
<th>${esc(t("skills.advisorActivity"))}</th>
<th>${esc(t("skills.advisorXpMin"))}</th>
<th>${esc(t("skills.advisorReqLevel"))}</th>
<th>${esc(t("skills.advisorMaterials"))}</th>
<th>${esc(t("skills.advisorEtaCol"))}</th>
<th class="col-actions">${esc(t("goals.actions"))}</th>
</tr></thead>
<tbody>${rows}</tbody>
</table>
</div>`;
body.querySelector(".skill-advisor-skill-goal-btn")?.addEventListener("click", () => {
adoptAdvisorSkillGoal();
});
body.querySelectorAll(".skill-advisor-goal-btn").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
adoptAdvisorItemGoal(btn.dataset.activityKey, Number(btn.dataset.crafts));
});
});
}
async function adoptAdvisorSkillGoal() {
const adv = state.skills.advisor;
if (!adv?.supported || !adv.skill_key) return;
const targetLevel = adv.skill_level + 1;
const res = await fetch(`${apiBase()}/goals`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
goal_type: "skill",
skill_key: adv.skill_key,
target_level: targetLevel,
mode: "absolute",
}),
});
const result = await res.json();
if (!res.ok) {
alert(result.error || t("goals.createFailed"));
return;
}
trackEvent("Goal Create", { source: "advisor", type: "skill", mode: "absolute" });
await refreshGoalsAfterCreate();
alert(t("skills.advisorGoalCreated", { name: adv.skill_name, target: targetLevel }));
}
async function adoptAdvisorItemGoal(activityKey, crafts) {
const adv = state.skills.advisor;
if (!adv?.supported || !activityKey) return;
const rec = adv.recommendations?.find((r) => r.activity_key === activityKey);
const name = rec?.display_name || activityKey.replace(/_/g, " ");
const count = crafts > 0 ? crafts : 1;
const res = await fetch(`${apiBase()}/goals`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
item_key: activityKey,
target_qty: count,
mode: "relative",
}),
});
const result = await res.json();
if (!res.ok) {
alert(result.error || t("goals.createFailed"));
return;
}
trackEvent("Goal Create", { source: "advisor", type: "item", mode: "relative" });
await refreshGoalsAfterCreate();
alert(t("skills.advisorItemGoalCreated", { name, count: fmt(count) }));
}
async function refreshGoalsAfterCreate() {
await loadGoals();
const overviewRes = await fetch(`${apiBase()}/goals/overview`);
if (overviewRes.ok) state.goalsOverview = await overviewRes.json();
renderGoals();
if (state.data) {
renderHeader(state.data);
renderSkills(state.data);
renderInventory(state.data);
}
}
function getFilteredInventoryItems(d, inv) {
+2 -1
View File
@@ -2,6 +2,7 @@
const I18n = (() => {
const STORAGE_KEY = "locale";
const LOCALE_VERSION = "7";
const SUPPORTED = ["en", "de"];
let locale = "en";
let preference = "auto";
@@ -13,7 +14,7 @@ const I18n = (() => {
}
async function loadMessages(code) {
const res = await fetch(`/static/locales/${code}.json`);
const res = await fetch(`/static/locales/${code}.json?v=${LOCALE_VERSION}`, { cache: "no-store" });
if (!res.ok) throw new Error(`Locale not found: ${code}`);
return res.json();
}
+19 -1
View File
@@ -121,7 +121,25 @@
"addGoalFor": "Ziel für {name} hinzufügen",
"trend": "Verlauf",
"trendExpand": "Klicken für großes Diagramm",
"trendExpandFor": "Level-Verlauf für {name}"
"trendExpandFor": "Level-Verlauf für {name}",
"advisorTitle": "Trainings-Empfehlungen",
"advisorHint": "Skill in der Tabelle anklicken für XP-Empfehlungen (nur Rezept-Skills).",
"advisorLoading": "Lade Empfehlungen…",
"advisorUnsupported": "Für diesen Skill liegen noch keine Rezeptdaten vor.",
"advisorUnavailable": "Empfehlungen konnten nicht geladen werden.",
"advisorSummary": "{name} (Level {level}) — noch {xp} XP bis zum nächsten Level",
"advisorNoRecipes": "Keine Aktivitäten auf deinem aktuellen Level freigeschaltet.",
"advisorActivity": "Aktivität",
"advisorXpMin": "XP / Min.",
"advisorReqLevel": "Level",
"advisorMaterials": "Material",
"advisorEtaCol": "Bis Level",
"advisorEta": "~{minutes} Min.",
"advisorMissing": "Fehlt: {items}",
"advisorAdoptSkillGoal": "Level {level} als Ziel",
"advisorAdoptGoalFor": "Ziel: {count}× {name} craften (bis nächstes Level)",
"advisorGoalCreated": "Skill-Ziel angelegt: {name} → Level {target}",
"advisorItemGoalCreated": "Item-Ziel angelegt: {count}× {name} craften"
},
"inventory": {
"search": "Item suchen…",
+19 -1
View File
@@ -121,7 +121,25 @@
"addGoalFor": "Add goal for {name}",
"trend": "Trend",
"trendExpand": "Click to enlarge chart",
"trendExpandFor": "Level history for {name}"
"trendExpandFor": "Level history for {name}",
"advisorTitle": "Training advisor",
"advisorHint": "Click a skill in the table for XP training recommendations (recipe skills only).",
"advisorLoading": "Loading recommendations…",
"advisorUnsupported": "No recipe data for this skill yet.",
"advisorUnavailable": "Could not load recommendations.",
"advisorSummary": "{name} (level {level}) — {xp} XP to next level",
"advisorNoRecipes": "No activities unlocked at your current level.",
"advisorActivity": "Activity",
"advisorXpMin": "XP / min",
"advisorReqLevel": "Req. level",
"advisorMaterials": "Materials",
"advisorEtaCol": "Est. to level",
"advisorEta": "~{minutes} min",
"advisorMissing": "Missing: {items}",
"advisorAdoptSkillGoal": "Level {level} as goal",
"advisorAdoptGoalFor": "Goal: craft {count}× {name} (to next level)",
"advisorGoalCreated": "Skill goal created: {name} → level {target}",
"advisorItemGoalCreated": "Item goal created: craft {count}× {name}"
},
"inventory": {
"search": "Search items…",
+80
View File
@@ -348,6 +348,86 @@ th {
th:hover { color: var(--text); }
tr:hover td { background: var(--bg-hover); }
.skills-table .skill-row {
cursor: pointer;
}
.skills-table .skill-row-selected td {
background: rgba(108, 140, 255, 0.12);
}
.skills-table .skill-row-selected:hover td {
background: rgba(108, 140, 255, 0.18);
}
.skill-advisor-card h3 {
margin-bottom: 8px;
}
.skill-advisor-hint {
margin: 0 0 12px;
color: var(--text-muted);
font-size: 0.88rem;
}
.skill-advisor-summary {
margin: 0 0 12px;
font-size: 0.9rem;
}
.skill-advisor-table th {
cursor: default;
}
.skill-advisor-table th:hover {
color: var(--text-muted);
}
.skill-advisor-table td.num {
white-space: nowrap;
}
.skill-advisor-mats {
font-size: 0.82rem;
color: var(--text-muted);
max-width: 16rem;
}
.skill-advisor-summary-row {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.skill-advisor-summary-row .skill-advisor-summary {
margin: 0;
flex: 1 1 12rem;
}
.skill-advisor-skill-goal-btn {
flex-shrink: 0;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-hover);
color: var(--text);
font-size: 0.85rem;
cursor: pointer;
}
.skill-advisor-skill-goal-btn:hover {
border-color: var(--accent);
color: var(--accent);
}
.skill-advisor-table .col-actions {
width: 3rem;
text-align: center;
}
.progress-bar {
height: 6px;
background: var(--bg);
+5 -1
View File
@@ -1,4 +1,4 @@
const CACHE = "if-viewer-static-v6";
const CACHE = "if-viewer-static-v7";
const ASSETS = [
"/static/style.css",
"/static/favicon.svg",
@@ -13,6 +13,10 @@ const ASSETS = [
const NETWORK_FIRST = new Set([
"/static/pwa.js",
"/static/style.css",
"/static/i18n.js",
"/static/locales/en.json",
"/static/locales/de.json",
"/static/app.js",
]);
self.addEventListener("install", (event) => {