Clarify training advisor table and create goal groups from + button.

Better column labels and legends reduce confusion between per-craft materials,
level-up time, and craft count; the advisor + action now opens a new goal group
with missing material targets plus the main activity goal.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-16 11:30:26 +02:00
co-authored by Cursor
parent cbe6174e7c
commit 3b47c16b74
4 changed files with 170 additions and 29 deletions
+123 -21
View File
@@ -793,6 +793,15 @@ function formatAdvisorMissing(missing) {
.join(", ");
}
function advisorTh(labelKey, hintKey, className = "") {
const label = t(labelKey);
const hint = hintKey ? t(hintKey) : "";
const cls = className ? ` class="${className}"` : "";
return hint
? `<th scope="col"${cls} title="${esc(hint)}">${esc(label)}</th>`
: `<th scope="col"${cls}>${esc(label)}</th>`;
}
function renderSkillAdvisorCard() {
const hint = document.getElementById("skill-advisor-hint");
const body = document.getElementById("skill-advisor-body");
@@ -846,7 +855,8 @@ function renderSkillAdvisorCard() {
const crafts = rec.crafts_to_next_level ?? 0;
const goalLabel = t("skills.advisorAdoptGoalFor", { name: rec.display_name, count: fmt(crafts) });
const goalCell = crafts > 0
? `<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>`
? `<span class="skill-advisor-craft-qty" title="${esc(t("skills.advisorGoalColHint"))}">${esc(fmt(crafts))}×</span>
<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>`
: `<span class="inv-spark-empty">—</span>`;
return `<tr>
<td>${esc(rec.display_name)}</td>
@@ -854,7 +864,7 @@ function renderSkillAdvisorCard() {
<td>${rec.level_required}</td>
<td class="skill-advisor-mats">${esc(mats)}</td>
<td class="num">${esc(eta)}</td>
<td class="col-actions">
<td class="col-actions skill-advisor-goal-cell">
${goalCell}
</td>
</tr>`;
@@ -869,16 +879,17 @@ function renderSkillAdvisorCard() {
<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>
${advisorTh("skills.advisorActivity")}
${advisorTh("skills.advisorXpMin")}
${advisorTh("skills.advisorReqLevel")}
${advisorTh("skills.advisorMaterials", "skills.advisorMaterialsHint")}
${advisorTh("skills.advisorEtaCol", "skills.advisorEtaColHint")}
${advisorTh("skills.advisorGoalCol", "skills.advisorGoalColHint", "col-actions")}
</tr></thead>
<tbody>${rows}</tbody>
</table>
</div>`;
</div>
<p class="skill-advisor-legend">${esc(t("skills.advisorTableLegend"))}</p>`;
body.querySelector(".skill-advisor-skill-goal-btn")?.addEventListener("click", () => {
adoptAdvisorSkillGoal();
@@ -915,30 +926,121 @@ async function adoptAdvisorSkillGoal() {
alert(t("skills.advisorGoalCreated", { name: adv.skill_name, target: targetLevel }));
}
function inventoryQtyMap(data) {
const map = {};
for (const item of data?.inventory || []) {
map[item.key] = item.qty;
}
return map;
}
function collectExistingGoalItemKeys() {
const keys = new Set();
const data = state.goals.data || {};
for (const goal of data.ungrouped || []) {
if (goal.goal_type === "item") keys.add(goal.item_key);
}
for (const group of data.groups || []) {
for (const goal of group.goals || []) {
if (goal.goal_type === "item") keys.add(goal.item_key);
}
}
return keys;
}
function advisorMaterialGoalTargets(rec, crafts, inventoryMap) {
const targets = [];
for (const [key, perCraft] of Object.entries(rec.materials || {})) {
const totalNeeded = Number(perCraft) * crafts;
const have = inventoryMap[key] ?? 0;
const missing = totalNeeded - have;
if (missing > 0) {
targets.push({ item_key: key, target_qty: missing });
}
}
return targets;
}
async function createGoalInGroup(itemKey, targetQty, groupId, existingKeys) {
if (existingKeys.has(itemKey)) {
return { ok: false, skipped: true };
}
const res = await fetch(`${apiBase()}/goals`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
item_key: itemKey,
target_qty: targetQty,
mode: "relative",
group_id: groupId,
}),
});
if (!res.ok) {
return { ok: false, skipped: true };
}
existingKeys.add(itemKey);
return { ok: true, goal: await res.json() };
}
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 = Number(crafts);
if (!count || count <= 0) return;
const res = await fetch(`${apiBase()}/goals`, {
if (!count || count <= 0 || !rec) return;
await loadGoals();
const existingKeys = collectExistingGoalItemKeys();
const inventoryMap = inventoryQtyMap(state.data);
const materialTargets = advisorMaterialGoalTargets(rec, count, inventoryMap);
const groupName = t("skills.advisorGoalGroupName", { name });
const groupRes = await fetch(`${apiBase()}/goal-groups`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
item_key: activityKey,
target_qty: count,
mode: "relative",
}),
body: JSON.stringify({ name: groupName }),
});
const result = await res.json();
if (!res.ok) {
alert(result.error || t("goals.createFailed"));
const groupData = await groupRes.json();
if (!groupRes.ok) {
alert(groupData.error || t("goals.groupCreateFailed"));
return;
}
trackEvent("Goal Create", { source: "advisor", type: "item", mode: "relative", group: "none" });
let created = 0;
let skipped = 0;
for (const mat of materialTargets) {
const result = await createGoalInGroup(mat.item_key, mat.target_qty, groupData.id, existingKeys);
if (result.ok) created += 1;
else skipped += 1;
}
const mainResult = await createGoalInGroup(activityKey, count, groupData.id, existingKeys);
if (mainResult.ok) created += 1;
else skipped += 1;
if (created === 0) {
await fetch(`${apiBase()}/goal-groups/${groupData.id}`, { method: "DELETE" });
alert(t("skills.advisorItemGoalsNone"));
return;
}
trackEvent("Goal Group Create", { source: "advisor" });
trackEvent("Goal Create", {
source: "advisor",
type: "item",
mode: "relative",
group: "new",
materials: materialTargets.length,
});
await refreshGoalsAfterCreate();
alert(t("skills.advisorItemGoalCreated", { name, count: fmt(count) }));
let message = t("skills.advisorItemGoalsCreated", { group: groupName, count: fmt(created) });
if (skipped > 0) {
message += ` ${t("skills.advisorItemGoalsSkipped", { count: fmt(skipped) })}`;
}
alert(message);
}
async function refreshGoalsAfterCreate() {
+13 -4
View File
@@ -133,14 +133,23 @@
"advisorActivity": "Aktivität",
"advisorXpMin": "XP / Min.",
"advisorReqLevel": "Level",
"advisorMaterials": "Material",
"advisorEtaCol": "Bis Level",
"advisorMaterials": "Material (1×)",
"advisorMaterialsHint": "Materialbedarf für einen einzelnen Craft — nicht für das gesamte Level-Up.",
"advisorEtaCol": "Zeit (~Min.)",
"advisorEtaColHint": "Geschätzte Spielzeit bis zum nächsten Level, wenn du nur diese Aktivität machst.",
"advisorGoalCol": "Ziel (Anz.)",
"advisorGoalColHint": "Anzahl Crafts bis zum nächsten Level. Mit + Zielgruppe inkl. fehlender Materialien anlegen.",
"advisorTableLegend": "Material pro Craft; Zeit und Anzahl beziehen sich auf das komplette Level-Up. Bei 60 Sek. pro Craft können Minuten und Anzahl übereinstimmen.",
"advisorEta": "~{minutes} Min.",
"advisorMissing": "Fehlt: {items}",
"advisorAdoptSkillGoal": "Level {level} als Ziel",
"advisorAdoptGoalFor": "Ziel: {count}× {name} craften (bis nächstes Level)",
"advisorAdoptGoalFor": "Zielgruppe anlegen: {count}× {name} craften plus fehlende Materialien",
"advisorGoalGroupName": "{name} — Level-Up",
"advisorGoalCreated": "Skill-Ziel angelegt: {name} → Level {target}",
"advisorItemGoalCreated": "Item-Ziel angelegt: {count}× {name} craften"
"advisorItemGoalCreated": "Item-Ziel angelegt: {count}× {name} craften",
"advisorItemGoalsCreated": "Gruppe \"{group}\" angelegt mit {count} Zielen.",
"advisorItemGoalsSkipped": "{count} Ziele übersprungen (bereits vorhanden).",
"advisorItemGoalsNone": "Keine neuen Ziele angelegt — alle Items haben bereits ein Ziel."
},
"inventory": {
"search": "Item suchen…",
+13 -4
View File
@@ -133,14 +133,23 @@
"advisorActivity": "Activity",
"advisorXpMin": "XP / min",
"advisorReqLevel": "Req. level",
"advisorMaterials": "Materials",
"advisorEtaCol": "Est. to level",
"advisorMaterials": "Materials (×1)",
"advisorMaterialsHint": "Materials needed for a single craft — not for the full level-up.",
"advisorEtaCol": "Time (~min)",
"advisorEtaColHint": "Estimated play time to the next level if you only use this activity.",
"advisorGoalCol": "Goal (qty)",
"advisorGoalColHint": "Number of crafts to reach the next level. Use + to create a goal group including missing materials.",
"advisorTableLegend": "Materials are per craft; time and quantity refer to the full level-up. At 60 seconds per craft, minutes and quantity may match.",
"advisorEta": "~{minutes} min",
"advisorMissing": "Missing: {items}",
"advisorAdoptSkillGoal": "Level {level} as goal",
"advisorAdoptGoalFor": "Goal: craft {count}× {name} (to next level)",
"advisorAdoptGoalFor": "Create goal group: craft {count}× {name} plus missing materials",
"advisorGoalGroupName": "{name} — level-up",
"advisorGoalCreated": "Skill goal created: {name} → level {target}",
"advisorItemGoalCreated": "Item goal created: craft {count}× {name}"
"advisorItemGoalCreated": "Item goal created: craft {count}× {name}",
"advisorItemGoalsCreated": "Created group \"{group}\" with {count} goals.",
"advisorItemGoalsSkipped": "{count} goals skipped (already exist).",
"advisorItemGoalsNone": "No new goals created — all items already have a goal."
},
"inventory": {
"search": "Search items…",
+21
View File
@@ -393,6 +393,27 @@ tr:hover td { background: var(--bg-hover); }
max-width: 16rem;
}
.skill-advisor-legend {
margin: 10px 0 0;
color: var(--text-muted);
font-size: 0.82rem;
line-height: 1.45;
}
.skill-advisor-goal-cell {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
white-space: nowrap;
}
.skill-advisor-craft-qty {
font-size: 0.85rem;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.skill-advisor-summary-row {
display: flex;
flex-wrap: wrap;