From 0d3ae2400de31a7496250098d94409622d3e6124 Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Thu, 15 Oct 2026 16:35:23 +0000 Subject: [PATCH] fix(cards): keep adult profiles off the rewards, bonus, penalty and bounty cards (#1071) The rewards card's child tabs, default child, jackpot breakdown and child badges, the bonuses/penalties child picker and a bounty's open-to-everyone row now follow the same family rule as the leaderboard: adults only with show_adults, and a pinned child_id is unaffected. A jackpot still lists an adult who has put points into it, so the shares add up. Each of those editors gains the Show adults toggle. The existing Show adults toggles also read off after reopening a card that had it on, because the editors' form data left the value out. They now pass it through. --- .../taskmate/www/taskmate-bounty-card.js | 24 +- .../taskmate/www/taskmate-calendar-card.js | 2 +- .../taskmate/www/taskmate-graph-card.js | 2 +- .../taskmate/www/taskmate-incentive-card.js | 10 +- .../taskmate/www/taskmate-leaderboard-card.js | 2 +- .../taskmate/www/taskmate-overview-card.js | 2 +- .../www/taskmate-parent-dashboard-card.js | 2 +- .../taskmate/www/taskmate-points-card.js | 2 +- .../www/taskmate-points-display-card.js | 2 +- .../www/taskmate-reward-progress-card.js | 2 +- .../taskmate/www/taskmate-rewards-card.js | 68 ++++-- .../taskmate/www/taskmate-streak-card.js | 2 +- .../taskmate/www/taskmate-weekly-card.js | 2 +- tests/frontend/harness.cjs | 5 +- .../frontend/taskmate-adult-profiles.test.cjs | 217 ++++++++++++++++++ 15 files changed, 309 insertions(+), 35 deletions(-) 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`); + }); +}