diff --git a/static/app.js b/static/app.js index 7cbc8cb..492adad 100644 --- a/static/app.js +++ b/static/app.js @@ -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 + ? `${esc(label)}` + : `${esc(label)}`; +} + 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 - ? `` + ? `${esc(fmt(crafts))}× + ` : ``; return ` ${esc(rec.display_name)} @@ -854,7 +864,7 @@ function renderSkillAdvisorCard() { ${rec.level_required} ${esc(mats)} ${esc(eta)} - + ${goalCell} `; @@ -869,16 +879,17 @@ function renderSkillAdvisorCard() {
- - - - - - + ${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")} ${rows}
${esc(t("skills.advisorActivity"))}${esc(t("skills.advisorXpMin"))}${esc(t("skills.advisorReqLevel"))}${esc(t("skills.advisorMaterials"))}${esc(t("skills.advisorEtaCol"))}${esc(t("goals.actions"))}
-
`; + +

${esc(t("skills.advisorTableLegend"))}

`; 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() { diff --git a/static/locales/de.json b/static/locales/de.json index e276267..b103637 100644 --- a/static/locales/de.json +++ b/static/locales/de.json @@ -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…", diff --git a/static/locales/en.json b/static/locales/en.json index 9f336ec..e5d34b1 100644 --- a/static/locales/en.json +++ b/static/locales/en.json @@ -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…", diff --git a/static/style.css b/static/style.css index 4fcb4b5..208ed66 100644 --- a/static/style.css +++ b/static/style.css @@ -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;