diff --git a/custom_components/taskmate/www/taskmate-bounty-card.js b/custom_components/taskmate/www/taskmate-bounty-card.js index 12caeaaa..fdbeccee 100644 --- a/custom_components/taskmate/www/taskmate-bounty-card.js +++ b/custom_components/taskmate/www/taskmate-bounty-card.js @@ -127,6 +127,20 @@ class TaskMateBountyCard extends LitElement { return c ? c.name : "?"; } + /** + * Who an open-to-everyone bounty shows as able to claim it (#1071). That + * avatar row is family-wide even though the card belongs to one child, so + * adult profiles stay out of it unless show_adults is set. The card's own + * child is always in it. + */ + _everyoneIds() { + const all = this._children(); + const cfg = { ...this.config, child_id: undefined }; + const listed = (window.__taskmate_family_children ? window.__taskmate_family_children(all, cfg) : all); + const me = String(this.config.child_id); + return listed.concat(all.filter(c => String(c.id) === me && !listed.includes(c))).map(c => c.id); + } + _eligible(b, childId) { return !Array.isArray(b.eligible) || b.eligible.length === 0 || b.eligible.map(String).includes(String(childId)); } @@ -349,7 +363,7 @@ class TaskMateBountyCard extends LitElement { _row(b, me, now, holdingClaim) { const mine = String(b.claimed_by || "") === me; - const eligibleIds = Array.isArray(b.eligible) && b.eligible.length ? b.eligible : this._children().map(c => c.id); + const eligibleIds = Array.isArray(b.eligible) && b.eligible.length ? b.eligible : this._everyoneIds(); const expLeft = b.expires_at ? this._msUntil(b.expires_at, now) : null; const showMeta = b.status === "open" || b.status === "claimed"; let cls = ""; @@ -713,11 +727,13 @@ class TaskMateBountyCardEditor extends LitElement { child_id: this._t("bounty.editor.child"), title: this._t("common.editor.card_title"), card_design: this._t("common.design.field_label"), + show_adults: this._t("common.editor.show_adults"), }[entry.name] ?? entry.name); _computeHelper = (entry) => ({ entity: this._t("common.editor.overview_entity_helper"), child_id: this._t("bounty.editor.child_helper"), + show_adults: this._t("common.editor.show_adults_helper"), }[entry.name] ?? ""); render() { @@ -727,12 +743,13 @@ class TaskMateBountyCardEditor extends LitElement { child_id: this.config.child_id || "", title: this.config.title || "", card_design: this.config.card_design || "global", + show_adults: this.config.show_adults === true, }; return html` @@ -1123,7 +1128,7 @@ export function createIncentiveCard(P) { c.id === this._selectedChildId)) this._selectedChildId = kids[0].id; } } + // The children a family-wide view lists (#1061/#1071): adults only with + // show_adults, and everyone when the card is pinned to a child_id. + _family(children) { + return (window.__taskmate_family_children ? window.__taskmate_family_children(children, this.config) : children); + } + + /** + * Who a reward row lists (#1071). Non-jackpot rewards and the child badges + * follow the family filter. A jackpot's breakdown does too, except that an + * adult who has put points into it stays listed — dropping them would leave + * the segments and the total not adding up. + */ + _rewardPeople(reward, children) { + const assignedTo = reward.assigned_to || []; + const family = this._family(children); + const assigned = (c) => assignedTo.length === 0 || assignedTo.includes(c.id); + const relevantChildren = family.filter(assigned); + const listed = new Set(family.map((c) => c.id)); + const hidden = new Set(children.filter((c) => !listed.has(c.id)).map((c) => c.id)); + const allocations = reward.pool_allocations || {}; + const contributors = relevantChildren.concat( + children.filter((c) => hidden.has(c.id) && assigned(c) && (allocations[c.id] || 0) !== 0) + ); + return { + relevantChildren, + contributors, + badgeIds: assignedTo.filter((cid) => !hidden.has(cid)), + }; + } + _selectChild(id) { this._selectedChildId = id; } @@ -1234,7 +1266,7 @@ class TaskMateRewardsCard extends LitElement { : ""} - ${this._renderChildTabs(children)} + ${this._renderChildTabs(this._family(children))} ${anyPoolReward && activeChild ? this._renderSpendableBanner(activeChild, pointsIcon, pointsName) : ''} @@ -1361,10 +1393,8 @@ class TaskMateRewardsCard extends LitElement { // All costs are static — just use reward.cost const displayCost = this._getDisplayCost(reward); - // Get relevant children for this reward - const relevantChildren = assignedTo.length === 0 - ? children - : children.filter(c => assignedTo.includes(c.id)); + // Get relevant children for this reward (family-filtered, #1071) + const { relevantChildren, contributors, badgeIds } = this._rewardPeople(reward, children); // Determine active child context const childId = this.config?.child_id || this._selectedChildId; @@ -1381,7 +1411,7 @@ class TaskMateRewardsCard extends LitElement { let childContributions = []; if (isJackpot) { - relevantChildren.forEach((child, index) => { + contributors.forEach((child, index) => { // In pool mode, show allocated points only; in wallet mode show wallet points const points = enablePoolMode ? (poolAllocations[child.id] || 0) @@ -1484,7 +1514,7 @@ class TaskMateRewardsCard extends LitElement {
${assignedTo.length === 0 ? html`${this._t('rewards.all_children')}` - : assignedTo.map( + : badgeIds.map( (cid) => html`${childMap[cid] || cid}` )} @@ -1858,7 +1888,6 @@ class TaskMateRewardsCard extends LitElement { * _getDisplayCost — static only). Returns everything the designed rows need. */ _rewardData(reward, children) { - const assignedTo = reward.assigned_to || []; const isJackpot = reward.is_jackpot || false; const cardForcesPool = this.config.enable_pool_mode === true; // Jackpot rewards are always pool-mode (#552): everyone deposits into the @@ -1867,9 +1896,7 @@ class TaskMateRewardsCard extends LitElement { const enablePoolMode = cardForcesPool || reward.pool_enabled === true || isJackpot; const displayCost = this._getDisplayCost(reward); - const relevantChildren = assignedTo.length === 0 - ? children - : children.filter(c => assignedTo.includes(c.id)); + const { relevantChildren, contributors } = this._rewardPeople(reward, children); const childId = this.config?.child_id || this._selectedChildId; const relevantChild = childId @@ -1883,7 +1910,7 @@ class TaskMateRewardsCard extends LitElement { const childContributions = []; if (isJackpot) { - relevantChildren.forEach((child, index) => { + contributors.forEach((child, index) => { const points = enablePoolMode ? (poolAllocations[child.id] || 0) : (child.points || 0); @@ -2006,7 +2033,7 @@ class TaskMateRewardsCard extends LitElement { const body = sortedRewards.length === 0 ? html`
${this._t('rewards.empty_title')}
${this._t('rewards.empty_subtitle')}
` : html` - ${this._designChildTabs(children)} + ${this._designChildTabs(this._family(children))} ${(anyPoolReward && activeChild) || activeChild ? this._designChildSelector(activeChild, pointsIcon, pointsName, design) : ''} @@ -2064,7 +2091,7 @@ class TaskMateRewardsCard extends LitElement { const badges = showChildBadges && !d.isJackpot ? (assignedTo.length === 0 ? html`${this._t('rewards.all_children')}` - : assignedTo.map((cid) => html`${childMap[cid] || cid}`)) + : this._rewardPeople(reward, children).badgeIds.map((cid) => html`${childMap[cid] || cid}`)) : ''; const costBadge = html` ${d.displayCost} ${pointsName}`; @@ -2353,6 +2380,7 @@ class TaskMateRewardsCardEditor extends LitElement { show_child_badges: this._t('rewards.editor.show_child_badges'), enable_pool_mode: this._t('rewards.editor.enable_pool_mode'), expand_to_fit: this._t('rewards.editor.expand_to_fit'), + show_adults: this._t('common.editor.show_adults'), }; return labels[schemaEntry.name] ?? schemaEntry.name; }; @@ -2365,6 +2393,7 @@ class TaskMateRewardsCardEditor extends LitElement { show_child_badges: this._t('rewards.editor.show_child_badges_helper'), enable_pool_mode: this._t('rewards.editor.enable_pool_mode_helper'), expand_to_fit: this._t('rewards.editor.expand_to_fit_helper'), + show_adults: this._t('common.editor.show_adults_helper'), }; return helpers[schemaEntry.name] ?? ''; }; @@ -2382,13 +2411,14 @@ class TaskMateRewardsCardEditor extends LitElement { show_child_badges: this.config.show_child_badges !== false, enable_pool_mode: this.config.enable_pool_mode === true, expand_to_fit: this.config.expand_to_fit !== false, + show_adults: this.config.show_adults === true, }; return html` { assert.ok(!leaderboard({}).includes("Mum")); assert.ok(leaderboard({ show_adults: true }).includes("Mum")); }); + +// ── Family-wide cards that still listed adults (#1071) ───────────────────── +// Each card is loaded with the real family filter and editor field from the +// resolver, so these fail if a card stops calling them. + +const ADULT_HELPERS = { + __taskmate_attrs: resolver.__taskmate_attrs, + __taskmate_family_children: family, + __taskmate_adults_field: resolver.__taskmate_adults_field, +}; +const hassWith = (attributes) => ({ states: { [OVERVIEW]: { state: "1", attributes: { children: CHILDREN, ...attributes } } } }); +const DESIGNED = "playroom"; +// Arrays built inside a card's sandbox have that realm's prototypes. +const plain = (x) => JSON.parse(JSON.stringify(x)); + +// Rewards card + +const RewardsCard = loadCard("taskmate-rewards-card.js", { window: ADULT_HELPERS }).get("taskmate-rewards-card"); +const RewardsEditor = loadCard("taskmate-rewards-card.js", { window: ADULT_HELPERS }).get("taskmate-rewards-card-editor"); + +const TV = { id: "tv", name: "Extra TV", cost: 20, assigned_to: [] }; +const PIZZA = { id: "pizza", name: "Pizza night", cost: 30, assigned_to: ["mum", "k1"] }; +const JACKPOT = (allocs) => ({ + id: "jp", name: "Trampoline", cost: 100, is_jackpot: true, assigned_to: [], + pool_allocations: allocs, jackpot_pool_total: Object.values(allocs).reduce((a, b) => a + b, 0), +}); + +function rewardsCard(config, rewards = [TV, PIZZA]) { + const card = new RewardsCard(); + card.config = { entity: OVERVIEW, ...config }; + card.hass = hassWith({ rewards }); + card.willUpdate(); + return card; +} + +const rewardsMarkup = (card, design) => { + card.config = { ...card.config, card_design: design }; + return render(card.render()).markup; +}; + +for (const design of ["classic", DESIGNED]) { + test(`rewards (${design}): the child tabs hide an adult unless show_adults is set`, () => { + const hidden = rewardsMarkup(rewardsCard({}), design); + assert.ok(hidden.includes("Ella") && hidden.includes("Finn")); + assert.ok(!hidden.includes("Mum"), "adult tab or badge leaked"); + assert.ok(rewardsMarkup(rewardsCard({ show_adults: true }), design).includes("Mum")); + }); + + test(`rewards (${design}): a card pinned to the adult still shows her`, () => { + const card = rewardsCard({ child_id: "mum" }); + assert.ok(rewardsMarkup(card, design).includes("Mum")); + }); + + test(`rewards (${design}): the jackpot breakdown drops an adult with nothing in it`, () => { + const card = rewardsCard({}, [JACKPOT({ k1: 10, k2: 5 })]); + const d = card._rewardData(JACKPOT({ k1: 10, k2: 5 }), CHILDREN); + assert.deepEqual(plain(d.childContributions.map((c) => c.child.id)), ["k1", "k2"]); + const markup = rewardsMarkup(card, design); + assert.ok(!markup.includes("Mum")); + if (design !== "classic") assert.equal(markup.split('class="chip rw-contrib"').length - 1, 2); + }); + + test(`rewards (${design}): the jackpot breakdown keeps an adult who put points in`, () => { + const card = rewardsCard({}, [JACKPOT({ mum: 40, k1: 10, k2: 5 })]); + const d = card._rewardData(JACKPOT({ mum: 40, k1: 10, k2: 5 }), CHILDREN); + assert.deepEqual(plain(d.childContributions.map((c) => c.child.id)), ["k1", "k2", "mum"]); + assert.equal(d.currentStars, 55); + const markup = rewardsMarkup(card, design); + if (design === "classic") { + assert.ok(markup.includes("Mum")); + assert.ok(markup.includes("55/100")); + } else { + assert.equal(markup.split('class="chip rw-contrib"').length - 1, 3); + } + }); +} + +test("rewards: the default selection is the first kid, not the adult", () => { + assert.equal(rewardsCard({})._selectedChildId, "k1"); + assert.equal(rewardsCard({ show_adults: true })._selectedChildId, "k1"); +}); + +test("rewards: a selected adult moves back to a kid once show_adults is off", () => { + const card = rewardsCard({ show_adults: true }); + card._selectedChildId = "mum"; + card.willUpdate(); + assert.equal(card._selectedChildId, "mum"); + card.config = { entity: OVERVIEW }; + card.willUpdate(); + assert.equal(card._selectedChildId, "k1"); +}); + +test("rewards: a pinned card keeps the adult's badge on the reward", () => { + const pinned = rewardsCard({ child_id: "k1" }); + assert.ok(render(pinned.render()).markup.includes("Mum")); +}); + +test("rewards: the editor offers the Show adults toggle", () => { + const editor = new RewardsEditor(); + editor.setConfig({ entity: OVERVIEW }); + editor.hass = hassWith({}); + assert.ok(render(editor.render()).markup.length > 0); + assert.equal(editor._computeLabel({ name: "show_adults" }), "Show adults"); + assert.equal(editor._computeHelper({ name: "show_adults" }), "Include adult profiles on this card"); +}); + +// Bounty card — always pinned to one child, but its "who can claim" avatar +// row for an open-to-everyone bounty is family-wide. + +const BountyCard = loadCard("taskmate-bounty-card.js", { window: ADULT_HELPERS }).get("taskmate-bounty-card"); +const BountyEditor = loadCard("taskmate-bounty-card.js", { window: ADULT_HELPERS }).get("taskmate-bounty-card-editor"); +const BOUNTY = { id: "b1", title: "Wash the car", points: 50, icon: "mdi:car-wash", status: "open", claim_hours: 2 }; + +function bountyCard(config) { + const card = new BountyCard(); + card.setConfig({ entity: OVERVIEW, ...config }); + card.hass = hassWith({ bounties: [BOUNTY] }); + return card; +} + +test("bounty: an open-to-everyone bounty hides the adult unless show_adults is set", () => { + assert.deepEqual(plain(bountyCard({ child_id: "k1" })._everyoneIds()), ["k1", "k2"]); + assert.ok(!render(bountyCard({ child_id: "k1" }).render()).markup.includes('title="Mum"')); + assert.deepEqual(plain(bountyCard({ child_id: "k1", show_adults: true })._everyoneIds()), ["k1", "k2", "mum"]); + assert.ok(render(bountyCard({ child_id: "k1", show_adults: true }).render()).markup.includes('title="Mum"')); +}); + +test("bounty: the adult's own card still lists her", () => { + assert.deepEqual(plain(bountyCard({ child_id: "mum" })._everyoneIds()), ["k1", "k2", "mum"]); +}); + +test("bounty: a bounty that names its claimers is shown as named", () => { + const card = bountyCard({ child_id: "k1" }); + card.hass = hassWith({ bounties: [{ ...BOUNTY, eligible: ["mum", "k1"] }] }); + assert.ok(render(card.render()).markup.includes('title="Mum"')); +}); + +test("bounty: the editor offers the Show adults toggle", () => { + const editor = new BountyEditor(); + editor.setConfig({ entity: OVERVIEW, child_id: "k1" }); + editor.hass = hassWith({}); + assert.equal(editor._computeLabel({ name: "show_adults" }), "Show adults"); + assert.equal(editor._computeHelper({ name: "show_adults" }), "Include adult profiles on this card"); +}); + +// Bonuses / penalties — the shared incentive base's child picker. + +function incentive(tag) { + const loaded = loadCard("taskmate-incentive-card.js", { + window: ADULT_HELPERS, + transform: (src) => src.replace("export function createIncentiveCard", "function createIncentiveCard") + + `\ncreateIncentiveCard({ tag: "${tag}", kind: "bonus", idKey: "bonus_id", i18n: "bonuses", attr: "bonuses",` + + ` icon: "mdi:star", headerIcon: "mdi:star", applyIcon: "mdi:plus", sign: "+", dpts: (n) => "+" + n,` + + ` accent: "#2e7d32", designHd: "#2e7d32", designIcons: {}, cardName: "x", cardDesc: "x", bannerName: "X", bannerColor: "#000" });`, + sandbox: { console: { info() {}, log() {}, warn() {}, error() {} } }, + }); + return { Card: loaded.get(tag), Editor: loaded.get(`${tag}-editor`) }; +} + +const { Card: BonusCard, Editor: BonusEditor } = incentive("taskmate-bonuses-card"); + +function bonusCard(config) { + const card = new BonusCard(); + card.setConfig({ entity: OVERVIEW, ...config }); + card.hass = hassWith({ bonuses: [{ id: "x", name: "Helped out", points: 5 }] }); + return card; +} + +for (const design of ["classic", DESIGNED]) { + test(`bonuses (${design}): the child picker hides an adult unless show_adults is set`, () => { + const hidden = render(bonusCard({ card_design: design }).render()).markup; + assert.ok(hidden.includes("Ella") && !hidden.includes("Mum")); + assert.ok(render(bonusCard({ card_design: design, show_adults: true }).render()).markup.includes("Mum")); + }); +} + +test("bonuses: the first kid is selected by default, and a stale adult selection falls back", () => { + assert.equal(bonusCard({})._getSelectedChild().id, "k1"); + const card = bonusCard({}); + card._selectedChildId = "mum"; + assert.equal(card._getSelectedChild().id, "k1"); +}); + +test("bonuses: the editor offers the Show adults toggle", () => { + const editor = new BonusEditor(); + editor.setConfig({ entity: OVERVIEW }); + editor.hass = hassWith({}); + assert.equal(editor._computeLabel({ name: "show_adults" }), "Show adults"); + assert.equal(editor._computeHelper({ name: "show_adults" }), "Include adult profiles on this card"); +}); + +// ── The editor toggle shows what the card has saved (#1071) ──────────────── +// ha-form draws a field from its data object, so a card saved with +// show_adults: true has to hand that value back or the toggle reads off. + +function formDataValues(tree, out = []) { + if (Array.isArray(tree)) tree.forEach((v) => formDataValues(v, out)); + else if (tree && tree.__tmTemplate) tree.values.forEach((v) => formDataValues(v, out)); + else if (tree && typeof tree === "object") out.push(tree); + return out; +} + +for (const name of [ + "calendar", "graph", "leaderboard", "overview", "parent-dashboard", "points", "points-display", + "reward-progress", "sticker-chart", "streak", "weekly", "rewards", "bonuses", "bounty", +]) { + test(`${name}: the editor's Show adults toggle reflects a saved show_adults`, () => { + const Editor = name === "bonuses" + ? incentive("taskmate-bonuses-card").Editor + : loadCard(`taskmate-${name}-card.js`, { window: ADULT_HELPERS, topLevelAwait: name === "sticker-chart" }).get(`taskmate-${name}-card-editor`); + const editor = new Editor(); + editor.setConfig({ entity: OVERVIEW, show_adults: true, ...(name === "bounty" ? { child_id: "k1" } : {}) }); + editor.hass = hassWith({}); + const shown = formDataValues(editor.render()).some((v) => v.show_adults === true); + assert.ok(shown, `${name} editor passes show_adults to ha-form`); + }); +}