Add skill training advisor with recipe data and one-click goals.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+213
-3
@@ -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
@@ -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
@@ -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
@@ -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…",
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user