diff --git a/README.md b/README.md index 89ff5863..38e38fef 100755 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ 3. **Parents approve** — chores marked "requires approval" land in a pending queue 4. **Kids claim rewards** — points are only deducted once a parent approves the claim -Everything is managed from the **TaskMate panel** in the Home Assistant sidebar, and surfaced on your dashboards through 30 Lovelace cards. +Everything is managed from the **TaskMate panel** in the Home Assistant sidebar, and surfaced on your dashboards through 31 Lovelace cards. All data stays inside your Home Assistant instance. Nothing is sent to any external service. @@ -73,9 +73,9 @@ For manual installation and the first-run walkthrough, see [Installation](https: **Chores** — flexible [scheduling](https://tempus2016.github.io/taskmate/Chore-Scheduling/) (specific days, recurring down to every N days, rotation, first-come), [difficulty tiers](https://tempus2016.github.io/taskmate/Difficulty-Tiers/), [dependencies](https://tempus2016.github.io/taskmate/Chore-Dependencies/), [timed tasks](https://tempus2016.github.io/taskmate/Timed-Tasks/), [weekly targets](https://tempus2016.github.io/taskmate/Weekly-Targets/) for "three times this week, any day" with an optional bonus for hitting them, [photo proof](https://tempus2016.github.io/taskmate/Photo-Proof/), [open-ended chores](https://tempus2016.github.io/taskmate/Open-Ended-Chores/) the child describes and you price at approval, [teamwork chores](https://tempus2016.github.io/taskmate/Teamwork-Chores/) that need two or more children, [chore auctions](https://tempus2016.github.io/taskmate/Chore-Auctions/) where the lowest bid wins an unpopular job, a [bounty board](https://tempus2016.github.io/taskmate/Bounty-Board/) of one-off jobs any child can claim, [swaps](https://tempus2016.github.io/taskmate/Chore-Swaps/), [vacation mode](https://tempus2016.github.io/taskmate/Vacation-Mode/), and chores that appear or hide based on [HA entity state](https://tempus2016.github.io/taskmate/Dynamic-Chore-Visibility/) or [the weather](https://tempus2016.github.io/taskmate/Weather-Aware-Chores/). Children can [tap an NFC tag](https://tempus2016.github.io/taskmate/NFC-Tag-Completion/) to tick a chore off, and you can add, move and skip chores straight from [Home Assistant's calendar](https://tempus2016.github.io/taskmate/Two-Way-Calendar/). Missed ticking one? [Log a past job](https://tempus2016.github.io/taskmate/Logging-Past-Jobs/) on the day it was really done, up to a week back. Chores can carry an [estimated time](https://tempus2016.github.io/taskmate/Estimated-Minutes/) so children see how long is left, a [reference photo](https://tempus2016.github.io/taskmate/Reference-Photos/) of the job done well, or a [quantity](https://tempus2016.github.io/taskmate/Quantity-Chores/) that pays per item, and pending chores can [approve themselves](https://tempus2016.github.io/taskmate/Auto-Approve/) after a set number of hours. -**Points & rewards** — [fixed-cost rewards](https://tempus2016.github.io/taskmate/Rewards/) with a parent approval flow, [savings jars](https://tempus2016.github.io/taskmate/Pool-Mode-\(Savings-Jars\)/), a [wishlist](https://tempus2016.github.io/taskmate/Wishlist/) children save towards and family can pledge to, [bonuses](https://tempus2016.github.io/taskmate/Bonuses/) and [penalties](https://tempus2016.github.io/taskmate/Penalties/), [weekend multipliers and streak milestones](https://tempus2016.github.io/taskmate/Bonus-Points/), [1–3 star quality ratings](https://tempus2016.github.io/taskmate/Quality-Ratings/) that scale the points, [real-money allowance](https://tempus2016.github.io/taskmate/Allowance/), [shared family goals](https://tempus2016.github.io/taskmate/Family-Goals/), [timed device unlocks](https://tempus2016.github.io/taskmate/Timed-Unlock-Rewards/), and [time-locked rewards](https://tempus2016.github.io/taskmate/Time-Locked-Rewards/) that are only claimable on chosen days or between set hours. Children can buy tickets for a [raffle](https://tempus2016.github.io/taskmate/Raffles/) with their points, and a [make-it-right penalty](https://tempus2016.github.io/taskmate/Make-It-Right-Penalties/) can be earned back by doing a chore. +**Points & rewards** — [fixed-cost rewards](https://tempus2016.github.io/taskmate/Rewards/) with a parent approval flow, [savings jars](https://tempus2016.github.io/taskmate/Pool-Mode-\(Savings-Jars\)/), a [wishlist](https://tempus2016.github.io/taskmate/Wishlist/) children save towards and family can pledge to, [bonuses](https://tempus2016.github.io/taskmate/Bonuses/) and [penalties](https://tempus2016.github.io/taskmate/Penalties/), [weekend multipliers and streak milestones](https://tempus2016.github.io/taskmate/Bonus-Points/), [1–3 star quality ratings](https://tempus2016.github.io/taskmate/Quality-Ratings/) that scale the points, [real-money allowance](https://tempus2016.github.io/taskmate/Allowance/), [shared family goals](https://tempus2016.github.io/taskmate/Family-Goals/), [timed device unlocks](https://tempus2016.github.io/taskmate/Timed-Unlock-Rewards/), and [time-locked rewards](https://tempus2016.github.io/taskmate/Time-Locked-Rewards/) that are only claimable on chosen days or between set hours. Children can buy tickets for a [raffle](https://tempus2016.github.io/taskmate/Raffles/) with their points, a [make-it-right penalty](https://tempus2016.github.io/taskmate/Make-It-Right-Penalties/) can be earned back by doing a chore, and [reward fulfilment](https://tempus2016.github.io/taskmate/Reward-Fulfilment/) keeps a redeemed reward on an owed list until somebody hands it over. -**Gamification** — a [virtual pet](https://tempus2016.github.io/taskmate/Virtual-Pets/) that hatches and grows as chores get done, [personal weekly goals](https://tempus2016.github.io/taskmate/Weekly-Goals/) with a bonus, [levels & XP](https://tempus2016.github.io/taskmate/Levels-and-XP/), [achievement badges](https://tempus2016.github.io/taskmate/Achievement-Badges/), [quests](https://tempus2016.github.io/taskmate/Quests/), [challenges](https://tempus2016.github.io/taskmate/Challenges/), [unlockable avatars](https://tempus2016.github.io/taskmate/Avatars/), [celebrations](https://tempus2016.github.io/taskmate/Celebrations/), [leaderboard seasons](https://tempus2016.github.io/taskmate/Leaderboard-Seasons/), [streak freezes](https://tempus2016.github.io/taskmate/Streak-Freezes/) that protect a streak through a missed day, [birthday mode](https://tempus2016.github.io/taskmate/Birthday-Mode/), [surprise inspections](https://tempus2016.github.io/taskmate/Surprise-Inspections/) with a bonus, and "Wrapped"-style [recaps](https://tempus2016.github.io/taskmate/Recaps/) on a schedule you choose. +**Gamification** — a [virtual pet](https://tempus2016.github.io/taskmate/Virtual-Pets/) that hatches and grows as chores get done, [personal weekly goals](https://tempus2016.github.io/taskmate/Weekly-Goals/) with a bonus, [personal bests](https://tempus2016.github.io/taskmate/Personal-Bests/) every child can beat whatever their place on the leaderboard, [levels & XP](https://tempus2016.github.io/taskmate/Levels-and-XP/), [achievement badges](https://tempus2016.github.io/taskmate/Achievement-Badges/), [quests](https://tempus2016.github.io/taskmate/Quests/), [challenges](https://tempus2016.github.io/taskmate/Challenges/), [unlockable avatars](https://tempus2016.github.io/taskmate/Avatars/), [celebrations](https://tempus2016.github.io/taskmate/Celebrations/), [leaderboard seasons](https://tempus2016.github.io/taskmate/Leaderboard-Seasons/), [streak freezes](https://tempus2016.github.io/taskmate/Streak-Freezes/) that protect a streak through a missed day, [birthday mode](https://tempus2016.github.io/taskmate/Birthday-Mode/), [surprise inspections](https://tempus2016.github.io/taskmate/Surprise-Inspections/) with a bonus, and "Wrapped"-style [recaps](https://tempus2016.github.io/taskmate/Recaps/) on a schedule you choose. **Parent tooling** — an [admin panel](https://tempus2016.github.io/taskmate/Admin-Panel/) with a Today home page and a [setup wizard](https://tempus2016.github.io/taskmate/Setup-Wizard/) that suggests chores by age, [ready-made dashboards](https://tempus2016.github.io/taskmate/Ready-Made-Dashboards/) for the kids, the parents and a wall tablet, [notifications](https://tempus2016.github.io/taskmate/Notifications/) in your Home Assistant language, with [quiet hours](https://tempus2016.github.io/taskmate/Quiet-Hours/), [reminder escalation](https://tempus2016.github.io/taskmate/Reminder-Escalation/) and [presence-aware reminders](https://tempus2016.github.io/taskmate/Presence-Aware-Reminders/) that wait until a child is home, [reject reasons](https://tempus2016.github.io/taskmate/Reject-Reasons/) the child can see, [multi-parent approval routing](https://tempus2016.github.io/taskmate/Multi-Parent-Approval-Routing/), a [weekly digest](https://tempus2016.github.io/taskmate/Weekly-Digest/), [insight reports](https://tempus2016.github.io/taskmate/Insights/) on fairness and friction, an [audit log](https://tempus2016.github.io/taskmate/Admin-Audit-Log/), [sick days](https://tempus2016.github.io/taskmate/Sick-Days/) that pause chores and protect streaks, [event entities](https://tempus2016.github.io/taskmate/Event-Entities/) for building automations in the UI editor, [Repairs issues and a diagnostics download](https://tempus2016.github.io/taskmate/Repairs-and-Diagnostics/), [adult profiles](https://tempus2016.github.io/taskmate/Adult-Profiles/) so parents can track their own chores without approving each other's, and [backup & restore](https://tempus2016.github.io/taskmate/Backup-and-Restore/). @@ -89,7 +89,7 @@ Lovelace resources register automatically on startup. Edit your dashboard → ** | For kids | For parents | |----------|-------------| -| Child · Sticker Chart · Pet · Raffle · Rewards · Reward Progress · Points Display · Routine · Kiosk · Streak · Badges · Bounty Board · Wishlist · Recap · Chore Auction | Approvals · Parent Dashboard · Overview · Chore Board · Activity · Points · Reorder · Penalties · Bonuses · Weekly · Graph · Leaderboard · Calendar · Family Goal · Photo Gallery | +| Child · Sticker Chart · Pet · Raffle · Rewards · Reward Progress · Points Display · Routine · Kiosk · Streak · Personal Bests · Badges · Bounty Board · Wishlist · Recap · Chore Auction | Approvals · Parent Dashboard · Overview · Chore Board · Activity · Points · Reorder · Penalties · Bonuses · Weekly · Graph · Leaderboard · Calendar · Family Goal · Photo Gallery | Every card takes a `header_color` and a `card_design` option. See [Dashboard Cards](https://tempus2016.github.io/taskmate/Dashboard-Cards/) for each card's full configuration. @@ -108,7 +108,7 @@ Every card takes a `header_color` and a `card_design` option. See [Dashboard Car | [Getting Started](https://tempus2016.github.io/taskmate/Getting-Started/) | First-time setup walkthrough | | [Admin Panel](https://tempus2016.github.io/taskmate/Admin-Panel/) | The management hub in the HA sidebar | | [Settings](https://tempus2016.github.io/taskmate/Settings/) | Every configurable setting in one place | -| [Dashboard Cards](https://tempus2016.github.io/taskmate/Dashboard-Cards/) | All 30 Lovelace cards and their options | +| [Dashboard Cards](https://tempus2016.github.io/taskmate/Dashboard-Cards/) | All 31 Lovelace cards and their options | | [Services](https://tempus2016.github.io/taskmate/Services/) | Every callable service, with examples | | [Sensor Reference](https://tempus2016.github.io/taskmate/Sensor-Reference/) | All entities and their attributes | | [Automations](https://tempus2016.github.io/taskmate/Automations/) | Bus events, blueprints, and examples | @@ -134,4 +134,4 @@ Bug reports and feature requests are welcome in [Issues](https://github.com/temp MIT — see [LICENSE](LICENSE). -> Originally created by [vinnybad/choremander](https://github.com/vinnybad/choremander). This fork adds 30 Lovelace cards, an admin panel, a bonus points system, streak tracking, reward approval flow, a penalty system, and much more. +> Originally created by [vinnybad/choremander](https://github.com/vinnybad/choremander). This fork adds 31 Lovelace cards, an admin panel, a bonus points system, streak tracking, reward approval flow, a penalty system, and much more. diff --git a/custom_components/taskmate/__init__.py b/custom_components/taskmate/__init__.py index 1212e3b6..d2c4e65f 100644 --- a/custom_components/taskmate/__init__.py +++ b/custom_components/taskmate/__init__.py @@ -2030,6 +2030,7 @@ async def handle_rebuild_badges(call: ServiceCall) -> None: parent_action=_parent, get_coordinator=lambda: _get_coordinator(hass), require_linked_child=lambda *args: _async_require_linked_child(*args), + require_parent=lambda *args: _async_require_parent(*args), ) diff --git a/custom_components/taskmate/const.py b/custom_components/taskmate/const.py index 3073f065..7c6941a9 100644 --- a/custom_components/taskmate/const.py +++ b/custom_components/taskmate/const.py @@ -369,6 +369,15 @@ def is_valid_completion_sound(value: str | None) -> bool: NOTIF_TYPE_PENALTY_REDEEMED: Final = "penalty_redeemed" # Raffles: the winner was drawn. NOTIF_TYPE_RAFFLE_DRAWN: Final = "raffle_drawn" +# Reward fulfilment (#1098): a redeemed reward is still waiting to be handed over. +NOTIF_TYPE_REWARD_OWED: Final = "reward_owed" +# Personal bests (#1099): a child beat one of their own records. +NOTIF_TYPE_PERSONAL_BEST: Final = "personal_best" + +# Reward fulfilment (#1098): default days before parents are reminded a +# redeemed reward is still owed (0 = never remind), and the most allowed. +DEFAULT_REWARD_FULFILMENT_REMINDER_DAYS: Final = 3 +REWARD_FULFILMENT_REMINDER_MAX_DAYS: Final = 60 # Presence-aware reminders (#926): the default for how long a child must have # been away before arriving home earns a "you're home" nudge. diff --git a/custom_components/taskmate/coord_bests.py b/custom_components/taskmate/coord_bests.py new file mode 100644 index 00000000..59e010d7 --- /dev/null +++ b/custom_components/taskmate/coord_bests.py @@ -0,0 +1,249 @@ +"""Personal bests (#1099): a child's own records, and the moment they beat one. + +Badges and streak milestones celebrate round numbers set by a parent. A +personal best is the child's own bar: the most points they have ever earned +in a day or a week, the most chores in a day, their biggest single award and +their longest streak. Each record keeps the value and the day it was set. + +Records are checked after every approval. The first time a measure is seen +it is stored quietly as the baseline — a child's very first chore is not +"five records at once". From then on, going past a stored value fires +``taskmate_personal_best``, the opt-in ``personal_best`` notification and a +tier-2 celebration, and the record shows as new on the child's card for the +rest of the day. + +When an approval is undone or rejected the affected day or week is +recomputed from the completions still on file; a record that no longer +holds falls back to the best the kept history can show. Records older than +the kept history are left as they are. +""" + +from __future__ import annotations + +import logging +from datetime import date, timedelta +from typing import Any + +from homeassistant.util import dt as dt_util + +from . import notify_strings +from .const import NOTIF_TYPE_PERSONAL_BEST + +_LOGGER = logging.getLogger(__name__) + +RECORD_KEYS: tuple[str, ...] = ( + "longest_streak", + "best_day_points", + "best_day_chores", + "best_week_points", + "best_single_award", +) +_DAY_RECORDS = ("best_day_points", "best_day_chores") +_WEEK_RECORDS = ("best_week_points",) + + +def _week_start(day: date) -> date: + return day - timedelta(days=day.weekday()) + + +class PersonalBestsMixin: + """Per-child records with a new-record celebration.""" + + def _personal_bests(self) -> dict[str, dict[str, Any]]: + return self.storage.get_section("personal_bests", dict) + + def _child_completions(self, child_id: str) -> list: + return [c for c in self.storage.get_completions() if c.child_id == child_id and c.approved] + + def _local_day(self, comp) -> date: + return dt_util.as_local(comp.completed_at).date() + + def _measures_for(self, child, completions: list) -> dict[str, tuple[int, str]]: + """Each record's current value with the day it belongs to.""" + today = dt_util.as_local(dt_util.now()).date() + week_start = _week_start(today) + day_points = day_chores = week_points = 0 + single = (0, "") + for comp in completions: + day = self._local_day(comp) + pts = int(comp.points_awarded or 0) + if day == today: + day_points += pts + if not comp.bonus_subtask_id: + day_chores += 1 + if day >= week_start: + week_points += pts + if pts > single[0]: + single = (pts, day.isoformat()) + return { + "longest_streak": (int(getattr(child, "current_streak", 0) or 0), today.isoformat()), + "best_day_points": (day_points, today.isoformat()), + "best_day_chores": (day_chores, today.isoformat()), + "best_week_points": (week_points, week_start.isoformat()), + "best_single_award": single, + } + + def personal_bests_state(self, child) -> dict[str, Any]: + """The records board for a card, or {} while there is nothing to show. + + ``new_today`` lists the records beaten today, so a card can make a + fuss for the rest of the day. + """ + section = self._personal_bests() + records = (section.get(child.id) or {}) if isinstance(section, dict) else {} + out: dict[str, Any] = {} + today = dt_util.as_local(dt_util.now()).date().isoformat() + new_today = [] + for key in RECORD_KEYS: + rec = records.get(key) + if not rec: + continue + entry = {"value": int(rec.get("value", 0) or 0), "date": rec.get("date", "") or ""} + if rec.get("previous") is not None and str(rec.get("set_at", ""))[:10] == today: + entry["previous"] = int(rec["previous"]) + new_today.append(key) + out[key] = entry + if "longest_streak" not in out and int(getattr(child, "best_streak", 0) or 0) > 0: + # Streaks were tracked long before records were: show the old best + # so nobody starts from zero. It has no date of its own. + out["longest_streak"] = {"value": int(child.best_streak), "date": ""} + if new_today: + out["new_today"] = new_today + return out + + async def async_check_personal_bests(self, child_id: str, refresh: bool = True) -> list[str]: + """Store any measure now past its record. Returns the records beaten (not baselines). + + Runs after the approval path's own refresh, so when something changed + the cards need another push or the new record sits unseen until the + next unrelated update. Batch callers (a filled team, Approve all) pass + ``refresh=False`` and refresh once themselves (#794). + """ + child = self.get_child(child_id) + if child is None: + return [] + section = self._personal_bests() + if not isinstance(section, dict): + return [] + records = section.setdefault(child.id, {}) + measures = self._measures_for(child, self._child_completions(child.id)) + now = dt_util.now() + beaten: list[str] = [] + changed = False + for key, (value, day) in measures.items(): + if value <= 0: + continue + rec = records.get(key) + if rec is None: + if key == "longest_streak" and int(getattr(child, "best_streak", 0) or 0) > value: + # The old best streak is the bar to beat, not today's run. + records[key] = {"value": int(child.best_streak), "date": "", "set_at": now.isoformat()} + else: + records[key] = {"value": value, "date": day, "set_at": now.isoformat()} + changed = True + continue + previous = int(rec.get("value", 0) or 0) + if value <= previous: + continue + records[key] = {"value": value, "date": day, "set_at": now.isoformat(), "previous": previous} + changed = True + beaten.append(key) + if not changed: + return [] + await self.storage.async_save() + points_name = self.storage.get_points_name() + for key in beaten: + rec = records[key] + label = notify_strings.render(self.hass, f"record_{key}", {"points_name": points_name}) + payload = { + "child_id": child.id, + "child_name": child.name, + "record": key, + "value": rec["value"], + "previous": rec["previous"], + "date": rec["date"], + "timestamp": now.isoformat(), + } + self.hass.bus.async_fire("taskmate_personal_best", payload) + if getattr(self, "notifications", None): + await self.notifications.fire( + NOTIF_TYPE_PERSONAL_BEST, + {**payload, "record_label": label, "points_name": points_name}, + ) + if hasattr(self, "_celebrate"): + await self._celebrate( + child, + "personal_best", + notify_strings.render( + self.hass, + "celebrate_personal_best", + {"child_name": child.name, "record_label": label, "value": rec["value"]}, + ), + tier=2, + extra={"record": key, "value": rec["value"], "previous": rec["previous"]}, + ) + if refresh: + await self.async_refresh() + return beaten + + async def async_recompute_personal_bests(self, child_id: str) -> None: + """After a reversal: a record set on a day (or week) we still hold must still be true.""" + section = self._personal_bests() + records = section.get(child_id) if isinstance(section, dict) else None + if not records: + return + completions = self._child_completions(child_id) + if completions: + oldest = min(self._local_day(c) for c in completions) + else: + oldest = dt_util.as_local(dt_util.now()).date() + days: dict[str, dict[str, int]] = {} + single: dict[str, int] = {} + for comp in completions: + day = self._local_day(comp).isoformat() + pts = int(comp.points_awarded or 0) + bucket = days.setdefault(day, {"points": 0, "chores": 0}) + bucket["points"] += pts + if not comp.bonus_subtask_id: + bucket["chores"] += 1 + single[day] = max(single.get(day, 0), pts) + weeks: dict[str, int] = {} + for day, bucket in days.items(): + wk = _week_start(date.fromisoformat(day)).isoformat() + weeks[wk] = weeks.get(wk, 0) + bucket["points"] + + def best(table: dict[str, int]) -> tuple[int, str]: + if not table: + return (0, "") + day, value = max(table.items(), key=lambda kv: (kv[1], kv[0])) + return (value, day) + + tables = { + "best_day_points": {d: b["points"] for d, b in days.items()}, + "best_day_chores": {d: b["chores"] for d, b in days.items()}, + "best_week_points": weeks, + "best_single_award": single, + } + changed = False + for key, table in tables.items(): + rec = records.get(key) + if not rec or not rec.get("date"): + continue + rec_day = date.fromisoformat(rec["date"]) + if key in _WEEK_RECORDS: + in_history = rec_day + timedelta(days=6) >= oldest + else: + in_history = rec_day >= oldest + if not in_history: + continue + still = table.get(rec["date"], 0) + if still >= int(rec.get("value", 0) or 0): + continue + value, day = best(table) + if value > 0: + records[key] = {"value": value, "date": day, "set_at": ""} + else: + records.pop(key, None) + changed = True + if changed: + await self.storage.async_save() diff --git a/custom_components/taskmate/coord_chores.py b/custom_components/taskmate/coord_chores.py index a1be9ec7..8fb238f7 100644 --- a/custom_components/taskmate/coord_chores.py +++ b/custom_components/taskmate/coord_chores.py @@ -1162,6 +1162,12 @@ async def _async_after_completions(self, chore, recorded: list, *, auto_approve: photo_url=completion.photo_url, ) + # Personal bests (#1099) before the batch's one refresh, so a new + # record reaches the cards with it rather than costing a second one. + if auto_approve and hasattr(self, "async_check_personal_bests"): + for child, _completion, _points in recorded: + await self.async_check_personal_bests(child.id, refresh=False) + await self.async_refresh() if not auto_approve: @@ -1668,6 +1674,11 @@ async def async_approve_chore( self._check_one_shot_fully_disabled(chore) self.storage.update_chore(chore) + # Personal bests (#1099): did this approval beat a record? + # Checked before the refresh below so a new record rides it. + if hasattr(self, "async_check_personal_bests"): + await self.async_check_personal_bests(completion.child_id, refresh=False) + await self.storage.async_save() if refresh: await self.async_refresh() @@ -1939,6 +1950,8 @@ async def async_reject_chore( await self._async_reverse_challenges(target_completion.child_id) if target_completion and target_completion.approved and hasattr(self, "async_reverse_weekly_goals"): await self.async_reverse_weekly_goals(target_completion.child_id) + if target_completion and target_completion.approved and hasattr(self, "async_recompute_personal_bests"): + await self.async_recompute_personal_bests(target_completion.child_id) # The completion record is gone, so its evidence photo is now orphaned — # delete it (best-effort; foreign/blank URLs are ignored), unless some # other record still points at the same file. @@ -2035,6 +2048,8 @@ async def async_undo_chore_approval(self, completion_id: str) -> None: await self._async_reverse_challenges(target.child_id) if hasattr(self, "async_reverse_weekly_goals"): await self.async_reverse_weekly_goals(target.child_id) + if hasattr(self, "async_recompute_personal_bests"): + await self.async_recompute_personal_bests(target.child_id) await self.storage.async_save() await self.async_refresh() diff --git a/custom_components/taskmate/coord_fulfilment.py b/custom_components/taskmate/coord_fulfilment.py new file mode 100644 index 00000000..282e5724 --- /dev/null +++ b/custom_components/taskmate/coord_fulfilment.py @@ -0,0 +1,180 @@ +"""Reward fulfilment tracker (#1098): a redeemed reward stays owed until it is handed over. + +Approving a claim spends the points; nothing used to record whether the +child ever got the thing. With ``reward_fulfilment_enabled`` on, every claim +approved for a reward a person has to hand over is marked ``needs_delivery`` +and sits in the owed list until a parent marks it delivered (approvals card, +parent dashboard, panel Today page) or the child taps "Got it!" on their own +card. Parents are reminded after ``reward_fulfilment_reminder_days`` days, +and again every that-many days while it stays owed (0 = never). + +Rewards the system fulfils on its own — streak freezes and timed unlocks — +and wishlist redemptions, which have their own hand-over step, are never +tracked. Claims approved while the tracker was off are not back-filled when +it is turned on. +""" + +from __future__ import annotations + +import logging +from datetime import timedelta +from typing import Any + +from homeassistant.util import dt as dt_util + +from .const import ( + DEFAULT_REWARD_FULFILMENT_REMINDER_DAYS, + NOTIF_TYPE_REWARD_OWED, + REWARD_FULFILMENT_REMINDER_MAX_DAYS, +) + +_LOGGER = logging.getLogger(__name__) + + +class FulfilmentMixin: + """Owed rewards and the hand-over step.""" + + def reward_fulfilment_enabled(self) -> bool: + v = self.storage.get_setting("reward_fulfilment_enabled", False) + return v is True or str(v).lower() == "true" + + def reward_fulfilment_reminder_days(self) -> int: + raw = self.storage.get_setting("reward_fulfilment_reminder_days", DEFAULT_REWARD_FULFILMENT_REMINDER_DAYS) + try: + days = int(raw) + except (TypeError, ValueError): + return 0 + return max(0, min(REWARD_FULFILMENT_REMINDER_MAX_DAYS, days)) + + @staticmethod + def reward_needs_delivery(reward) -> bool: + """True for a reward a person has to hand over (not one TaskMate grants itself).""" + if reward is None: + return False + if getattr(reward, "streak_freeze", False): + return False + return not getattr(reward, "unlock_entity", "") + + def _track_fulfilment(self, claim, reward) -> None: + """Called by the reward approval path once the claim is approved.""" + if self.reward_fulfilment_enabled() and self.reward_needs_delivery(reward): + claim.needs_delivery = True + + def _owed_claims(self) -> list: + if not self.reward_fulfilment_enabled(): + return [] + return [ + c + for c in self.storage.get_reward_claims() + if c.approved and getattr(c, "needs_delivery", False) and c.delivered_at is None + ] + + def owed_rewards(self, child_id: str | None = None) -> list[dict[str, Any]]: + """Approved rewards still to be handed over, oldest first. + + Each entry is what a card needs to draw the row and mark it done; the + child's face comes from the overview's children list, not from here. + """ + now = dt_util.now() + out: list[dict[str, Any]] = [] + for claim in self._owed_claims(): + if child_id and claim.child_id != child_id: + continue + child = self.get_child(claim.child_id) + reward = self.get_reward(claim.reward_id) + if child is None or reward is None: + continue + since = claim.approved_at or claim.claimed_at + days = max(0, (dt_util.as_local(now).date() - dt_util.as_local(since).date()).days) + out.append( + { + "claim_id": claim.id, + "child_id": child.id, + "child_name": child.name, + "reward_id": reward.id, + "reward_name": reward.name, + "reward_icon": reward.icon or "mdi:gift", + "cost": claim.approved_cost if claim.approved_cost is not None else reward.cost, + "approved_at": since.isoformat(), + "days_owed": days, + } + ) + out.sort(key=lambda o: o["approved_at"]) + return out + + async def async_mark_reward_delivered(self, claim_id: str, by: str = "parent", child_id: str | None = None) -> None: + """Hand-over done. ``by`` is "parent" or "child"; a child may only confirm their own.""" + claim = next((c for c in self.storage.get_reward_claims() if c.id == claim_id), None) + if claim is None or not claim.approved or not getattr(claim, "needs_delivery", False): + raise ValueError("That reward is not waiting to be handed over.") + if claim.delivered_at is not None: + raise ValueError("That reward has already been marked as delivered.") + if by == "child" and child_id != claim.child_id: + child = self.get_child(claim.child_id) + name = child.name if child else "someone else" + raise ValueError(f"That reward belongs to {name}.") + now = dt_util.now() + claim.delivered_at = now + claim.delivered_by = "child" if by == "child" else "parent" + self.storage.update_reward_claim(claim) + await self.storage.async_save() + if getattr(self, "notifications", None): + await self.notifications.clear_approval(NOTIF_TYPE_REWARD_OWED, claim.id) + child = self.get_child(claim.child_id) + reward = self.get_reward(claim.reward_id) + self.hass.bus.async_fire( + "taskmate_reward_delivered", + { + "child_id": claim.child_id, + "child_name": getattr(child, "name", ""), + "reward_id": claim.reward_id, + "reward_name": getattr(reward, "name", ""), + "claim_id": claim.id, + "delivered_by": claim.delivered_by, + "timestamp": now.isoformat(), + }, + ) + await self.async_refresh() + + async def async_check_owed_rewards(self, refresh: bool = False) -> int: + """Remind the parents about rewards owed for N days or more. Idempotent per period. + + Runs from the minutely tick. Returns how many reminders went out. + """ + days = self.reward_fulfilment_reminder_days() + if not days: + return 0 + now = dt_util.now() + gap = timedelta(days=days) + sent = 0 + for claim in self._owed_claims(): + since = claim.approved_at or claim.claimed_at + if now - since < gap: + continue + last = claim.owed_reminded_at + if last is not None and now - last < gap: + continue + child = self.get_child(claim.child_id) + reward = self.get_reward(claim.reward_id) + if child is None or reward is None: + continue + claim.owed_reminded_at = now + self.storage.update_reward_claim(claim) + await self.storage.async_save() + await self.notifications.fire( + NOTIF_TYPE_REWARD_OWED, + { + "child_id": child.id, + "child_name": child.name, + "reward_id": reward.id, + "reward_name": reward.name, + "claim_id": claim.id, + "entry_id": claim.id, + "days": (dt_util.as_local(now).date() - dt_util.as_local(since).date()).days, + "points_name": self.storage.get_points_name(), + }, + ) + sent += 1 + if sent and refresh: + await self.async_refresh() + return sent diff --git a/custom_components/taskmate/coord_notifications.py b/custom_components/taskmate/coord_notifications.py index 9183750a..fddc19eb 100644 --- a/custom_components/taskmate/coord_notifications.py +++ b/custom_components/taskmate/coord_notifications.py @@ -49,10 +49,12 @@ NOTIF_TYPE_PENALTY_REDEEMED, NOTIF_TYPE_PENDING_CHORE_APPROVAL, NOTIF_TYPE_PENDING_REWARD_CLAIM, + NOTIF_TYPE_PERSONAL_BEST, NOTIF_TYPE_PET_EVOLVED, NOTIF_TYPE_PRESENCE_ARRIVAL, NOTIF_TYPE_RAFFLE_DRAWN, NOTIF_TYPE_RECAP_READY, + NOTIF_TYPE_REWARD_OWED, NOTIF_TYPE_SEASON_CHAMPION, NOTIF_TYPE_STREAK_AT_RISK, NOTIF_TYPE_STREAK_FREEZE_USED, @@ -186,6 +188,12 @@ class NotificationTypeMeta: NotificationTypeMeta(NOTIF_TYPE_PENALTY_REDEEMED, "both", False, False, False, False), NotificationTypeMeta(NOTIF_TYPE_RAFFLE_DRAWN, "both", False, False, False, False), NotificationTypeMeta(NOTIF_TYPE_PET_EVOLVED, "both", False, False, False, False), + # Reward fulfilment (#1098): on by default like inspection_reminder — it + # only ever fires once a parent has switched the tracker on, and a + # tracker that nags nobody is just a list. + NotificationTypeMeta(NOTIF_TYPE_REWARD_OWED, "parent", False, False, False, True), + # Personal bests (#1099): opt-in, like every celebration added after release. + NotificationTypeMeta(NOTIF_TYPE_PERSONAL_BEST, "both", False, False, False, False), ] NOTIFICATION_TYPES_BY_ID: dict[str, NotificationTypeMeta] = {t.id: t for t in NOTIFICATION_TYPES} @@ -196,6 +204,7 @@ class NotificationTypeMeta: _OWN_EVENT_TYPES: frozenset[str] = frozenset( { NOTIF_TYPE_AUCTION_OPENED, + NOTIF_TYPE_PERSONAL_BEST, NOTIF_TYPE_BADGE_EARNED, NOTIF_TYPE_BOUNTY_POSTED, NOTIF_TYPE_CELEBRATION, @@ -496,6 +505,8 @@ async def send_test(self, type_id: str) -> list[str]: "raffle_name": sample["raffle"], "winner_name": "Alex", "tickets": 12, + "record_label": notify_strings.text(self.hass, "record_longest_streak"), + "value": 12, } ctx["result_text"] = notify_strings.render( self.hass, diff --git a/custom_components/taskmate/coord_rewards.py b/custom_components/taskmate/coord_rewards.py index 50bb815a..8b41286b 100644 --- a/custom_components/taskmate/coord_rewards.py +++ b/custom_components/taskmate/coord_rewards.py @@ -645,6 +645,8 @@ async def async_approve_reward(self, claim_id: str) -> None: claim.approved = True claim.approved_at = dt_util.now() claim.approved_cost = effective_cost + # Fulfilment tracker (#1098): owed until somebody hands it over. + self._track_fulfilment(claim, reward) self.storage.update_reward_claim(claim) # One pool, one redemption. Claims stored before a jackpot was diff --git a/custom_components/taskmate/coordinator.py b/custom_components/taskmate/coordinator.py index 0971ddba..df21bfce 100644 --- a/custom_components/taskmate/coordinator.py +++ b/custom_components/taskmate/coordinator.py @@ -23,11 +23,13 @@ from .coord_auto_approve import AutoApproveMixin from .coord_avatars import AvatarsMixin from .coord_badges import BadgeCoordinator +from .coord_bests import PersonalBestsMixin from .coord_birthdays import BirthdaysMixin from .coord_bounties import BountiesMixin from .coord_calendar import CalendarMixin from .coord_challenges import ChallengesMixin from .coord_chores import ChoresMixin +from .coord_fulfilment import FulfilmentMixin from .coord_goals import WeeklyGoalsMixin from .coord_guests import GuestsMixin from .coord_inspections import InspectionsMixin @@ -108,6 +110,8 @@ class TaskMateCoordinator( TagsMixin, TeamworkMixin, WishlistMixin, + FulfilmentMixin, + PersonalBestsMixin, BountiesMixin, AuctionsMixin, RecapsMixin, @@ -507,6 +511,7 @@ async def async_run_minutely_checks(self) -> None: self.async_check_weekly_goals, self.async_draw_due_raffles, self.async_check_pet_growth, + self.async_check_owed_rewards, ): try: await step() diff --git a/custom_components/taskmate/event.py b/custom_components/taskmate/event.py index 8044bbc2..c4121817 100644 --- a/custom_components/taskmate/event.py +++ b/custom_components/taskmate/event.py @@ -62,6 +62,8 @@ "penalty_redeemed": notification_event_name(NOTIF_TYPE_PENALTY_REDEEMED), "raffle_won": "taskmate_raffle_won", "pet_evolved": notification_event_name(NOTIF_TYPE_PET_EVOLVED), + "reward_delivered": "taskmate_reward_delivered", + "personal_best": "taskmate_personal_best", } # Bus events about the whole family. diff --git a/custom_components/taskmate/family_services.py b/custom_components/taskmate/family_services.py index 6f56c355..fad30f0d 100644 --- a/custom_components/taskmate/family_services.py +++ b/custom_components/taskmate/family_services.py @@ -30,8 +30,10 @@ SERVICE_BUY_RAFFLE_TICKETS = "buy_raffle_tickets" SERVICE_DRAW_RAFFLE = "draw_raffle" SERVICE_ADOPT_PET = "adopt_pet" +SERVICE_MARK_REWARD_DELIVERED = "mark_reward_delivered" FAMILY_SERVICES: tuple[str, ...] = ( + SERVICE_MARK_REWARD_DELIVERED, SERVICE_REQUEST_HELP, SERVICE_ANSWER_HELP, SERVICE_LOG_MOOD, @@ -53,6 +55,7 @@ def async_register_family_services( parent_action: Wrapper, get_coordinator: Callable[[], object], require_linked_child: Callable[..., Awaitable[None]], + require_parent: Callable[..., Awaitable[None]] | None = None, ) -> None: """Register the services behind the caller's child and parent gates.""" @@ -94,6 +97,30 @@ async def adopt_pet(coordinator, call): async def draw_raffle(coordinator, call): await coordinator.async_draw_raffle(call.data["raffle_id"]) + # Reward fulfilment (#1098): a parent marks a reward handed over, or the + # child confirms their own with ``child_id`` — so the one service has two + # gates and picks by who is calling. + async def mark_reward_delivered(call: ServiceCall) -> None: + coordinator = get_coordinator() + if not coordinator: + _LOGGER.error("No TaskMate coordinator available") + return + child_id = call.data.get("child_id") + if child_id: + await require_linked_child(hass, call, coordinator, child_id) + await coordinator.async_mark_reward_delivered(call.data["claim_id"], by="child", child_id=child_id) + return + if require_parent is not None: + await require_parent(hass, call) + await coordinator.async_mark_reward_delivered(call.data["claim_id"]) + + hass.services.async_register( + DOMAIN, + SERVICE_MARK_REWARD_DELIVERED, + child_action(mark_reward_delivered), + schema=vol.Schema({vol.Required("claim_id"): cv.string, vol.Optional("child_id"): cv.string}), + ) + registrations = ( ( SERVICE_REQUEST_HELP, diff --git a/custom_components/taskmate/frontend.py b/custom_components/taskmate/frontend.py index 04411fce..d73c1edd 100644 --- a/custom_components/taskmate/frontend.py +++ b/custom_components/taskmate/frontend.py @@ -58,6 +58,7 @@ "taskmate-chore-board-card.js", "taskmate-raffle-card.js", "taskmate-pet-card.js", + "taskmate-personal-bests-card.js", ] # Cards that USED to ship but were removed. Their files no longer exist, so any diff --git a/custom_components/taskmate/models.py b/custom_components/taskmate/models.py index 2d93a3e1..aacfcce3 100644 --- a/custom_components/taskmate/models.py +++ b/custom_components/taskmate/models.py @@ -1308,6 +1308,16 @@ class RewardClaim: # Set when the claim redeems a wishlist wish (#932) rather than a reward. # reward_id then holds the wish id too, and no Reward record exists. wish_id: str = "" + # Reward fulfilment (#1098): True on a claim approved while the tracker + # was on, for a reward somebody has to hand over. It stays "owed" until + # delivered_at is set — by a parent, or by the child saying "got it". + # Claims from before the feature, or approved while it was off, keep + # False and never show up as owed. + needs_delivery: bool = False + delivered_at: datetime | None = None + delivered_by: str = "" # "parent" | "child" | "" + # When the parents were last reminded this is still owed. + owed_reminded_at: datetime | None = None @classmethod def from_dict(cls, data: dict[str, Any]) -> RewardClaim: @@ -1324,6 +1334,10 @@ def from_dict(cls, data: dict[str, Any]) -> RewardClaim: id=data.get("id") or generate_id(), approved_cost=parse_optional_points(data.get("approved_cost")), wish_id=str(data.get("wish_id", "") or ""), + needs_delivery=bool(data.get("needs_delivery", False)), + delivered_at=parse_datetime(data.get("delivered_at")), + delivered_by=str(data.get("delivered_by", "") or ""), + owed_reminded_at=parse_datetime(data.get("owed_reminded_at")), ) def to_dict(self) -> dict[str, Any]: @@ -1340,6 +1354,15 @@ def to_dict(self) -> dict[str, Any]: # Only a wishlist redemption carries it, so ordinary claims keep their shape. if self.wish_id: out["wish_id"] = self.wish_id + # Fulfilment (#1098) fields likewise only appear on tracked claims. + if self.needs_delivery: + out["needs_delivery"] = True + if self.delivered_at is not None: + out["delivered_at"] = format_datetime(self.delivered_at) + if self.delivered_by: + out["delivered_by"] = self.delivered_by + if self.owed_reminded_at is not None: + out["owed_reminded_at"] = format_datetime(self.owed_reminded_at) return out diff --git a/custom_components/taskmate/notify_strings.py b/custom_components/taskmate/notify_strings.py index d70f609f..132d8e60 100644 --- a/custom_components/taskmate/notify_strings.py +++ b/custom_components/taskmate/notify_strings.py @@ -74,6 +74,14 @@ "help_requested": "🙋 {child_name} needs help with '{chore_name}'{message_text}", "mood_low": "{emoji} {child_name} had a tough day today. Maybe check in?", "weekly_goal_reached": "🎯 {child_name} reached their weekly goal of {target} {points_name} — +{bonus} bonus!", + "reward_owed": "🎁 {child_name} is still waiting for '{reward_name}' — redeemed {days} days ago.", + "personal_best": "🏆 New personal best! {child_name}: {record_label} — {value}.", + # Personal best record names, used inside the personal_best message. + "record_longest_streak": "longest streak (days)", + "record_best_day_points": "most {points_name} in a day", + "record_best_day_chores": "most chores in a day", + "record_best_week_points": "most {points_name} in a week", + "record_best_single_award": "biggest single award", "pet_evolved": "{news}", "pet_news_stage": "🐣 {pet_name} just grew up! Have a look at {child_name}'s pet.", "pet_news_legendary": "✨ {pet_name} turned legendary! {child_name}'s pet is shining gold.", @@ -115,6 +123,7 @@ "celebrate_weekly_target": "{child_name} reached the weekly target for '{chore_name}' — +{bonus}!", "celebrate_quest_completed": "{child_name} completed the quest '{quest_name}'!", "celebrate_streak_milestone": "{child_name} hit a {days}-day streak!", + "celebrate_personal_best": "{child_name} set a new personal best: {record_label} — {value}!", # Recap announcements: the single-recap child push is recap_ready above. "recap_ready_many": "✨ {count} new recaps are ready, {child_name}! Tap to watch them.", "recap_parent_one": "✨ New {period} recaps are ready for {names}. Tap to see them.", diff --git a/custom_components/taskmate/sensor.py b/custom_components/taskmate/sensor.py index 26769599..1c1aad22 100644 --- a/custom_components/taskmate/sensor.py +++ b/custom_components/taskmate/sensor.py @@ -261,6 +261,18 @@ def _build_children_summary(coordinator: TaskMateCoordinator, common: dict) -> l **({"pet": pet} if (pet := coordinator.pet_state(c)) is not None else {}), # Personal weekly goal, while the feature is on. **({"weekly_goal": goal} if (goal := coordinator.weekly_goal_state(c.id)) is not None else {}), + # Reward fulfilment (#1098): what this child is still owed. + **( + { + "owed_rewards": [ + {k: o[k] for k in ("claim_id", "reward_name", "reward_icon", "days_owed")} for o in owed + ] + } + if (owed := coordinator.owed_rewards(c.id)) + else {} + ), + # Personal bests (#1099): the records board, once there is one. + **({"personal_bests": bests} if (bests := coordinator.personal_bests_state(c)) else {}), # Make it right: open offers to earn a penalty back. **( { @@ -1960,7 +1972,7 @@ def extra_state_attributes(self) -> dict: class PendingApprovalsSensor(TaskMateBaseSensor): """Sensor for pending approvals.""" - _unrecorded_attributes = frozenset({"chore_completions", "reward_claims", "mandatory_misses"}) + _unrecorded_attributes = frozenset({"chore_completions", "reward_claims", "mandatory_misses", "owed_rewards"}) def __init__( self, @@ -2084,6 +2096,10 @@ def extra_state_attributes(self) -> dict: "reward_claims": reward_details, "mandatory_misses": mandatory_misses, } + # Reward fulfilment (#1098): redeemed rewards still to hand over, only + # while the tracker is on and something is owed. + if owed := self.coordinator.owed_rewards(): + attrs["owed_rewards"] = owed # Quality rating (#927): tells approval cards to draw the star picker, # and what each star pays. Absent entirely while the feature is off. if self.coordinator.quality_rating_enabled(): diff --git a/custom_components/taskmate/services.yaml b/custom_components/taskmate/services.yaml index b4278091..80c7407b 100644 --- a/custom_components/taskmate/services.yaml +++ b/custom_components/taskmate/services.yaml @@ -1856,6 +1856,23 @@ set_weekly_goal: max: 100000 mode: box +mark_reward_delivered: + name: Mark reward delivered + description: A redeemed reward has been handed over, so it leaves the owed list. Give child_id when the child is confirming their own. + fields: + claim_id: + name: Claim ID + description: The approved reward claim + required: true + selector: + text: + child_id: + name: Child ID + description: The child confirming they received it (leave empty when a parent marks it) + required: false + selector: + text: + buy_raffle_tickets: name: Buy raffle tickets description: A child spends points on tickets for a raffle. Each ticket is one chance in the draw. diff --git a/custom_components/taskmate/strings.json b/custom_components/taskmate/strings.json index 1b22315f..d515d579 100644 --- a/custom_components/taskmate/strings.json +++ b/custom_components/taskmate/strings.json @@ -1448,6 +1448,20 @@ "description": "Hatch the egg the grown-up pet laid into a new pet. The old pet joins the pet family." } } + }, + "mark_reward_delivered": { + "name": "Mark reward delivered", + "description": "A redeemed reward has been handed over, so it leaves the owed list. Give child_id when the child is confirming their own.", + "fields": { + "claim_id": { + "name": "Claim ID", + "description": "The approved reward claim" + }, + "child_id": { + "name": "Child ID", + "description": "The child confirming they received it (leave empty when a parent marks it)" + } + } } }, "entity": { @@ -1516,7 +1530,9 @@ "chore_auto_approved": "Chore auto-approved", "penalty_redeemed": "Penalty earned back", "raffle_won": "Raffle won", - "pet_evolved": "Pet grew up" + "pet_evolved": "Pet grew up", + "reward_delivered": "Reward handed over", + "personal_best": "Personal best" } } } diff --git a/custom_components/taskmate/translations/da.json b/custom_components/taskmate/translations/da.json index 0a53e22b..452e2ca7 100644 --- a/custom_components/taskmate/translations/da.json +++ b/custom_components/taskmate/translations/da.json @@ -1448,6 +1448,20 @@ "description": "Udklæk ægget, som det voksne kæledyr lagde, til et nyt kæledyr. Det gamle kæledyr kommer med i kæledyrsfamilien." } } + }, + "mark_reward_delivered": { + "name": "Markér belønning som givet", + "description": "En indløst belønning er givet og forlader listen over udeståender. Angiv child_id, når barnet selv bekræfter.", + "fields": { + "claim_id": { + "name": "Krav-ID", + "description": "Det godkendte belønningskrav" + }, + "child_id": { + "name": "Barn-ID", + "description": "Barnet, der bekræfter at have fået den (lad stå tomt, når en forælder markerer)" + } + } } }, "entity": { @@ -1516,7 +1530,9 @@ "chore_auto_approved": "Opgave godkendt automatisk", "penalty_redeemed": "Straf tjent tilbage", "raffle_won": "Lodtrækning vundet", - "pet_evolved": "Kæledyr voksede" + "pet_evolved": "Kæledyr voksede", + "reward_delivered": "Belønning givet", + "personal_best": "Personlig rekord" } } } diff --git a/custom_components/taskmate/translations/de.json b/custom_components/taskmate/translations/de.json index 7da2f9af..3d2d5cf5 100644 --- a/custom_components/taskmate/translations/de.json +++ b/custom_components/taskmate/translations/de.json @@ -1448,6 +1448,20 @@ "description": "Das Ei des ausgewachsenen Haustiers zu einem neuen Haustier ausbrüten. Das alte Haustier kommt in die Haustierfamilie." } } + }, + "mark_reward_delivered": { + "name": "Belohnung als übergeben markieren", + "description": "Eine eingelöste Belohnung wurde übergeben und verlässt die Liste der ausstehenden. child_id angeben, wenn das Kind selbst bestätigt.", + "fields": { + "claim_id": { + "name": "Anspruchs-ID", + "description": "Der genehmigte Belohnungsanspruch" + }, + "child_id": { + "name": "Kind-ID", + "description": "Das Kind, das den Erhalt bestätigt (leer lassen, wenn ein Elternteil markiert)" + } + } } }, "entity": { @@ -1516,7 +1530,9 @@ "chore_auto_approved": "Aufgabe automatisch genehmigt", "penalty_redeemed": "Strafe wieder verdient", "raffle_won": "Verlosung gewonnen", - "pet_evolved": "Haustier gewachsen" + "pet_evolved": "Haustier gewachsen", + "reward_delivered": "Belohnung übergeben", + "personal_best": "Persönliche Bestleistung" } } } diff --git a/custom_components/taskmate/translations/en-GB.json b/custom_components/taskmate/translations/en-GB.json index 1b22315f..d515d579 100644 --- a/custom_components/taskmate/translations/en-GB.json +++ b/custom_components/taskmate/translations/en-GB.json @@ -1448,6 +1448,20 @@ "description": "Hatch the egg the grown-up pet laid into a new pet. The old pet joins the pet family." } } + }, + "mark_reward_delivered": { + "name": "Mark reward delivered", + "description": "A redeemed reward has been handed over, so it leaves the owed list. Give child_id when the child is confirming their own.", + "fields": { + "claim_id": { + "name": "Claim ID", + "description": "The approved reward claim" + }, + "child_id": { + "name": "Child ID", + "description": "The child confirming they received it (leave empty when a parent marks it)" + } + } } }, "entity": { @@ -1516,7 +1530,9 @@ "chore_auto_approved": "Chore auto-approved", "penalty_redeemed": "Penalty earned back", "raffle_won": "Raffle won", - "pet_evolved": "Pet grew up" + "pet_evolved": "Pet grew up", + "reward_delivered": "Reward handed over", + "personal_best": "Personal best" } } } diff --git a/custom_components/taskmate/translations/en.json b/custom_components/taskmate/translations/en.json index 1b22315f..d515d579 100644 --- a/custom_components/taskmate/translations/en.json +++ b/custom_components/taskmate/translations/en.json @@ -1448,6 +1448,20 @@ "description": "Hatch the egg the grown-up pet laid into a new pet. The old pet joins the pet family." } } + }, + "mark_reward_delivered": { + "name": "Mark reward delivered", + "description": "A redeemed reward has been handed over, so it leaves the owed list. Give child_id when the child is confirming their own.", + "fields": { + "claim_id": { + "name": "Claim ID", + "description": "The approved reward claim" + }, + "child_id": { + "name": "Child ID", + "description": "The child confirming they received it (leave empty when a parent marks it)" + } + } } }, "entity": { @@ -1516,7 +1530,9 @@ "chore_auto_approved": "Chore auto-approved", "penalty_redeemed": "Penalty earned back", "raffle_won": "Raffle won", - "pet_evolved": "Pet grew up" + "pet_evolved": "Pet grew up", + "reward_delivered": "Reward handed over", + "personal_best": "Personal best" } } } diff --git a/custom_components/taskmate/translations/fr.json b/custom_components/taskmate/translations/fr.json index e3e59ee5..9487970e 100644 --- a/custom_components/taskmate/translations/fr.json +++ b/custom_components/taskmate/translations/fr.json @@ -1448,6 +1448,20 @@ "description": "Faire éclore l’œuf pondu par l’animal adulte pour obtenir un nouvel animal. L’ancien rejoint la famille des animaux." } } + }, + "mark_reward_delivered": { + "name": "Marquer la récompense comme remise", + "description": "Une récompense échangée a été remise : elle quitte la liste des récompenses dues. Indiquez child_id quand c’est l’enfant qui confirme.", + "fields": { + "claim_id": { + "name": "ID de la demande", + "description": "La demande de récompense approuvée" + }, + "child_id": { + "name": "ID de l’enfant", + "description": "L’enfant qui confirme l’avoir reçue (laisser vide quand un parent la marque)" + } + } } }, "entity": { @@ -1516,7 +1530,9 @@ "chore_auto_approved": "Tâche approuvée automatiquement", "penalty_redeemed": "Pénalité regagnée", "raffle_won": "Tombola gagnée", - "pet_evolved": "L’animal a grandi" + "pet_evolved": "L’animal a grandi", + "reward_delivered": "Récompense remise", + "personal_best": "Record personnel" } } } diff --git a/custom_components/taskmate/translations/nb.json b/custom_components/taskmate/translations/nb.json index 6a9b398d..d5f76fd4 100644 --- a/custom_components/taskmate/translations/nb.json +++ b/custom_components/taskmate/translations/nb.json @@ -1448,6 +1448,20 @@ "description": "Klekk egget det voksne kjæledyret la, til et nytt kjæledyr. Det gamle kjæledyret blir med i kjæledyrfamilien." } } + }, + "mark_reward_delivered": { + "name": "Merk belønning som gitt", + "description": "En innløst belønning er gitt og forlater listen over utestående. Oppgi child_id når barnet selv bekrefter.", + "fields": { + "claim_id": { + "name": "Krav-ID", + "description": "Det godkjente belønningskravet" + }, + "child_id": { + "name": "Barn-ID", + "description": "Barnet som bekrefter å ha fått den (la stå tom når en forelder merker)" + } + } } }, "entity": { @@ -1516,7 +1530,9 @@ "chore_auto_approved": "Oppgave godkjent automatisk", "penalty_redeemed": "Straff tjent inn igjen", "raffle_won": "Loddtrekning vunnet", - "pet_evolved": "Kjæledyret vokste" + "pet_evolved": "Kjæledyret vokste", + "reward_delivered": "Belønning gitt", + "personal_best": "Personlig rekord" } } } diff --git a/custom_components/taskmate/translations/nl.json b/custom_components/taskmate/translations/nl.json index e5bf1141..4adf6bed 100644 --- a/custom_components/taskmate/translations/nl.json +++ b/custom_components/taskmate/translations/nl.json @@ -1448,6 +1448,20 @@ "description": "Broed het ei dat het volgroeide huisdier legde uit tot een nieuw huisdier. Het oude huisdier komt bij de huisdierenfamilie." } } + }, + "mark_reward_delivered": { + "name": "Beloning als gegeven markeren", + "description": "Een ingewisselde beloning is overhandigd en verdwijnt van de lijst met nog te geven beloningen. Geef child_id op als het kind het zelf bevestigt.", + "fields": { + "claim_id": { + "name": "Aanvraag-ID", + "description": "De goedgekeurde beloningsaanvraag" + }, + "child_id": { + "name": "Kind-ID", + "description": "Het kind dat bevestigt dat het de beloning heeft gekregen (leeg laten als een ouder het markeert)" + } + } } }, "entity": { @@ -1516,7 +1530,9 @@ "chore_auto_approved": "Taak automatisch goedgekeurd", "penalty_redeemed": "Puntenaftrek goedgemaakt", "raffle_won": "Loterij gewonnen", - "pet_evolved": "Huisdier gegroeid" + "pet_evolved": "Huisdier gegroeid", + "reward_delivered": "Beloning overhandigd", + "personal_best": "Persoonlijk record" } } } diff --git a/custom_components/taskmate/translations/nn.json b/custom_components/taskmate/translations/nn.json index e43e7ca9..5b0b2be7 100644 --- a/custom_components/taskmate/translations/nn.json +++ b/custom_components/taskmate/translations/nn.json @@ -1448,6 +1448,20 @@ "description": "Klekk egget det vaksne kjæledyret la, til eit nytt kjæledyr. Det gamle kjæledyret blir med i kjæledyrfamilien." } } + }, + "mark_reward_delivered": { + "name": "Merk løning som gjeven", + "description": "Ei innløyst løning er gjeven og forlèt lista over uteståande. Oppgje child_id når barnet sjølv stadfestar.", + "fields": { + "claim_id": { + "name": "Krav-ID", + "description": "Det godkjende løningskravet" + }, + "child_id": { + "name": "Barn-ID", + "description": "Barnet som stadfestar å ha fått henne (la stå tom når ein forelder merkjer)" + } + } } }, "entity": { @@ -1516,7 +1530,9 @@ "chore_auto_approved": "Oppgåve godkjend automatisk", "penalty_redeemed": "Straff tent inn att", "raffle_won": "Loddtrekning vunnen", - "pet_evolved": "Kjæledyret voks" + "pet_evolved": "Kjæledyret voks", + "reward_delivered": "Løning gjeven", + "personal_best": "Personleg rekord" } } } diff --git a/custom_components/taskmate/translations/pl.json b/custom_components/taskmate/translations/pl.json index 677a21c4..cb911a4c 100644 --- a/custom_components/taskmate/translations/pl.json +++ b/custom_components/taskmate/translations/pl.json @@ -1448,6 +1448,20 @@ "description": "Wykluj z jajka dorosłego zwierzaka nowego zwierzaka. Stary dołącza do rodziny zwierzaków." } } + }, + "mark_reward_delivered": { + "name": "Oznacz nagrodę jako wręczoną", + "description": "Odebrana nagroda została wręczona i znika z listy należnych. Podaj child_id, gdy potwierdza samo dziecko.", + "fields": { + "claim_id": { + "name": "ID zgłoszenia", + "description": "Zatwierdzone zgłoszenie nagrody" + }, + "child_id": { + "name": "ID dziecka", + "description": "Dziecko potwierdzające odbiór (zostaw puste, gdy oznacza rodzic)" + } + } } }, "entity": { @@ -1516,7 +1530,9 @@ "chore_auto_approved": "Zadanie zatwierdzone automatycznie", "penalty_redeemed": "Odrobiono karę", "raffle_won": "Wygrano losowanie", - "pet_evolved": "Zwierzak urósł" + "pet_evolved": "Zwierzak urósł", + "reward_delivered": "Nagroda wręczona", + "personal_best": "Rekord życiowy" } } } diff --git a/custom_components/taskmate/translations/pt-BR.json b/custom_components/taskmate/translations/pt-BR.json index 90baefa2..d8e317f2 100644 --- a/custom_components/taskmate/translations/pt-BR.json +++ b/custom_components/taskmate/translations/pt-BR.json @@ -1448,6 +1448,20 @@ "description": "Chocar o ovo que o bichinho adulto botou para ter um bichinho novo. O antigo entra para a família de bichinhos." } } + }, + "mark_reward_delivered": { + "name": "Marcar recompensa como entregue", + "description": "Uma recompensa resgatada foi entregue e sai da lista de pendentes. Informe child_id quando for a criança confirmando.", + "fields": { + "claim_id": { + "name": "ID do pedido", + "description": "O pedido de recompensa aprovado" + }, + "child_id": { + "name": "ID da criança", + "description": "A criança que confirma ter recebido (deixe vazio quando um dos pais marca)" + } + } } }, "entity": { @@ -1516,7 +1530,9 @@ "chore_auto_approved": "Tarefa aprovada automaticamente", "penalty_redeemed": "Penalidade recuperada", "raffle_won": "Sorteio ganho", - "pet_evolved": "O bichinho cresceu" + "pet_evolved": "O bichinho cresceu", + "reward_delivered": "Recompensa entregue", + "personal_best": "Recorde pessoal" } } } diff --git a/custom_components/taskmate/translations/pt.json b/custom_components/taskmate/translations/pt.json index ac5072d8..33e70701 100644 --- a/custom_components/taskmate/translations/pt.json +++ b/custom_components/taskmate/translations/pt.json @@ -1448,6 +1448,20 @@ "description": "Chocar o ovo posto pelo animal adulto para ter um animal novo. O antigo junta-se à família de animais." } } + }, + "mark_reward_delivered": { + "name": "Marcar recompensa como entregue", + "description": "Uma recompensa resgatada foi entregue e sai da lista de pendentes. Indica child_id quando é a criança a confirmar.", + "fields": { + "claim_id": { + "name": "ID do pedido", + "description": "O pedido de recompensa aprovado" + }, + "child_id": { + "name": "ID da criança", + "description": "A criança que confirma ter recebido (deixar vazio quando é um dos pais a marcar)" + } + } } }, "entity": { @@ -1516,7 +1530,9 @@ "chore_auto_approved": "Tarefa aprovada automaticamente", "penalty_redeemed": "Penalização recuperada", "raffle_won": "Sorteio ganho", - "pet_evolved": "O animal cresceu" + "pet_evolved": "O animal cresceu", + "reward_delivered": "Recompensa entregue", + "personal_best": "Recorde pessoal" } } } diff --git a/custom_components/taskmate/websocket.py b/custom_components/taskmate/websocket.py index 770a53df..19b89425 100644 --- a/custom_components/taskmate/websocket.py +++ b/custom_components/taskmate/websocket.py @@ -78,6 +78,7 @@ MAX_RECURRENCE_INTERVAL_DAYS, MAX_TIME_PERIODS, MIN_RECURRENCE_INTERVAL_DAYS, + REWARD_FULFILMENT_REMINDER_MAX_DAYS, SCHEDULE_MODES, TAG_ID_MAX_LENGTH, TEAM_POINTS_MODES, @@ -494,6 +495,8 @@ def _build_state_snapshot(coordinator: TaskMateCoordinator) -> dict[str, Any]: "inspection_bed_rule": coordinator.inspection_bed_rule(), # Today page: open help requests, who is off sick and today's moods. "help_requests": coordinator.open_help_requests(), + # Reward fulfilment (#1098): redeemed rewards still to be handed over. + "owed_rewards": coordinator.owed_rewards(), "sick_today": [c.id for c in coordinator.storage.get_children() if coordinator.is_sick_day(c.id)], "moods_today": {c.id: m for c in coordinator.storage.get_children() if (m := coordinator.mood_on(c.id))}, "raffles": coordinator.all_raffles(), @@ -1985,6 +1988,8 @@ async def _ws_remove_custom_sound(hass, connection, msg, coordinator): "auto_approve_hours", "weekly_goal_enabled", "weekly_goal_bonus", + "reward_fulfilment_enabled", + "reward_fulfilment_reminder_days", "pets_enabled", *PET_INT_SETTINGS, *PET_BOOL_SETTINGS, @@ -2277,6 +2282,10 @@ def _validate_chore_undo_seconds(value: Any) -> int: }, **{vol.Optional(key): bool for key in PET_BOOL_SETTINGS}, vol.Optional("weekly_goal_bonus"): vol.All(vol.Coerce(int), vol.Range(min=0, max=100000)), + vol.Optional("reward_fulfilment_enabled"): bool, + vol.Optional("reward_fulfilment_reminder_days"): vol.All( + vol.Coerce(int), vol.Range(min=0, max=REWARD_FULFILMENT_REMINDER_MAX_DAYS) + ), vol.Optional("recap_frequencies"): _validate_recap_frequencies, vol.Optional("recap_child_frequencies"): _validate_recap_child_frequencies, vol.Optional("recap_notify_children"): bool, diff --git a/custom_components/taskmate/www/locales/da.json b/custom_components/taskmate/www/locales/da.json index 6f5bdae9..1beedc15 100644 --- a/custom_components/taskmate/www/locales/da.json +++ b/custom_components/taskmate/www/locales/da.json @@ -3425,5 +3425,60 @@ "panel.pet_level_every": "Nyt niveau hver", "panel.pet_reset_defaults": "Nulstil tal til standard", "panel.pet_settings_invalid": "Kæledyrstrinene skal stige i rækkefølge, og legendarisk og ægget skal komme efter fuldvoksen.", - "panel.pet_about_one_chore": "Cirka 1 opgave (opgaver til 5 point)" + "panel.pet_about_one_chore": "Cirka 1 opgave (opgaver til 5 point)", + "notify.reward_owed": "🎁 {child_name} venter stadig på '{reward_name}' — indløst for {days} dage siden.", + "notify.personal_best": "🏆 Ny personlig rekord! {child_name}: {record_label} — {value}.", + "notify.record_longest_streak": "længste stime (dage)", + "notify.record_best_day_points": "flest {points_name} på en dag", + "notify.record_best_day_chores": "flest opgaver på en dag", + "notify.record_best_week_points": "flest {points_name} på en uge", + "notify.record_best_single_award": "største enkelte belønning", + "notify.celebrate_personal_best": "{child_name} satte ny personlig rekord: {record_label} — {value}!", + "notification.reward_owed.name": "Belønning mangler stadig", + "notification.reward_owed.description": "Minder forældre om, at en indløst belønning endnu ikke er givet. Sendes kun, mens belønningsopfølgning er slået til.", + "notification.personal_best.name": "Personlig rekord", + "notification.personal_best.description": "Et barn slog en af sine egne rekorder: længste stime, bedste dag, bedste uge eller største belønning.", + "approvals.owed_section": "Mangler at blive givet", + "approvals.owed_delivered": "Givet", + "approvals.owed_today": "indløst i dag", + "approvals.owed_days_one": "udestået i 1 dag", + "approvals.owed_days": "udestået i {count} dage", + "panel.today_owed_title": "Belønninger der skal gives", + "panel.today_owed_delivered": "Givet", + "panel.today_owed_marked": "Markeret som givet", + "panel.today_owed_today": "indløst i dag", + "panel.today_owed_days_one": "udestået i 1 dag", + "panel.today_owed_days": "udestået i {count} dage", + "panel.fulfilment_title": "Belønningsopfølgning", + "panel.fulfilment_hint": "Når en belønning godkendes, bruges pointene; her bliver den stående på en liste over udeståender, indtil nogen giver den. Barnet kan trykke „Fået!“ på sit kort.", + "panel.fulfilment_enabled": "Følg belønninger, indtil de er givet", + "panel.fulfilment_reminder_days": "Mind mig om det efter", + "panel.fulfilment_reminder_days_hint": "Dage før en besked siger, at en belønning stadig mangler, gentaget hver så mange dage. 0 = aldrig.", + "child.owed_title": "Du har en belønning til gode", + "child.owed_got_it": "Fået!", + "child.owed_today": "indløst i dag", + "child.owed_days_one": "for 1 dag siden", + "child.owed_days": "for {count} dage siden", + "child.record_title": "Ny personlig rekord!", + "child.record_longest_streak": "Længste stime", + "child.record_best_day_points": "Flest {points_name} på en dag", + "child.record_best_day_chores": "Flest opgaver på en dag", + "child.record_best_week_points": "Flest {points_name} på en uge", + "child.record_best_single_award": "Største enkelte belønning", + "child.editor.show_personal_bests": "Vis nye personlige rekorder", + "bests.default_title": "Personlige rekorder", + "bests.error.entity_required": "Angiv en enhed", + "bests.subtitle": "Dine egne rekorder at slå", + "bests.none_yet": "Ingen rekorder endnu — de første opgaver sætter niveauet", + "bests.not_yet": "ikke endnu", + "bests.new_tag": "Ny", + "bests.new_count": "{count} nye i dag", + "bests.beat_previous": "slog {previous}", + "bests.editor.title": "Titel", + "bests.editor.child_helper": "Vis kun ét barns rekorder", + "bests.record_longest_streak": "Længste stime", + "bests.record_best_day_points": "Bedste dag ({points_name})", + "bests.record_best_day_chores": "Flest opgaver på en dag", + "bests.record_best_week_points": "Bedste uge ({points_name})", + "bests.record_best_single_award": "Største belønning" } diff --git a/custom_components/taskmate/www/locales/de.json b/custom_components/taskmate/www/locales/de.json index 6679a767..e697ec13 100644 --- a/custom_components/taskmate/www/locales/de.json +++ b/custom_components/taskmate/www/locales/de.json @@ -3425,5 +3425,60 @@ "panel.pet_level_every": "Neues Level alle", "panel.pet_reset_defaults": "Zahlen auf Standard zurücksetzen", "panel.pet_settings_invalid": "Die Stufen müssen der Reihe nach steigen, und legendär und das Ei müssen nach »ausgewachsen« kommen.", - "panel.pet_about_one_chore": "Etwa 1 Aufgabe (Aufgaben mit 5 Punkten)" + "panel.pet_about_one_chore": "Etwa 1 Aufgabe (Aufgaben mit 5 Punkten)", + "notify.reward_owed": "🎁 {child_name} wartet noch auf „{reward_name}“ – vor {days} Tagen eingelöst.", + "notify.personal_best": "🏆 Neue persönliche Bestleistung! {child_name}: {record_label} – {value}.", + "notify.record_longest_streak": "längste Serie (Tage)", + "notify.record_best_day_points": "meiste {points_name} an einem Tag", + "notify.record_best_day_chores": "meiste Aufgaben an einem Tag", + "notify.record_best_week_points": "meiste {points_name} in einer Woche", + "notify.record_best_single_award": "größte Einzelbelohnung", + "notify.celebrate_personal_best": "{child_name} hat eine neue Bestleistung: {record_label} – {value}!", + "notification.reward_owed.name": "Belohnung noch ausstehend", + "notification.reward_owed.description": "Erinnert Eltern daran, dass eine eingelöste Belohnung noch nicht übergeben wurde. Nur aktiv, wenn die Belohnungsübergabe eingeschaltet ist.", + "notification.personal_best.name": "Persönliche Bestleistung", + "notification.personal_best.description": "Ein Kind hat einen eigenen Rekord gebrochen: längste Serie, bester Tag, beste Woche oder größte Belohnung.", + "approvals.owed_section": "Noch zu übergeben", + "approvals.owed_delivered": "Übergeben", + "approvals.owed_today": "heute eingelöst", + "approvals.owed_days_one": "seit 1 Tag ausstehend", + "approvals.owed_days": "seit {count} Tagen ausstehend", + "panel.today_owed_title": "Belohnungen zum Übergeben", + "panel.today_owed_delivered": "Übergeben", + "panel.today_owed_marked": "Als übergeben markiert", + "panel.today_owed_today": "heute eingelöst", + "panel.today_owed_days_one": "seit 1 Tag ausstehend", + "panel.today_owed_days": "seit {count} Tagen ausstehend", + "panel.fulfilment_title": "Belohnungsübergabe", + "panel.fulfilment_hint": "Das Genehmigen einer Belohnung zieht die Punkte ab; hier bleibt sie auf einer „Ausstehend“-Liste, bis jemand sie übergibt. Das Kind kann auf seiner Karte „Hab ich!“ antippen.", + "panel.fulfilment_enabled": "Belohnungen bis zur Übergabe verfolgen", + "panel.fulfilment_reminder_days": "Erinnern nach", + "panel.fulfilment_reminder_days_hint": "Tage, bis eine Push-Nachricht an die ausstehende Belohnung erinnert, danach alle so viele Tage wieder. 0 = nie.", + "child.owed_title": "Dir steht eine Belohnung zu", + "child.owed_got_it": "Hab ich!", + "child.owed_today": "heute eingelöst", + "child.owed_days_one": "vor 1 Tag", + "child.owed_days": "vor {count} Tagen", + "child.record_title": "Neue persönliche Bestleistung!", + "child.record_longest_streak": "Längste Serie", + "child.record_best_day_points": "Meiste {points_name} an einem Tag", + "child.record_best_day_chores": "Meiste Aufgaben an einem Tag", + "child.record_best_week_points": "Meiste {points_name} in einer Woche", + "child.record_best_single_award": "Größte Einzelbelohnung", + "child.editor.show_personal_bests": "Neue Bestleistungen anzeigen", + "bests.default_title": "Bestleistungen", + "bests.error.entity_required": "Bitte eine Entität angeben", + "bests.subtitle": "Deine eigenen Rekorde zum Knacken", + "bests.none_yet": "Noch keine Rekorde – die ersten Aufgaben setzen die Messlatte", + "bests.not_yet": "noch nicht", + "bests.new_tag": "Neu", + "bests.new_count": "{count} neu heute", + "bests.beat_previous": "vorher {previous}", + "bests.editor.title": "Titel", + "bests.editor.child_helper": "Nur die Rekorde eines Kindes anzeigen", + "bests.record_longest_streak": "Längste Serie", + "bests.record_best_day_points": "Bester Tag ({points_name})", + "bests.record_best_day_chores": "Meiste Aufgaben an einem Tag", + "bests.record_best_week_points": "Beste Woche ({points_name})", + "bests.record_best_single_award": "Größte Belohnung" } diff --git a/custom_components/taskmate/www/locales/en-GB.json b/custom_components/taskmate/www/locales/en-GB.json index ba20f279..b8475062 100644 --- a/custom_components/taskmate/www/locales/en-GB.json +++ b/custom_components/taskmate/www/locales/en-GB.json @@ -3425,5 +3425,60 @@ "panel.pet_level_every": "New level every", "panel.pet_reset_defaults": "Reset numbers to defaults", "panel.pet_settings_invalid": "Pet stages must go up in order, and legendary and the egg must come after fully grown.", - "panel.pet_about_one_chore": "About 1 chore (5-point chores)" + "panel.pet_about_one_chore": "About 1 chore (5-point chores)", + "notify.reward_owed": "🎁 {child_name} is still waiting for '{reward_name}' — redeemed {days} days ago.", + "notify.personal_best": "🏆 New personal best! {child_name}: {record_label} — {value}.", + "notify.record_longest_streak": "longest streak (days)", + "notify.record_best_day_points": "most {points_name} in a day", + "notify.record_best_day_chores": "most chores in a day", + "notify.record_best_week_points": "most {points_name} in a week", + "notify.record_best_single_award": "biggest single award", + "notify.celebrate_personal_best": "{child_name} set a new personal best: {record_label} — {value}!", + "notification.reward_owed.name": "Reward still owed", + "notification.reward_owed.description": "Reminds parents that a redeemed reward has not been handed over yet. Only fires while reward fulfilment is on.", + "notification.personal_best.name": "Personal best", + "notification.personal_best.description": "A child beat one of their own records: longest streak, best day, best week or biggest award.", + "approvals.owed_section": "Still to hand over", + "approvals.owed_delivered": "Handed over", + "approvals.owed_today": "redeemed today", + "approvals.owed_days_one": "owed for 1 day", + "approvals.owed_days": "owed for {count} days", + "panel.today_owed_title": "Rewards to hand over", + "panel.today_owed_delivered": "Handed over", + "panel.today_owed_marked": "Marked as handed over", + "panel.today_owed_today": "redeemed today", + "panel.today_owed_days_one": "owed for 1 day", + "panel.today_owed_days": "owed for {count} days", + "panel.fulfilment_title": "Reward fulfilment", + "panel.fulfilment_hint": "Approving a reward spends the points; this keeps it on an \"owed\" list until somebody hands it over. The child can tap \"Got it!\" on their card.", + "panel.fulfilment_enabled": "Track rewards until they are handed over", + "panel.fulfilment_reminder_days": "Remind me after", + "panel.fulfilment_reminder_days_hint": "Days before a push says a reward is still owed, repeated every that many days. 0 = never.", + "child.owed_title": "You're owed a reward", + "child.owed_got_it": "Got it!", + "child.owed_today": "redeemed today", + "child.owed_days_one": "1 day ago", + "child.owed_days": "{count} days ago", + "child.record_title": "New personal best!", + "child.record_longest_streak": "Longest streak", + "child.record_best_day_points": "Most {points_name} in a day", + "child.record_best_day_chores": "Most chores in a day", + "child.record_best_week_points": "Most {points_name} in a week", + "child.record_best_single_award": "Biggest single award", + "child.editor.show_personal_bests": "Show new personal bests", + "bests.default_title": "Personal Bests", + "bests.error.entity_required": "Please define an entity", + "bests.subtitle": "Your own records to beat", + "bests.none_yet": "No records yet — the first chores set the bar", + "bests.not_yet": "not yet", + "bests.new_tag": "New", + "bests.new_count": "{count} new today", + "bests.beat_previous": "beat {previous}", + "bests.editor.title": "Title", + "bests.editor.child_helper": "Show one child's records only", + "bests.record_longest_streak": "Longest streak", + "bests.record_best_day_points": "Best day ({points_name})", + "bests.record_best_day_chores": "Most chores in a day", + "bests.record_best_week_points": "Best week ({points_name})", + "bests.record_best_single_award": "Biggest award" } diff --git a/custom_components/taskmate/www/locales/en.json b/custom_components/taskmate/www/locales/en.json index ba20f279..b8475062 100644 --- a/custom_components/taskmate/www/locales/en.json +++ b/custom_components/taskmate/www/locales/en.json @@ -3425,5 +3425,60 @@ "panel.pet_level_every": "New level every", "panel.pet_reset_defaults": "Reset numbers to defaults", "panel.pet_settings_invalid": "Pet stages must go up in order, and legendary and the egg must come after fully grown.", - "panel.pet_about_one_chore": "About 1 chore (5-point chores)" + "panel.pet_about_one_chore": "About 1 chore (5-point chores)", + "notify.reward_owed": "🎁 {child_name} is still waiting for '{reward_name}' — redeemed {days} days ago.", + "notify.personal_best": "🏆 New personal best! {child_name}: {record_label} — {value}.", + "notify.record_longest_streak": "longest streak (days)", + "notify.record_best_day_points": "most {points_name} in a day", + "notify.record_best_day_chores": "most chores in a day", + "notify.record_best_week_points": "most {points_name} in a week", + "notify.record_best_single_award": "biggest single award", + "notify.celebrate_personal_best": "{child_name} set a new personal best: {record_label} — {value}!", + "notification.reward_owed.name": "Reward still owed", + "notification.reward_owed.description": "Reminds parents that a redeemed reward has not been handed over yet. Only fires while reward fulfilment is on.", + "notification.personal_best.name": "Personal best", + "notification.personal_best.description": "A child beat one of their own records: longest streak, best day, best week or biggest award.", + "approvals.owed_section": "Still to hand over", + "approvals.owed_delivered": "Handed over", + "approvals.owed_today": "redeemed today", + "approvals.owed_days_one": "owed for 1 day", + "approvals.owed_days": "owed for {count} days", + "panel.today_owed_title": "Rewards to hand over", + "panel.today_owed_delivered": "Handed over", + "panel.today_owed_marked": "Marked as handed over", + "panel.today_owed_today": "redeemed today", + "panel.today_owed_days_one": "owed for 1 day", + "panel.today_owed_days": "owed for {count} days", + "panel.fulfilment_title": "Reward fulfilment", + "panel.fulfilment_hint": "Approving a reward spends the points; this keeps it on an \"owed\" list until somebody hands it over. The child can tap \"Got it!\" on their card.", + "panel.fulfilment_enabled": "Track rewards until they are handed over", + "panel.fulfilment_reminder_days": "Remind me after", + "panel.fulfilment_reminder_days_hint": "Days before a push says a reward is still owed, repeated every that many days. 0 = never.", + "child.owed_title": "You're owed a reward", + "child.owed_got_it": "Got it!", + "child.owed_today": "redeemed today", + "child.owed_days_one": "1 day ago", + "child.owed_days": "{count} days ago", + "child.record_title": "New personal best!", + "child.record_longest_streak": "Longest streak", + "child.record_best_day_points": "Most {points_name} in a day", + "child.record_best_day_chores": "Most chores in a day", + "child.record_best_week_points": "Most {points_name} in a week", + "child.record_best_single_award": "Biggest single award", + "child.editor.show_personal_bests": "Show new personal bests", + "bests.default_title": "Personal Bests", + "bests.error.entity_required": "Please define an entity", + "bests.subtitle": "Your own records to beat", + "bests.none_yet": "No records yet — the first chores set the bar", + "bests.not_yet": "not yet", + "bests.new_tag": "New", + "bests.new_count": "{count} new today", + "bests.beat_previous": "beat {previous}", + "bests.editor.title": "Title", + "bests.editor.child_helper": "Show one child's records only", + "bests.record_longest_streak": "Longest streak", + "bests.record_best_day_points": "Best day ({points_name})", + "bests.record_best_day_chores": "Most chores in a day", + "bests.record_best_week_points": "Best week ({points_name})", + "bests.record_best_single_award": "Biggest award" } diff --git a/custom_components/taskmate/www/locales/fr.json b/custom_components/taskmate/www/locales/fr.json index 8b11847a..c9a112ed 100644 --- a/custom_components/taskmate/www/locales/fr.json +++ b/custom_components/taskmate/www/locales/fr.json @@ -3425,5 +3425,60 @@ "panel.pet_level_every": "Nouveau niveau tous les", "panel.pet_reset_defaults": "Rétablir les nombres par défaut", "panel.pet_settings_invalid": "Les étapes doivent augmenter dans l’ordre, et légendaire et l’œuf doivent venir après adulte.", - "panel.pet_about_one_chore": "Environ 1 tâche (tâches à 5 points)" + "panel.pet_about_one_chore": "Environ 1 tâche (tâches à 5 points)", + "notify.reward_owed": "🎁 {child_name} attend toujours « {reward_name} » — échangée il y a {days} jours.", + "notify.personal_best": "🏆 Nouveau record personnel ! {child_name} : {record_label} — {value}.", + "notify.record_longest_streak": "plus longue série (jours)", + "notify.record_best_day_points": "le plus de {points_name} en une journée", + "notify.record_best_day_chores": "le plus de tâches en une journée", + "notify.record_best_week_points": "le plus de {points_name} en une semaine", + "notify.record_best_single_award": "plus gros gain d’un coup", + "notify.celebrate_personal_best": "{child_name} a battu un record personnel : {record_label} — {value} !", + "notification.reward_owed.name": "Récompense toujours due", + "notification.reward_owed.description": "Rappelle aux parents qu’une récompense échangée n’a pas encore été remise. Actif seulement quand le suivi des récompenses est activé.", + "notification.personal_best.name": "Record personnel", + "notification.personal_best.description": "Un enfant a battu l’un de ses propres records : plus longue série, meilleure journée, meilleure semaine ou plus gros gain.", + "approvals.owed_section": "À remettre", + "approvals.owed_delivered": "Remise", + "approvals.owed_today": "échangée aujourd’hui", + "approvals.owed_days_one": "due depuis 1 jour", + "approvals.owed_days": "due depuis {count} jours", + "panel.today_owed_title": "Récompenses à remettre", + "panel.today_owed_delivered": "Remise", + "panel.today_owed_marked": "Marquée comme remise", + "panel.today_owed_today": "échangée aujourd’hui", + "panel.today_owed_days_one": "due depuis 1 jour", + "panel.today_owed_days": "due depuis {count} jours", + "panel.fulfilment_title": "Remise des récompenses", + "panel.fulfilment_hint": "Approuver une récompense dépense les points ; ici elle reste sur une liste « à remettre » jusqu’à ce que quelqu’un la donne. L’enfant peut appuyer sur « Reçue ! » sur sa carte.", + "panel.fulfilment_enabled": "Suivre les récompenses jusqu’à leur remise", + "panel.fulfilment_reminder_days": "Me rappeler après", + "panel.fulfilment_reminder_days_hint": "Jours avant qu’une notification rappelle qu’une récompense est due, puis tous les autant de jours. 0 = jamais.", + "child.owed_title": "On te doit une récompense", + "child.owed_got_it": "Reçue !", + "child.owed_today": "échangée aujourd’hui", + "child.owed_days_one": "il y a 1 jour", + "child.owed_days": "il y a {count} jours", + "child.record_title": "Nouveau record personnel !", + "child.record_longest_streak": "Plus longue série", + "child.record_best_day_points": "Le plus de {points_name} en une journée", + "child.record_best_day_chores": "Le plus de tâches en une journée", + "child.record_best_week_points": "Le plus de {points_name} en une semaine", + "child.record_best_single_award": "Plus gros gain d’un coup", + "child.editor.show_personal_bests": "Afficher les nouveaux records personnels", + "bests.default_title": "Records personnels", + "bests.error.entity_required": "Veuillez définir une entité", + "bests.subtitle": "Tes propres records à battre", + "bests.none_yet": "Pas encore de record — les premières tâches fixent la barre", + "bests.not_yet": "pas encore", + "bests.new_tag": "Nouveau", + "bests.new_count": "{count} nouveau(x) aujourd’hui", + "bests.beat_previous": "avant : {previous}", + "bests.editor.title": "Titre", + "bests.editor.child_helper": "N’afficher que les records d’un enfant", + "bests.record_longest_streak": "Plus longue série", + "bests.record_best_day_points": "Meilleure journée ({points_name})", + "bests.record_best_day_chores": "Le plus de tâches en un jour", + "bests.record_best_week_points": "Meilleure semaine ({points_name})", + "bests.record_best_single_award": "Plus gros gain" } diff --git a/custom_components/taskmate/www/locales/nb.json b/custom_components/taskmate/www/locales/nb.json index 19ee294b..ffaa5570 100644 --- a/custom_components/taskmate/www/locales/nb.json +++ b/custom_components/taskmate/www/locales/nb.json @@ -3425,5 +3425,60 @@ "panel.pet_level_every": "Nytt nivå hver", "panel.pet_reset_defaults": "Tilbakestill tall til standard", "panel.pet_settings_invalid": "Stadiene må stige i rekkefølge, og legendarisk og egget må komme etter fullvoksen.", - "panel.pet_about_one_chore": "Omtrent 1 oppgave (oppgaver på 5 poeng)" + "panel.pet_about_one_chore": "Omtrent 1 oppgave (oppgaver på 5 poeng)", + "notify.reward_owed": "🎁 {child_name} venter fortsatt på «{reward_name}» – innløst for {days} dager siden.", + "notify.personal_best": "🏆 Ny personlig rekord! {child_name}: {record_label} – {value}.", + "notify.record_longest_streak": "lengste rekke (dager)", + "notify.record_best_day_points": "flest {points_name} på én dag", + "notify.record_best_day_chores": "flest oppgaver på én dag", + "notify.record_best_week_points": "flest {points_name} på én uke", + "notify.record_best_single_award": "største enkeltbelønning", + "notify.celebrate_personal_best": "{child_name} satte ny personlig rekord: {record_label} – {value}!", + "notification.reward_owed.name": "Belønning mangler fortsatt", + "notification.reward_owed.description": "Minner foreldre på at en innløst belønning ikke er gitt enda. Sendes bare når belønningsoppfølging er på.", + "notification.personal_best.name": "Personlig rekord", + "notification.personal_best.description": "Et barn slo en av sine egne rekorder: lengste rekke, beste dag, beste uke eller største belønning.", + "approvals.owed_section": "Gjenstår å gi", + "approvals.owed_delivered": "Gitt", + "approvals.owed_today": "innløst i dag", + "approvals.owed_days_one": "utestående i 1 dag", + "approvals.owed_days": "utestående i {count} dager", + "panel.today_owed_title": "Belønninger som skal gis", + "panel.today_owed_delivered": "Gitt", + "panel.today_owed_marked": "Merket som gitt", + "panel.today_owed_today": "innløst i dag", + "panel.today_owed_days_one": "utestående i 1 dag", + "panel.today_owed_days": "utestående i {count} dager", + "panel.fulfilment_title": "Belønningsoppfølging", + "panel.fulfilment_hint": "Når en belønning godkjennes, brukes poengene; her blir den stående på en liste over utestående til noen gir den. Barnet kan trykke «Fått!» på kortet sitt.", + "panel.fulfilment_enabled": "Følg belønninger til de er gitt", + "panel.fulfilment_reminder_days": "Minn meg på det etter", + "panel.fulfilment_reminder_days_hint": "Dager før et varsel sier at en belønning fortsatt mangler, gjentatt hver så mange dager. 0 = aldri.", + "child.owed_title": "Du har en belønning til gode", + "child.owed_got_it": "Fått!", + "child.owed_today": "innløst i dag", + "child.owed_days_one": "for 1 dag siden", + "child.owed_days": "for {count} dager siden", + "child.record_title": "Ny personlig rekord!", + "child.record_longest_streak": "Lengste rekke", + "child.record_best_day_points": "Flest {points_name} på én dag", + "child.record_best_day_chores": "Flest oppgaver på én dag", + "child.record_best_week_points": "Flest {points_name} på én uke", + "child.record_best_single_award": "Største enkeltbelønning", + "child.editor.show_personal_bests": "Vis nye personlige rekorder", + "bests.default_title": "Personlige rekorder", + "bests.error.entity_required": "Angi en enhet", + "bests.subtitle": "Dine egne rekorder å slå", + "bests.none_yet": "Ingen rekorder enda – de første oppgavene setter nivået", + "bests.not_yet": "ikke enda", + "bests.new_tag": "Ny", + "bests.new_count": "{count} nye i dag", + "bests.beat_previous": "slo {previous}", + "bests.editor.title": "Tittel", + "bests.editor.child_helper": "Vis bare ett barns rekorder", + "bests.record_longest_streak": "Lengste rekke", + "bests.record_best_day_points": "Beste dag ({points_name})", + "bests.record_best_day_chores": "Flest oppgaver på én dag", + "bests.record_best_week_points": "Beste uke ({points_name})", + "bests.record_best_single_award": "Største belønning" } diff --git a/custom_components/taskmate/www/locales/nl.json b/custom_components/taskmate/www/locales/nl.json index 54737ce9..dd188c36 100644 --- a/custom_components/taskmate/www/locales/nl.json +++ b/custom_components/taskmate/www/locales/nl.json @@ -3425,5 +3425,60 @@ "panel.pet_level_every": "Nieuw level om de", "panel.pet_reset_defaults": "Standaardgetallen terugzetten", "panel.pet_settings_invalid": "De groei per fase moet steeds hoger worden, en legendarisch en het ei komen pas na volgroeid.", - "panel.pet_about_one_chore": "Ongeveer 1 taak (taken van 5 punten)" + "panel.pet_about_one_chore": "Ongeveer 1 taak (taken van 5 punten)", + "notify.reward_owed": "🎁 {child_name} wacht nog steeds op '{reward_name}' — {days} dagen geleden ingewisseld.", + "notify.personal_best": "🏆 Nieuw persoonlijk record! {child_name}: {record_label} — {value}.", + "notify.record_longest_streak": "langste reeks (dagen)", + "notify.record_best_day_points": "meeste {points_name} op één dag", + "notify.record_best_day_chores": "meeste taken op één dag", + "notify.record_best_week_points": "meeste {points_name} in één week", + "notify.record_best_single_award": "grootste beloning in één keer", + "notify.celebrate_personal_best": "{child_name} heeft een nieuw persoonlijk record: {record_label} — {value}!", + "notification.reward_owed.name": "Beloning nog te geven", + "notification.reward_owed.description": "Herinnert ouders eraan dat een ingewisselde beloning nog niet is overhandigd. Werkt alleen als het bijhouden van beloningen aanstaat.", + "notification.personal_best.name": "Persoonlijk record", + "notification.personal_best.description": "Een kind heeft een eigen record verbroken: langste reeks, beste dag, beste week of grootste beloning.", + "approvals.owed_section": "Nog te overhandigen", + "approvals.owed_delivered": "Overhandigd", + "approvals.owed_today": "vandaag ingewisseld", + "approvals.owed_days_one": "1 dag tegoed", + "approvals.owed_days": "{count} dagen tegoed", + "panel.today_owed_title": "Beloningen om te overhandigen", + "panel.today_owed_delivered": "Overhandigd", + "panel.today_owed_marked": "Gemarkeerd als overhandigd", + "panel.today_owed_today": "vandaag ingewisseld", + "panel.today_owed_days_one": "1 dag tegoed", + "panel.today_owed_days": "{count} dagen tegoed", + "panel.fulfilment_title": "Beloningen overhandigen", + "panel.fulfilment_hint": "Bij het goedkeuren van een beloning worden de punten uitgegeven; hiermee blijft de beloning op een lijst met nog te geven beloningen staan tot iemand hem overhandigt. Het kind kan op zijn kaart op \"Gekregen!\" tikken.", + "panel.fulfilment_enabled": "Beloningen bijhouden tot ze zijn overhandigd", + "panel.fulfilment_reminder_days": "Herinner me na", + "panel.fulfilment_reminder_days_hint": "Aantal dagen voordat een melding laat weten dat een beloning nog gegeven moet worden; elke zoveel dagen herhaald. 0 = nooit.", + "child.owed_title": "Je krijgt nog een beloning", + "child.owed_got_it": "Gekregen!", + "child.owed_today": "vandaag ingewisseld", + "child.owed_days_one": "1 dag geleden", + "child.owed_days": "{count} dagen geleden", + "child.record_title": "Nieuw persoonlijk record!", + "child.record_longest_streak": "Langste reeks", + "child.record_best_day_points": "Meeste {points_name} op één dag", + "child.record_best_day_chores": "Meeste taken op één dag", + "child.record_best_week_points": "Meeste {points_name} in één week", + "child.record_best_single_award": "Grootste beloning in één keer", + "child.editor.show_personal_bests": "Nieuwe persoonlijke records tonen", + "bests.default_title": "Persoonlijke records", + "bests.error.entity_required": "Kies eerst een entiteit", + "bests.subtitle": "Je eigen records om te verbreken", + "bests.none_yet": "Nog geen records — de eerste taken leggen de lat", + "bests.not_yet": "nog niet", + "bests.new_tag": "Nieuw", + "bests.new_count": "{count} nieuw vandaag", + "bests.beat_previous": "vorig record: {previous}", + "bests.editor.title": "Titel", + "bests.editor.child_helper": "Alleen de records van één kind tonen", + "bests.record_longest_streak": "Langste reeks", + "bests.record_best_day_points": "Beste dag ({points_name})", + "bests.record_best_day_chores": "Meeste taken op één dag", + "bests.record_best_week_points": "Beste week ({points_name})", + "bests.record_best_single_award": "Grootste beloning" } diff --git a/custom_components/taskmate/www/locales/nn.json b/custom_components/taskmate/www/locales/nn.json index 412667dd..c24a3958 100644 --- a/custom_components/taskmate/www/locales/nn.json +++ b/custom_components/taskmate/www/locales/nn.json @@ -3425,5 +3425,60 @@ "panel.pet_level_every": "Nytt nivå kvar", "panel.pet_reset_defaults": "Tilbakestill tal til standard", "panel.pet_settings_invalid": "Stadia må stige i rekkjefølgje, og legendarisk og egget må kome etter fullvaksen.", - "panel.pet_about_one_chore": "Om lag 1 oppgåve (oppgåver på 5 poeng)" + "panel.pet_about_one_chore": "Om lag 1 oppgåve (oppgåver på 5 poeng)", + "notify.reward_owed": "🎁 {child_name} ventar framleis på «{reward_name}» – innløyst for {days} dagar sidan.", + "notify.personal_best": "🏆 Ny personleg rekord! {child_name}: {record_label} – {value}.", + "notify.record_longest_streak": "lengste rekkje (dagar)", + "notify.record_best_day_points": "flest {points_name} på éin dag", + "notify.record_best_day_chores": "flest oppgåver på éin dag", + "notify.record_best_week_points": "flest {points_name} på éi veke", + "notify.record_best_single_award": "største enkeltløning", + "notify.celebrate_personal_best": "{child_name} sette ny personleg rekord: {record_label} – {value}!", + "notification.reward_owed.name": "Løning manglar framleis", + "notification.reward_owed.description": "Minner foreldre på at ei innløyst løning ikkje er gjeven enno. Blir berre sendt når løningsoppfølging er på.", + "notification.personal_best.name": "Personleg rekord", + "notification.personal_best.description": "Eit barn slo ein av sine eigne rekordar: lengste rekkje, beste dag, beste veke eller største løning.", + "approvals.owed_section": "Står att å gje", + "approvals.owed_delivered": "Gjeven", + "approvals.owed_today": "innløyst i dag", + "approvals.owed_days_one": "uteståande i 1 dag", + "approvals.owed_days": "uteståande i {count} dagar", + "panel.today_owed_title": "Løningar som skal gjevast", + "panel.today_owed_delivered": "Gjeven", + "panel.today_owed_marked": "Merkt som gjeven", + "panel.today_owed_today": "innløyst i dag", + "panel.today_owed_days_one": "uteståande i 1 dag", + "panel.today_owed_days": "uteståande i {count} dagar", + "panel.fulfilment_title": "Løningsoppfølging", + "panel.fulfilment_hint": "Når ei løning blir godkjend, blir poenga brukte; her blir ho ståande på ei liste over uteståande til nokon gjev henne. Barnet kan trykkje «Fått!» på kortet sitt.", + "panel.fulfilment_enabled": "Følg løningar til dei er gjevne", + "panel.fulfilment_reminder_days": "Minn meg på det etter", + "panel.fulfilment_reminder_days_hint": "Dagar før eit varsel seier at ei løning framleis manglar, gjenteke kvar så mange dagar. 0 = aldri.", + "child.owed_title": "Du har ei løning til gode", + "child.owed_got_it": "Fått!", + "child.owed_today": "innløyst i dag", + "child.owed_days_one": "for 1 dag sidan", + "child.owed_days": "for {count} dagar sidan", + "child.record_title": "Ny personleg rekord!", + "child.record_longest_streak": "Lengste rekkje", + "child.record_best_day_points": "Flest {points_name} på éin dag", + "child.record_best_day_chores": "Flest oppgåver på éin dag", + "child.record_best_week_points": "Flest {points_name} på éi veke", + "child.record_best_single_award": "Største enkeltløning", + "child.editor.show_personal_bests": "Vis nye personlege rekordar", + "bests.default_title": "Personlege rekordar", + "bests.error.entity_required": "Oppgje ein entitet", + "bests.subtitle": "Dine eigne rekordar å slå", + "bests.none_yet": "Ingen rekordar enno – dei første oppgåvene set nivået", + "bests.not_yet": "ikkje enno", + "bests.new_tag": "Ny", + "bests.new_count": "{count} nye i dag", + "bests.beat_previous": "slo {previous}", + "bests.editor.title": "Tittel", + "bests.editor.child_helper": "Vis berre rekordane til eitt barn", + "bests.record_longest_streak": "Lengste rekkje", + "bests.record_best_day_points": "Beste dag ({points_name})", + "bests.record_best_day_chores": "Flest oppgåver på éin dag", + "bests.record_best_week_points": "Beste veke ({points_name})", + "bests.record_best_single_award": "Største løning" } diff --git a/custom_components/taskmate/www/locales/pl.json b/custom_components/taskmate/www/locales/pl.json index 67cde070..6769ef48 100644 --- a/custom_components/taskmate/www/locales/pl.json +++ b/custom_components/taskmate/www/locales/pl.json @@ -3425,5 +3425,60 @@ "panel.pet_level_every": "Nowy poziom co", "panel.pet_reset_defaults": "Przywróć domyślne liczby", "panel.pet_settings_invalid": "Etapy muszą rosnąć po kolei, a legendarność i jajko muszą być po dorośnięciu.", - "panel.pet_about_one_chore": "Około 1 zadania (zadania za 5 punktów)" + "panel.pet_about_one_chore": "Około 1 zadania (zadania za 5 punktów)", + "notify.reward_owed": "🎁 {child_name} nadal czeka na „{reward_name}” — odebrano {days} dni temu.", + "notify.personal_best": "🏆 Nowy rekord życiowy! {child_name}: {record_label} — {value}.", + "notify.record_longest_streak": "najdłuższa seria (dni)", + "notify.record_best_day_points": "najwięcej {points_name} w jeden dzień", + "notify.record_best_day_chores": "najwięcej obowiązków w jeden dzień", + "notify.record_best_week_points": "najwięcej {points_name} w jeden tydzień", + "notify.record_best_single_award": "największa pojedyncza nagroda", + "notify.celebrate_personal_best": "{child_name} ma nowy rekord życiowy: {record_label} — {value}!", + "notification.reward_owed.name": "Nagroda wciąż należna", + "notification.reward_owed.description": "Przypomina rodzicom, że odebrana nagroda nie została jeszcze wręczona. Działa tylko, gdy włączone jest śledzenie wręczania nagród.", + "notification.personal_best.name": "Rekord życiowy", + "notification.personal_best.description": "Dziecko pobiło jeden ze swoich rekordów: najdłuższą serię, najlepszy dzień, najlepszy tydzień lub największą nagrodę.", + "approvals.owed_section": "Do wręczenia", + "approvals.owed_delivered": "Wręczono", + "approvals.owed_today": "odebrano dzisiaj", + "approvals.owed_days_one": "należna od 1 dnia", + "approvals.owed_days": "należna od {count} dni", + "panel.today_owed_title": "Nagrody do wręczenia", + "panel.today_owed_delivered": "Wręczono", + "panel.today_owed_marked": "Oznaczono jako wręczoną", + "panel.today_owed_today": "odebrano dzisiaj", + "panel.today_owed_days_one": "należna od 1 dnia", + "panel.today_owed_days": "należna od {count} dni", + "panel.fulfilment_title": "Wręczanie nagród", + "panel.fulfilment_hint": "Zatwierdzenie nagrody zużywa punkty; tutaj nagroda zostaje na liście „należne”, aż ktoś ją wręczy. Dziecko może nacisnąć „Mam!” na swojej karcie.", + "panel.fulfilment_enabled": "Śledź nagrody do momentu wręczenia", + "panel.fulfilment_reminder_days": "Przypomnij mi po", + "panel.fulfilment_reminder_days_hint": "Liczba dni, po których powiadomienie przypomni o należnej nagrodzie, powtarzane co tyle dni. 0 = nigdy.", + "child.owed_title": "Należy ci się nagroda", + "child.owed_got_it": "Mam!", + "child.owed_today": "odebrano dzisiaj", + "child.owed_days_one": "1 dzień temu", + "child.owed_days": "{count} dni temu", + "child.record_title": "Nowy rekord życiowy!", + "child.record_longest_streak": "Najdłuższa seria", + "child.record_best_day_points": "Najwięcej {points_name} w jeden dzień", + "child.record_best_day_chores": "Najwięcej obowiązków w jeden dzień", + "child.record_best_week_points": "Najwięcej {points_name} w jeden tydzień", + "child.record_best_single_award": "Największa pojedyncza nagroda", + "child.editor.show_personal_bests": "Pokaż nowe rekordy życiowe", + "bests.default_title": "Rekordy życiowe", + "bests.error.entity_required": "Podaj encję", + "bests.subtitle": "Twoje własne rekordy do pobicia", + "bests.none_yet": "Jeszcze brak rekordów — pierwsze obowiązki ustawią poprzeczkę", + "bests.not_yet": "jeszcze nie", + "bests.new_tag": "Nowy", + "bests.new_count": "{count} nowe dzisiaj", + "bests.beat_previous": "poprzednio {previous}", + "bests.editor.title": "Tytuł", + "bests.editor.child_helper": "Pokaż rekordy tylko jednego dziecka", + "bests.record_longest_streak": "Najdłuższa seria", + "bests.record_best_day_points": "Najlepszy dzień ({points_name})", + "bests.record_best_day_chores": "Najwięcej obowiązków w jeden dzień", + "bests.record_best_week_points": "Najlepszy tydzień ({points_name})", + "bests.record_best_single_award": "Największa nagroda" } diff --git a/custom_components/taskmate/www/locales/pt-BR.json b/custom_components/taskmate/www/locales/pt-BR.json index e94a4764..075a31fd 100644 --- a/custom_components/taskmate/www/locales/pt-BR.json +++ b/custom_components/taskmate/www/locales/pt-BR.json @@ -3425,5 +3425,60 @@ "panel.pet_level_every": "Novo nível a cada", "panel.pet_reset_defaults": "Restaurar os números padrão", "panel.pet_settings_invalid": "As fases precisam subir em ordem, e o lendário e o ovo precisam vir depois de adulto.", - "panel.pet_about_one_chore": "Cerca de 1 tarefa (tarefas de 5 pontos)" + "panel.pet_about_one_chore": "Cerca de 1 tarefa (tarefas de 5 pontos)", + "notify.reward_owed": "🎁 {child_name} ainda está esperando por '{reward_name}' — resgatada há {days} dias.", + "notify.personal_best": "🏆 Novo recorde pessoal! {child_name}: {record_label} — {value}.", + "notify.record_longest_streak": "sequência mais longa (dias)", + "notify.record_best_day_points": "mais {points_name} em um dia", + "notify.record_best_day_chores": "mais tarefas em um dia", + "notify.record_best_week_points": "mais {points_name} em uma semana", + "notify.record_best_single_award": "maior prêmio de uma vez", + "notify.celebrate_personal_best": "{child_name} bateu um recorde pessoal: {record_label} — {value}!", + "notification.reward_owed.name": "Recompensa ainda não entregue", + "notification.reward_owed.description": "Lembra os pais de que uma recompensa resgatada ainda não foi entregue. Só é enviada enquanto a entrega de recompensas estiver ligada.", + "notification.personal_best.name": "Recorde pessoal", + "notification.personal_best.description": "Uma criança bateu um dos seus próprios recordes: sequência mais longa, melhor dia, melhor semana ou maior prêmio.", + "approvals.owed_section": "Para entregar", + "approvals.owed_delivered": "Entregue", + "approvals.owed_today": "resgatada hoje", + "approvals.owed_days_one": "pendente há 1 dia", + "approvals.owed_days": "pendente há {count} dias", + "panel.today_owed_title": "Recompensas para entregar", + "panel.today_owed_delivered": "Entregue", + "panel.today_owed_marked": "Marcada como entregue", + "panel.today_owed_today": "resgatada hoje", + "panel.today_owed_days_one": "pendente há 1 dia", + "panel.today_owed_days": "pendente há {count} dias", + "panel.fulfilment_title": "Entrega de recompensas", + "panel.fulfilment_hint": "Aprovar uma recompensa gasta os pontos; aqui ela fica numa lista de \"pendentes\" até alguém entregá-la. A criança pode tocar em \"Recebi!\" no seu cartão.", + "panel.fulfilment_enabled": "Acompanhar as recompensas até serem entregues", + "panel.fulfilment_reminder_days": "Me lembrar após", + "panel.fulfilment_reminder_days_hint": "Dias até uma notificação avisar que uma recompensa continua pendente, repetida a cada tantos dias. 0 = nunca.", + "child.owed_title": "Você tem uma recompensa para receber", + "child.owed_got_it": "Recebi!", + "child.owed_today": "resgatada hoje", + "child.owed_days_one": "há 1 dia", + "child.owed_days": "há {count} dias", + "child.record_title": "Novo recorde pessoal!", + "child.record_longest_streak": "Sequência mais longa", + "child.record_best_day_points": "Mais {points_name} em um dia", + "child.record_best_day_chores": "Mais tarefas em um dia", + "child.record_best_week_points": "Mais {points_name} em uma semana", + "child.record_best_single_award": "Maior prêmio de uma vez", + "child.editor.show_personal_bests": "Mostrar novos recordes pessoais", + "bests.default_title": "Recordes pessoais", + "bests.error.entity_required": "Defina uma entidade", + "bests.subtitle": "Seus próprios recordes para bater", + "bests.none_yet": "Ainda sem recordes — as primeiras tarefas definem a meta", + "bests.not_yet": "ainda não", + "bests.new_tag": "Novo", + "bests.new_count": "{count} novos hoje", + "bests.beat_previous": "antes: {previous}", + "bests.editor.title": "Título", + "bests.editor.child_helper": "Mostrar apenas os recordes de uma criança", + "bests.record_longest_streak": "Sequência mais longa", + "bests.record_best_day_points": "Melhor dia ({points_name})", + "bests.record_best_day_chores": "Mais tarefas em um dia", + "bests.record_best_week_points": "Melhor semana ({points_name})", + "bests.record_best_single_award": "Maior prêmio" } diff --git a/custom_components/taskmate/www/locales/pt.json b/custom_components/taskmate/www/locales/pt.json index 717bcbd5..cb648ed6 100644 --- a/custom_components/taskmate/www/locales/pt.json +++ b/custom_components/taskmate/www/locales/pt.json @@ -3425,5 +3425,60 @@ "panel.pet_level_every": "Novo nível a cada", "panel.pet_reset_defaults": "Repor os números predefinidos", "panel.pet_settings_invalid": "As fases têm de subir por ordem, e o lendário e o ovo têm de vir depois de adulto.", - "panel.pet_about_one_chore": "Cerca de 1 tarefa (tarefas de 5 pontos)" + "panel.pet_about_one_chore": "Cerca de 1 tarefa (tarefas de 5 pontos)", + "notify.reward_owed": "🎁 {child_name} ainda está à espera de '{reward_name}' — resgatada há {days} dias.", + "notify.personal_best": "🏆 Novo recorde pessoal! {child_name}: {record_label} — {value}.", + "notify.record_longest_streak": "sequência mais longa (dias)", + "notify.record_best_day_points": "mais {points_name} num dia", + "notify.record_best_day_chores": "mais tarefas num dia", + "notify.record_best_week_points": "mais {points_name} numa semana", + "notify.record_best_single_award": "maior prémio de uma vez", + "notify.celebrate_personal_best": "{child_name} bateu um recorde pessoal: {record_label} — {value}!", + "notification.reward_owed.name": "Recompensa ainda por entregar", + "notification.reward_owed.description": "Lembra os pais de que uma recompensa resgatada ainda não foi entregue. Só é enviada enquanto a entrega de recompensas estiver ativa.", + "notification.personal_best.name": "Recorde pessoal", + "notification.personal_best.description": "Uma criança bateu um dos seus próprios recordes: sequência mais longa, melhor dia, melhor semana ou maior prémio.", + "approvals.owed_section": "Por entregar", + "approvals.owed_delivered": "Entregue", + "approvals.owed_today": "resgatada hoje", + "approvals.owed_days_one": "por entregar há 1 dia", + "approvals.owed_days": "por entregar há {count} dias", + "panel.today_owed_title": "Recompensas por entregar", + "panel.today_owed_delivered": "Entregue", + "panel.today_owed_marked": "Marcada como entregue", + "panel.today_owed_today": "resgatada hoje", + "panel.today_owed_days_one": "por entregar há 1 dia", + "panel.today_owed_days": "por entregar há {count} dias", + "panel.fulfilment_title": "Entrega de recompensas", + "panel.fulfilment_hint": "Aprovar uma recompensa gasta os pontos; aqui ela fica numa lista de \"por entregar\" até alguém a dar. A criança pode tocar em \"Recebi!\" no seu cartão.", + "panel.fulfilment_enabled": "Acompanhar as recompensas até serem entregues", + "panel.fulfilment_reminder_days": "Lembrar-me após", + "panel.fulfilment_reminder_days_hint": "Dias até uma notificação avisar que uma recompensa continua por entregar, repetida a cada tantos dias. 0 = nunca.", + "child.owed_title": "Tens uma recompensa por receber", + "child.owed_got_it": "Recebi!", + "child.owed_today": "resgatada hoje", + "child.owed_days_one": "há 1 dia", + "child.owed_days": "há {count} dias", + "child.record_title": "Novo recorde pessoal!", + "child.record_longest_streak": "Sequência mais longa", + "child.record_best_day_points": "Mais {points_name} num dia", + "child.record_best_day_chores": "Mais tarefas num dia", + "child.record_best_week_points": "Mais {points_name} numa semana", + "child.record_best_single_award": "Maior prémio de uma vez", + "child.editor.show_personal_bests": "Mostrar novos recordes pessoais", + "bests.default_title": "Recordes pessoais", + "bests.error.entity_required": "Define uma entidade", + "bests.subtitle": "Os teus próprios recordes a bater", + "bests.none_yet": "Ainda sem recordes — as primeiras tarefas definem a meta", + "bests.not_yet": "ainda não", + "bests.new_tag": "Novo", + "bests.new_count": "{count} novos hoje", + "bests.beat_previous": "antes: {previous}", + "bests.editor.title": "Título", + "bests.editor.child_helper": "Mostrar apenas os recordes de uma criança", + "bests.record_longest_streak": "Sequência mais longa", + "bests.record_best_day_points": "Melhor dia ({points_name})", + "bests.record_best_day_chores": "Mais tarefas num dia", + "bests.record_best_week_points": "Melhor semana ({points_name})", + "bests.record_best_single_award": "Maior prémio" } diff --git a/custom_components/taskmate/www/taskmate-approvals-card.js b/custom_components/taskmate/www/taskmate-approvals-card.js index 4ed56935..8ed8a01a 100644 --- a/custom_components/taskmate/www/taskmate-approvals-card.js +++ b/custom_components/taskmate/www/taskmate-approvals-card.js @@ -536,6 +536,10 @@ class TaskMateApprovalsCard extends LitElement { color: white; } + .approval-item.owed { border-inline-start: 3px solid #8e44ad; } + .approval-item.owed .owed-since { font-size: 0.8rem; color: var(--secondary-text-color); } + .action-button.delivered { width: auto; border-radius: 999px; padding: 0 12px; gap: 6px; } + .action-button.delivered .delivered-label { font-size: 0.8rem; font-weight: 600; } .approval-item.mandatory { border-inline-start: 5px solid var(--fun-red, #e74c3c); } @@ -770,6 +774,9 @@ class TaskMateApprovalsCard extends LitElement { // Missed mandatory chores awaiting parent review (#532) const misses = this._filterMissesByChild(attrs.mandatory_misses || []); + // Reward fulfilment (#1098): redeemed rewards still to hand over. Not an + // approval, so it never counts in the header badge. + const owed = this._owedRewards(entity, attrs); const totalPending = filteredCompletions.length + filteredClaims.length + misses.length; @@ -793,12 +800,13 @@ class TaskMateApprovalsCard extends LitElement {
- ${totalPending === 0 + ${totalPending === 0 && owed.length === 0 ? this._renderEmptyState() : html` ${misses.length > 0 ? this._renderMandatoryMisses(misses) : ''} ${filteredClaims.length > 0 ? this._renderRewardClaims(filteredClaims) : ''} ${filteredCompletions.length > 0 ? this._renderApprovals(groupedByDay) : ''} + ${owed.length > 0 ? this._renderOwedRewards(owed) : ''} `}
${this._renderReview()} @@ -897,6 +905,20 @@ class TaskMateApprovalsCard extends LitElement { }); }); + this._owedRewards(entity, attrs).forEach((o) => { + items.push({ + kind: "owed", + id: o.claim_id, + title: o.reward_name || "", + childName: o.child_name || "", + childId: o.child_id, + points: o.cost ?? 0, + icon: o.reward_icon || "mdi:gift", + daysOwed: o.days_owed || 0, + sortTime: 3, + }); + }); + filteredCompletions.forEach((c) => { items.push({ kind: "completion", @@ -930,6 +952,9 @@ class TaskMateApprovalsCard extends LitElement { if (it.kind === "completion" && it.completedAt) { dayLabel = this._getDayLabel(new Date(it.completedAt)); cat = it.timeCategory || "anytime"; + } else if (it.kind === "owed") { + // Owed rewards sit in their own group after the approvals. + dayLabel = this._t('approvals.owed_section'); } if (!groups.has(dayLabel)) groups.set(dayLabel, new Map()); const cats = groups.get(dayLabel); @@ -938,7 +963,10 @@ class TaskMateApprovalsCard extends LitElement { }); // Build ordered list of { dayLabel, sub, items[] } sections. const sections = []; - for (const [dayLabel, cats] of groups) { + const owedLabel = this._t('approvals.owed_section'); + const ordered_days = [...groups.keys()].sort((a, b) => (a === owedLabel) - (b === owedLabel)); + for (const dayLabel of ordered_days) { + const cats = groups.get(dayLabel); const ordered = [...cats.entries()].sort( ([a], [b]) => this._getTimeCategoryOrder(a) - this._getTimeCategoryOrder(b) ); @@ -980,8 +1008,9 @@ class TaskMateApprovalsCard extends LitElement { } const completions = items.filter((it) => it.kind === "completion").map((it) => it.completion); + const pendingCount = items.filter((it) => it.kind !== "owed").length; return html` - ${this._designHeader(hd, items.length, completions)} + ${this._designHeader(hd, pendingCount, completions)}
${body}
${this._renderReview()} ${this._renderRejectSheet()} @@ -1019,6 +1048,15 @@ class TaskMateApprovalsCard extends LitElement { const reject = () => this._handleRejectReward(it.id, it.title, it.childName); return this._apActionPair(approve, reject, isLoading, shape); } + if (it.kind === "owed") { + const done = () => this._handleRewardDelivered(it.id); + if (shape === "round") { + return html``; + } + return html``; + } // mandatory miss: apply penalty (if any) / postpone / dismiss if (shape === "round") { return html` @@ -1072,7 +1110,7 @@ class TaskMateApprovalsCard extends LitElement { const photoUrl = this._photoHref(it.photo); const ref = it.kind === "completion" ? it.reference : ""; if (it.kind !== "completion" || !(photoUrl || ref)) { - return it.kind === "claim" && it.icon + return (it.kind === "claim" || it.kind === "owed") && it.icon ? html`
` : ""; } @@ -1148,10 +1186,10 @@ class TaskMateApprovalsCard extends LitElement { ${this._av(it.childName, this._childAvatar(it.childId), tone, 40)}
${it.title}
-
${it.childName}${it.quantity ? html` · 🔢 ${it.quantity}` : ""}
+
${it.childName}${it.quantity ? html` · 🔢 ${it.quantity}` : ""}${it.kind === "owed" ? html` · ${this._owedFor(it.daysOwed)}` : ""}
${this._apPhotoDesigned(it, "ap-pl-photo")} -
+${it.points}
+
${it.kind === "owed" ? "" : "+"}${it.points}
${this._designActions(it, "full")} @@ -1166,7 +1204,7 @@ class TaskMateApprovalsCard extends LitElement { ${this._av(it.childName, this._childAvatar(it.childId), tone, 34)}
${it.title}
-
${(it.childName || '').toUpperCase()} · +${it.points} XP${it.quantity ? html` · ${it.quantity}` : ""}
+
${(it.childName || '').toUpperCase()} · ${it.kind === "owed" ? this._owedFor(it.daysOwed) : html`+${it.points} XP`}${it.quantity ? html` · ${it.quantity}` : ""}
${it.kind === "completion" ? this._renderStars(it.completion, "ap-cn-stars") : ""}
${this._apPhotoDesigned(it, "ap-cn-photo")} @@ -1184,10 +1222,10 @@ class TaskMateApprovalsCard extends LitElement { ${this._av(it.childName, this._childAvatar(it.childId), tone, 38)}
${it.title}
-
${it.childName}${time ? html` · ${time}` : ""}${it.quantity ? html` · 🔢 ${it.quantity}` : ""}
+
${it.childName}${time ? html` · ${time}` : ""}${it.quantity ? html` · 🔢 ${it.quantity}` : ""}${it.kind === "owed" ? html` · ${this._owedFor(it.daysOwed)}` : ""}
${this._apPhotoDesigned(it, "ap-cp-photo")} - +${it.points} + ${it.kind === "owed" ? "" : "+"}${it.points}
${this._designActions(it, "ghost")}
`; } @@ -1299,6 +1337,67 @@ class TaskMateApprovalsCard extends LitElement { } } + // ---- reward fulfilment (#1098) ---------------------------------------- + /** Redeemed rewards still to hand over, from either sensor, filtered like the claims. */ + _owedRewards(entity, attrs) { + const list = (entity && entity.attributes && entity.attributes.owed_rewards) || attrs.owed_rewards || []; + return this._filterClaimsByChild(Array.isArray(list) ? list : []); + } + + _owedFor(days) { + const n = Number(days) || 0; + if (n <= 0) return this._t('approvals.owed_today'); + return this._t(n === 1 ? 'approvals.owed_days_one' : 'approvals.owed_days', { count: n }); + } + + _renderOwedRewards(owed) { + return html` +
+
+ + ${this._t('approvals.owed_section')} +
+ ${owed.map((o) => this._renderOwedItem(o))} +
+ `; + } + + _renderOwedItem(o) { + const isLoading = this._loading[o.claim_id]; + return html` +
+
+ + + ${o.reward_name || ''} + +
+ + + ${o.child_name || ''} + + ${this._owedFor(o.days_owed)} +
+
+
+ +
+
+ `; + } + + async _handleRewardDelivered(claimId) { + await this._callClaimService('mark_reward_delivered', claimId); + } + // ---- mandatory-miss review (#532) ------------------------------------ _filterMissesByChild(misses) { if (!this.config.child_id) return misses; diff --git a/custom_components/taskmate/www/taskmate-child-card.js b/custom_components/taskmate/www/taskmate-child-card.js index 83c21f50..308ecf74 100644 --- a/custom_components/taskmate/www/taskmate-child-card.js +++ b/custom_components/taskmate/www/taskmate-child-card.js @@ -103,6 +103,7 @@ class TaskMateChildCard extends LitElement { || this.hass?.states?.[this.config?.entity]?.attributes || {}; this._maybeBirthdayConfetti(attrs); this._maybeInspectionCelebration(attrs); + this._maybeRecordConfetti(attrs); this._scheduleUndoExpiry(attrs); const sessions = attrs.active_timed_sessions || []; const hasRunning = sessions.some(s => s.state === 'running' && s.child_id === this.config?.child_id); @@ -1434,6 +1435,10 @@ class TaskMateChildCard extends LitElement { .tm-care-sick { background: #e8f1ff; color: #1e3a6e; } .tm-care-redeem { background: #fff4e0; color: #6b4200; } .tm-care-goal { background: #ecf8ef; color: #1d5b2c; } + .tm-care-owed { background: #f3e9fb; color: #4a2070; } + .tm-care-owed .ic ha-icon { --mdc-icon-size: 26px; color: #8e44ad; } + .tm-care-owed .tm-owed-body { flex: 1; min-width: 0; } + .tm-care-record { background: #fff6d6; color: #6b4e00; } .tm-care-goal.reached { background: #d9f5df; } .tm-goal-set { cursor: pointer; width: 100%; } .tm-goal-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 6px; } @@ -5141,6 +5146,28 @@ class TaskMateChildCard extends LitElement { 🤝${this._t("child.redeem_title")}
${this._t("child.redeem_body", { chore: offer.chore_name, points: offer.points, date: this._shortDate(offer.expires) })}
`); } + // Reward fulfilment (#1098): what this child is still owed, with "Got it!". + for (const o of child.owed_rewards || []) { + const n = Number(o.days_owed) || 0; + const since = n <= 0 ? this._t("child.owed_today") : this._t(n === 1 ? "child.owed_days_one" : "child.owed_days", { count: n }); + parts.push(html`
+ + ${this._t("child.owed_title")}
${o.reward_name} · ${since}
+ +
`); + } + // Personal bests (#1099): records beaten today, for the rest of the day. + const bests = child.personal_bests; + if (bests && this.config.show_personal_bests !== false && Array.isArray(bests.new_today) && bests.new_today.length) { + const pointsName = this._pointsNameFor(child); + const lines = bests.new_today.map((key) => { + const rec = bests[key] || {}; + return `${this._t(`child.record_${key}`, { points_name: pointsName })}: ${rec.value}`; + }); + parts.push(html`
+ 🏆${this._t("child.record_title")}
${lines.join(" · ")}
`); + } const goal = child.weekly_goal; if (goal && this.config.show_weekly_goal !== false) { if (!goal.target) { @@ -5163,6 +5190,44 @@ class TaskMateChildCard extends LitElement { return html`
${parts}
${this._renderGoalSheet()}`; } + _pointsNameFor() { + const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config?.entity)) + || this.hass?.states?.[this.config?.entity]?.attributes || {}; + return attrs.points_name || this._t("common.points"); + } + + async _gotReward(child, owed) { + const key = `owed_${owed.claim_id}`; + if (this._loading[key]) return; + this._loading = { ...this._loading, [key]: true }; + this.requestUpdate(); + try { + await this.hass.callService("taskmate", "mark_reward_delivered", { claim_id: owed.claim_id, child_id: child.id }); + } catch (err) { + console.error("mark_reward_delivered failed", err); + } finally { + this._loading = { ...this._loading, [key]: false }; + this.requestUpdate(); + } + } + + /* Personal bests (#1099): confetti the first time this device sees a record + beaten today — once per child, record and day, shared across cards. */ + _maybeRecordConfetti(attrs) { + const child = (attrs.children || []).find(c => c.id === this.config?.child_id); + const fresh = child && child.personal_bests && child.personal_bests.new_today; + if (!Array.isArray(fresh) || !fresh.length || this.config?.show_personal_bests === false) return; + const KEY = "taskmate_records_celebrated"; + let seen; + try { seen = JSON.parse(window.localStorage.getItem(KEY) || "[]") || []; } catch (e) { seen = []; } + if (!Array.isArray(seen)) seen = []; + const ids = fresh.map((k) => `${child.id}:${k}:${(child.personal_bests[k] || {}).date || ""}`).filter((id) => !seen.includes(id)); + if (!ids.length) return; + seen.push(...ids); + try { window.localStorage.setItem(KEY, JSON.stringify(seen.slice(-60))); } catch (e) { /* private mode: plays each load */ } + this._spawnConfetti(); + } + _shortDate(iso) { const d = iso ? new Date(`${iso}T12:00:00`) : null; if (!d || isNaN(d)) return iso || ""; @@ -5937,6 +6002,7 @@ class TaskMateChildCardEditor extends LitElement { { name: 'show_help_button', selector: { boolean: {} } }, { name: 'show_mood', selector: { boolean: {} } }, { name: 'show_weekly_goal', selector: { boolean: {} } }, + { name: 'show_personal_bests', selector: { boolean: {} } }, { name: 'show_description', selector: { boolean: {} } }, { name: 'pre_reader', selector: { boolean: {} } }, { name: 'pre_reader_labels', selector: { boolean: {} } }, @@ -5963,6 +6029,7 @@ class TaskMateChildCardEditor extends LitElement { show_help_button: this._t('child.editor.show_help_button'), show_mood: this._t('child.editor.show_mood'), show_weekly_goal: this._t('child.editor.show_weekly_goal'), + show_personal_bests: this._t('child.editor.show_personal_bests'), show_description: this._t('child.editor.show_description'), pre_reader: this._t('child.editor.pre_reader'), pre_reader_labels: this._t('child.editor.pre_reader_labels'), @@ -6008,6 +6075,7 @@ class TaskMateChildCardEditor extends LitElement { show_help_button: this.config.show_help_button !== false, show_mood: this.config.show_mood !== false, show_weekly_goal: this.config.show_weekly_goal !== false, + show_personal_bests: this.config.show_personal_bests !== false, show_description: this.config.show_description === true, pre_reader: this.config.pre_reader === true, pre_reader_labels: this.config.pre_reader_labels === true, @@ -6050,6 +6118,7 @@ class TaskMateChildCardEditor extends LitElement { for (const [key, value] of Object.entries(newValues)) { const defaults = { show_countdown: true, show_minutes_left: true, show_help_button: true, show_mood: true, show_weekly_goal: true, + show_personal_bests: true, show_description: false, debug: false, time_category: 'morning', due_days_mode: 'hide', recurrence_done_mode: 'dim', elapsed_time_mode: 'dim', diff --git a/custom_components/taskmate/www/taskmate-panel.js b/custom_components/taskmate/www/taskmate-panel.js index 3236af06..b6da4416 100644 --- a/custom_components/taskmate/www/taskmate-panel.js +++ b/custom_components/taskmate/www/taskmate-panel.js @@ -652,6 +652,7 @@ class TaskMatePanel extends HTMLElement { if (act === "adjust-points") { this._doAdjustPoints(t.dataset.id, Number(t.dataset.delta)); return; } if (act === "adjust-points-custom") { this._openAdjustDialog(t.dataset.id); return; } if (act === "answer-help") { this._answerHelp(t.dataset.id); return; } + if (act === "reward-delivered") { this._markRewardDelivered(t.dataset.id); return; } if (act === "toggle-sick") { this._toggleSickDay(t.dataset.id); return; } if (act === "backdate-open") { this._openBackdateDialog(t.dataset.id); return; } if (act === "save-backdate") { this._saveBackdateDialog(); return; } @@ -4981,6 +4982,7 @@ class TaskMatePanel extends HTMLElement {
${this._renderHelpCard(kids)} + ${this._renderOwedCard(kids)}

${this._t("panel.today_needs_title")} ${queue.count > 0 ? `${queue.count}` : ""} @@ -5048,6 +5050,41 @@ class TaskMatePanel extends HTMLElement {

`; } + /** Reward fulfilment (#1098): redeemed rewards still to hand over, with a "Handed over" button each. */ + _renderOwedCard(kids) { + const ids = new Set(kids.map(k => k.id)); + const owed = (this._state.owed_rewards || []).filter(o => ids.has(o.child_id)); + if (!owed.length) return ""; + const names = Object.fromEntries((this._state.children || []).map(c => [c.id, c.name])); + const since = (o) => { + const n = Number(o.days_owed) || 0; + if (n <= 0) return this._t("panel.today_owed_today"); + return this._t(n === 1 ? "panel.today_owed_days_one" : "panel.today_owed_days", { count: n }); + }; + return ` +
+

${this._t("panel.today_owed_title")} + ${owed.length}

+
+ ${owed.map(o => ` +
+
+ ${this._esc(names[o.child_id] || o.child_name || "?")} — ${this._esc(o.reward_name || "")} +
${this._esc(since(o))}
+
+ +
`).join("")} +
+
`; + } + + async _markRewardDelivered(id) { + const { ok, err } = await this._callService("mark_reward_delivered", { claim_id: id }); + if (!ok) { this._showToast("err", err); return; } + await this._fetchState(); + this._showToast("ok", this._t("panel.today_owed_marked")); + } + async _answerHelp(id) { const { ok, err } = await this._callService("answer_help", { request_id: id }); if (!ok) { this._showToast("err", err); return; } @@ -9653,6 +9690,20 @@ class TaskMatePanel extends HTMLElement {
+
+

${this._t("panel.fulfilment_title")}

${this._t("panel.fulfilment_hint")}

+
+
+
${this._t("panel.fulfilment_enabled")}
+ +
+
+
${this._t("panel.fulfilment_reminder_days")}${this._t("panel.fulfilment_reminder_days_hint")}
+ +
+
+
+

${this._t("panel.allowance_title")}

${this._t("panel.allowance_hint")}

@@ -14059,6 +14110,7 @@ class TaskMatePanel extends HTMLElement { .tm-kid-sick-on { color: var(--info-color, #039be5); background: color-mix(in srgb, var(--info-color, #039be5) 14%, transparent); } .tm-pill-info { background: color-mix(in srgb, var(--info-color, #039be5) 14%, transparent); color: var(--info-color, #039be5); } .tm-help-card { border-inline-start: 4px solid var(--warning-color, #ffa600); } + .tm-owed-card { border-inline-start: 4px solid #8e44ad; } .tm-help-list { display: flex; flex-direction: column; gap: 8px; } .tm-help-row { display: flex; align-items: center; gap: 10px; justify-content: space-between; padding: 8px 0; border-top: 1px solid var(--divider-color); } .tm-help-row:first-child { border-top: 0; } diff --git a/custom_components/taskmate/www/taskmate-personal-bests-card.js b/custom_components/taskmate/www/taskmate-personal-bests-card.js new file mode 100644 index 00000000..d2a15938 --- /dev/null +++ b/custom_components/taskmate/www/taskmate-personal-bests-card.js @@ -0,0 +1,467 @@ +/** + * TaskMate Personal Bests Card (#1099) + * + * A child's own records board: longest streak, most points in a day and in a + * week, most chores in a day and the biggest single award, each with the day + * it was set. A record beaten today glows for the rest of the day. + * + * Reads `children[].personal_bests` from the overview sensor. With a + * `child_id` it is that child's board; without one it shows every child. + */ + +const LitElement = customElements.get("hui-masonry-view") + ? Object.getPrototypeOf(customElements.get("hui-masonry-view")) + : Object.getPrototypeOf(customElements.get("hui-view")); + +const html = LitElement.prototype.html; +const css = LitElement.prototype.css; + +const _safeColor = (c, d) => (typeof c === "string" && /^#[0-9a-fA-F]{3,8}$/.test(c) ? c : d); + +const RECORDS = [ + { key: "longest_streak", icon: "mdi:fire", emoji: "🔥" }, + { key: "best_day_points", icon: "mdi:star-circle", emoji: "⭐" }, + { key: "best_week_points", icon: "mdi:calendar-star", emoji: "📅" }, + { key: "best_day_chores", icon: "mdi:check-all", emoji: "✅" }, + { key: "best_single_award", icon: "mdi:trophy", emoji: "🏆" }, +]; + +class TaskMatePersonalBestsCard extends LitElement { + static get properties() { + return { + hass: { type: Object }, + config: { type: Object }, + }; + } + + shouldUpdate(changedProps) { + if (changedProps.has("hass")) { + return window.__taskmate_hasChanged + ? window.__taskmate_hasChanged(changedProps.get("hass"), this.hass, this.config?.entity) + : true; + } + return true; + } + + _t(key, params) { + const fn = window.__taskmate_localize; + return fn ? fn(this.hass, key, params) : key; + } + + _avatarGlyph(source, fallback = "mdi:account-circle", cls = "pb-face-img") { + const face = window.__taskmate_child_visual(source); + return face.kind === "image" + ? html`` + : html``; + } + + static get styles() { + const base = css` + :host { display: block; } + ha-card { overflow: hidden; } + + .card-header { + display: flex; align-items: center; justify-content: space-between; + padding: 14px 18px; background: var(--taskmate-header-bg, #d35400); color: white; + } + .header-content { display: flex; align-items: center; gap: 10px; } + .header-icon { --mdc-icon-size: 28px; opacity: 0.9; } + .header-title { font-size: 1.2rem; font-weight: 600; } + .header-count { font-size: .85rem; font-weight: 700; background: rgba(255,255,255,.25); border-radius: 999px; padding: 3px 10px; } + + .card-content { padding: 14px; display: flex; flex-direction: column; gap: 14px; } + + .pb-child { border: 1px solid var(--divider-color, #e0e0e0); border-radius: 14px; overflow: hidden; + background: var(--card-background-color, #fff); } + .pb-child-head { display: flex; align-items: center; gap: 12px; padding: 12px 14px 8px; } + .pb-avatar { width: 40px; height: 40px; border-radius: 50%; display: flex; align-items: center; justify-content: center; + background: linear-gradient(135deg, #d35400, #f39c12); color: #fff; flex-shrink: 0; overflow: hidden; } + .pb-avatar ha-icon { --mdc-icon-size: 26px; color: white; } + .pb-face-img { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; } + .pb-name { font-weight: 600; font-size: 1rem; color: var(--primary-text-color); } + .pb-sub { font-size: .8rem; color: var(--secondary-text-color); } + + .pb-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 10px; padding: 6px 14px 14px; } + .pb-tile { position: relative; border-radius: 12px; padding: 10px 12px; background: var(--secondary-background-color, #f5f5f5); + display: flex; flex-direction: column; gap: 2px; min-height: 76px; } + .pb-tile.new { background: #fff3cd; box-shadow: 0 0 0 2px #f1c40f inset; } + .pb-tile.empty { opacity: .55; } + .pb-tile-k { display: flex; align-items: center; gap: 6px; font-size: .72rem; font-weight: 700; letter-spacing: .03em; + text-transform: uppercase; color: var(--secondary-text-color); min-width: 0; } + .pb-tile-k .pb-tile-label { flex: 1; min-width: 0; } + .pb-tile-k ha-icon { --mdc-icon-size: 16px; } + .pb-tile-v { font-size: 1.5rem; font-weight: 800; color: var(--primary-text-color); line-height: 1.1; } + .pb-tile-d { font-size: .75rem; color: var(--secondary-text-color); } + .pb-new-tag { flex: none; font-size: .65rem; font-weight: 800; background: #f1c40f; + color: #5a4300; border-radius: 999px; padding: 2px 7px; text-transform: uppercase; } + + .empty-state, .error-state { padding: 32px 16px; text-align: center; color: var(--secondary-text-color); } + .empty-state ha-icon, .error-state ha-icon { --mdc-icon-size: 40px; display: block; margin: 0 auto 8px; opacity: .6; } + + /* Designed layouts (shared kit in taskmate-design.js) */ + .pb-d-child { margin-bottom: 12px; } + .pb-d-child:last-child { margin-bottom: 0; } + .pb-d-head { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; } + .pb-d-name { font-family: var(--tmd-font-display); font-weight: 800; font-size: 15px; } + .pb-d-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 8px; } + .pb-d-tile { position: relative; border-radius: var(--tmd-radius-sm); padding: 10px 12px; background: var(--tmd-surface-2); + border: 1px solid var(--tmd-border); min-height: 72px; display: flex; flex-direction: column; gap: 2px; } + .pb-d-tile.new { border-color: var(--tmd-accent); box-shadow: 0 0 0 1px var(--tmd-accent) inset; } + .pb-d-tile.empty { opacity: .5; } + .pb-d-tile .stat { display: contents; } + .pb-d-date { font-size: 11px; color: var(--tmd-dim); } + .pb-d-tile .k { display: flex; align-items: flex-start; gap: 4px; } + .pb-d-tile .k .pb-d-label { flex: 1; min-width: 0; } + .pb-d-new { flex: none; padding: 2px 7px; font-size: 10px; } + :host([data-tm-design="playroom"]) .pb-d-tile { border-radius: 16px; border: 0; } + :host([data-tm-design="playroom"]) .pb-d-tile.new { background: color-mix(in srgb, var(--tmd-accent) 16%, var(--tmd-surface-2)); } + :host([data-tm-design="console"]) .pb-d-tile.new { box-shadow: 0 0 14px color-mix(in srgb, var(--tmd-accent) 45%, transparent); } + :host([data-tm-design="console"]) .pb-d-tile .v { font-variant-numeric: tabular-nums; } + :host([data-tm-design="graphite"]) .pb-d-tile { background: transparent; } + `; + const tokens = window.__taskmate_design && window.__taskmate_design.styles + ? window.__taskmate_design.styles() : null; + return tokens ? [tokens, base] : base; + } + + setConfig(config) { + if (!config.entity) throw new Error(this._t('bests.error.entity_required')); + this.config = { + title: null, + child_id: null, + header_color: '#d35400', + ...config, + }; + } + + getCardSize() { return 4; } + static getConfigElement() { return document.createElement("taskmate-personal-bests-card-editor"); } + static getStubConfig() { + return { entity: "sensor.taskmate_overview" }; + } + + /* ── data ─────────────────────────────────────────────────────────── */ + + _attrs(entity) { + return (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) || entity.attributes || {}; + } + + _children(attrs) { + let children = window.__taskmate_family_children + ? window.__taskmate_family_children(attrs.children || [], this.config) + : (attrs.children || []); + if (this.config.child_id) children = children.filter(c => c.id === this.config.child_id); + return children; + } + + /** The five tiles for a child, in a fixed order, empty ones included. */ + _tiles(child, pointsName) { + const bests = child.personal_bests || {}; + const fresh = new Set(Array.isArray(bests.new_today) ? bests.new_today : []); + return RECORDS.map((r) => { + const rec = bests[r.key]; + return { + ...r, + label: this._t(`bests.record_${r.key}`, { points_name: pointsName }), + value: rec ? rec.value : null, + date: rec && rec.date ? this._fmtDate(rec.date) : "", + fresh: fresh.has(r.key), + previous: rec && rec.previous != null ? rec.previous : null, + }; + }); + } + + _fmtDate(iso) { + const d = new Date(`${iso}T12:00:00`); + if (isNaN(d)) return iso; + return d.toLocaleDateString(this.hass?.locale?.language || undefined, { day: "numeric", month: "short" }); + } + + _freshCount(children) { + return children.reduce((n, c) => n + ((c.personal_bests && c.personal_bests.new_today) || []).length, 0); + } + + /* ── classic ──────────────────────────────────────────────────────── */ + + render() { + if (!this.hass || !this.config) return html``; + + const design = window.__taskmate_design + ? window.__taskmate_design.apply(this, this.hass, this.config, this.config.entity) + : "classic"; + if (design !== "classic") return this._renderDesigned(design); + + const entity = this.hass.states[this.config.entity]; + if (!entity) { + return html`
${this._t('common.entity_not_found', { entity: this.config.entity })}
`; + } + if (entity.state === "unavailable" || entity.state === "unknown") { + return html`
${this._t('common.unavailable')}
`; + } + + const attrs = this._attrs(entity); + const children = this._children(attrs); + const pointsName = attrs.points_name || this._t("common.points"); + const fresh = this._freshCount(children); + + return html` + + +
+
+ + ${this.config.title || this._t('bests.default_title')} +
+ ${fresh > 0 ? html`${this._t('bests.new_count', { count: fresh })}` : ""} +
+
+ ${children.length === 0 + ? html`
${this._t('common.no_children')}
` + : children.map((child) => this._renderChild(child, pointsName))} +
+
+ `; + } + + _renderChild(child, pointsName) { + const tiles = this._tiles(child, pointsName); + const any = tiles.some(t => t.value != null); + return html` +
+
+
${this._avatarGlyph(child)}
+
+
${child.name}
+
${any ? this._t('bests.subtitle') : this._t('bests.none_yet')}
+
+
+
+ ${tiles.map((t) => html` +
+
${t.label}${t.fresh ? html`${this._t('bests.new_tag')}` : ""}
+
${t.value == null ? "—" : t.value}
+
${t.fresh && t.previous != null + ? this._t('bests.beat_previous', { previous: t.previous }) + : (t.date || (t.value == null ? this._t('bests.not_yet') : ""))}
+
`)} +
+
`; + } + + /* ── designed (playroom / console / cleanpro / accessible / graphite) ── */ + + _designTone(i) { return `var(--tmd-c${(i % 6) + 1})`; } + + _av(child, tone, size) { + const face = window.__taskmate_child_visual(child); + const inner = face.kind === "icon" + ? html`` + : face.kind === "image" + ? html`${child.name || ''}` + : (child.name || "?").split(" ").map(w => w[0]).slice(0, 2).join("").toUpperCase(); + return html`
${inner}
`; + } + + _designHeader(hd, fresh) { + const title = this.config.title || this._t('bests.default_title'); + return html` +
+ 🏆 + ${title} + ${fresh > 0 ? html`${this._t('bests.new_count', { count: fresh })}` : ""} +
`; + } + + _renderDesigned(design) { + const entity = this.hass.states[this.config.entity]; + const hd = _safeColor(this.config.header_color, '#d35400'); + if (!entity) { + return html` + ${this._designHeader(hd, 0)} +
${this._t('common.entity_not_found', { entity: this.config.entity })}
+
`; + } + if (entity.state === "unavailable" || entity.state === "unknown") { + return html` + ${this._designHeader(hd, 0)} +
${this._t('common.unavailable')}
+
`; + } + const attrs = this._attrs(entity); + const children = this._children(attrs); + const pointsName = attrs.points_name || this._t("common.points"); + const fresh = this._freshCount(children); + const body = children.length === 0 + ? html`
${this._t('common.no_children')}
` + : children.map((child, i) => this._designChild(child, i, pointsName, design)); + return html`${this._designHeader(hd, fresh)}
${body}
`; + } + + _designChild(child, i, pointsName, design) { + const tone = this._designTone(i); + const tiles = this._tiles(child, pointsName); + const any = tiles.some(t => t.value != null); + const emoji = design === "playroom"; + return html` +
+
+ ${this._av(child, tone, 34)} +
+
${child.name}
+
${any ? this._t('bests.subtitle') : this._t('bests.none_yet')}
+
+
+
+ ${tiles.map((t) => html` +
+
${emoji ? html`${t.emoji} ` : html` `}${t.label}${t.fresh ? html`${this._t('bests.new_tag')}` : ""}
+
${t.value == null ? "—" : t.value}
+
${t.fresh && t.previous != null + ? this._t('bests.beat_previous', { previous: t.previous }) + : (t.date || (t.value == null ? this._t('bests.not_yet') : ""))}
+
`)} +
+
`; + } +} + +class TaskMatePersonalBestsCardEditor extends LitElement { + static get properties() { + return { hass: { type: Object }, config: { type: Object } }; + } + + _t(key, params) { + const fn = window.__taskmate_localize; + return fn ? fn(this.hass, key, params) : key; + } + + setConfig(config) { this.config = config; } + + _buildSchema() { + const entity = this.config?.entity ? this.hass?.states?.[this.config.entity] : null; + const children = entity?.attributes?.children || []; + return [ + { name: 'entity', selector: { entity: { domain: 'sensor' } } }, + { name: 'title', selector: { text: {} } }, + { + name: 'child_id', + selector: { + select: { + options: [ + { value: '__all__', label: this._t('common.editor.filter_by_child_all') }, + ...children.map((c) => ({ value: c.id, label: c.name })), + ], + mode: 'dropdown', + }, + }, + }, + { + name: 'card_design', + selector: { + select: { + options: window.__taskmate_design + ? window.__taskmate_design.editorOptions(this._t.bind(this)) + : [{ value: 'global', label: 'Use global default' }], + mode: 'dropdown', + }, + }, + }, + ]; + } + + _computeLabel = (entry) => { + const labels = { + show_adults: this._t('common.editor.show_adults'), + entity: this._t('common.editor.overview_entity'), + title: this._t('bests.editor.title'), + child_id: this._t('common.editor.filter_by_child'), + card_design: this._t('common.design.field_label'), + }; + return labels[entry.name] ?? entry.name; + }; + + _computeHelper = (entry) => { + const helpers = { + show_adults: this._t('common.editor.show_adults_helper'), + entity: this._t('common.editor.overview_entity_helper'), + child_id: this._t('bests.editor.child_helper'), + }; + return helpers[entry.name] ?? ''; + }; + + render() { + if (!this.hass || !this.config) return html``; + const data = { + entity: this.config.entity || '', + title: this.config.title || '', + child_id: this.config.child_id || '__all__', + card_design: this.config.card_design || 'global', + }; + return html` + + ${this._renderColourPicker('header_color', '#d35400')} + `; + } + + _renderColourPicker(key, defaultValue) { + const d = window.__taskmate_design; + const current = this.config[key] || defaultValue; + if (!d || !d.colourPicker) return html``; + return d.colourPicker({ + defaultValue, current, + label: this._t('common.editor.header_colour'), + helper: this._t('common.editor.header_colour_helper'), + resetLabel: this._t('common.reset'), + onInput: (v) => this._updateConfig(key, v), + onPreset: (v) => this._updateConfig(key, v), + onReset: () => this._updateConfig(key, defaultValue), + }); + } + + _formChanged(e) { + const newValues = e.detail.value || {}; + const newConfig = { ...this.config }; + for (const [key, value] of Object.entries(newValues)) { + if ( + value === '' || value === null || value === undefined + || (key === 'child_id' && value === '__all__') + || (key === 'card_design' && value === 'global') + ) { + delete newConfig[key]; + } else { + newConfig[key] = value; + } + } + this.dispatchEvent(new CustomEvent('config-changed', { + detail: { config: newConfig }, bubbles: true, composed: true, + })); + } + + _updateConfig(key, value) { + const newConfig = { ...this.config, [key]: value }; + if (value === null || value === "" || value === undefined) delete newConfig[key]; + this.dispatchEvent(new CustomEvent("config-changed", { + detail: { config: newConfig }, bubbles: true, composed: true, + })); + } +} + +customElements.define("taskmate-personal-bests-card", TaskMatePersonalBestsCard); +customElements.define("taskmate-personal-bests-card-editor", TaskMatePersonalBestsCardEditor); + +window.customCards = window.customCards || []; +window.customCards.push({ + type: "taskmate-personal-bests-card", + name: "TaskMate Personal Bests", + description: "Each child's own records: longest streak, best day, best week and biggest award", + preview: true, + getEntitySuggestion: (hass, entityId) => + window.__taskmate_suggest ? window.__taskmate_suggest(hass, entityId, "taskmate-personal-bests-card", "overview") : undefined, +}); diff --git a/mkdocs.yml b/mkdocs.yml index 73dc39aa..5e36212d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -109,6 +109,7 @@ nav: - "Bounty Board": "Bounty-Board.md" - "Wishlist": "Wishlist.md" - "Raffles": "Raffles.md" + - "Reward Fulfilment": "Reward-Fulfilment.md" - "Gamification": - "Levels & XP": "Levels-and-XP.md" - "Achievement Badges": "Achievement-Badges.md" @@ -122,6 +123,7 @@ nav: - "Recaps": "Recaps.md" - "Virtual Pets": "Virtual-Pets.md" - "Weekly Goals": "Weekly-Goals.md" + - "Personal Bests": "Personal-Bests.md" - "Notifications & Admin": - "Notifications": "Notifications.md" - "Quiet Hours": "Quiet-Hours.md" diff --git a/tests/frontend/taskmate-fulfilment-bests.test.cjs b/tests/frontend/taskmate-fulfilment-bests.test.cjs new file mode 100644 index 00000000..fe3fbe42 --- /dev/null +++ b/tests/frontend/taskmate-fulfilment-bests.test.cjs @@ -0,0 +1,253 @@ +const assert = require("node:assert/strict"); +const { test } = require("node:test"); + +const { loadCard, render, localize } = require("./harness.cjs"); + +// Reward fulfilment (#1098) and personal bests (#1099), on every design +// (two-render-paths rule): the approvals card's "still to hand over" list, +// the child card's "you're owed" strip and new-record banner, the Personal +// Bests card, and the panel's Today page card. + +const DESIGNS = ["classic", "playroom", "console", "cleanpro", "accessible", "graphite"]; +const ENTITY = "sensor.taskmate_overview"; +// Objects built inside the vm sandbox have foreign prototypes; compare by value. +const plain = (value) => JSON.parse(JSON.stringify(value)); + +const owed = (over = {}) => ({ + claim_id: "c1", child_id: "kid1", child_name: "Mia", reward_id: "r1", reward_name: "Movie night", + reward_icon: "mdi:movie", cost: 40, approved_at: "2026-10-15T10:00:00+00:00", days_owed: 3, ...over, +}); + +// ── approvals card ──────────────────────────────────────────────────────── + +const ApprovalsCard = loadCard("taskmate-approvals-card.js").get("taskmate-approvals-card"); + +function approvalsCard({ design = "classic", owedList = [owed()], claims = [], childId } = {}) { + const card = new ApprovalsCard(); + card.setConfig({ entity: ENTITY, card_design: design, ...(childId ? { child_id: childId } : {}) }); + const calls = []; + card.hass = { + states: { + [ENTITY]: { + state: "1", + attributes: { + children: [{ id: "kid1", name: "Mia" }, { id: "kid2", name: "Leo" }], + chore_completions: [], + reward_claims: claims, + mandatory_misses: [], + owed_rewards: owedList, + points_icon: "mdi:star", + }, + }, + }, + callService: async (domain, service, data) => { calls.push({ domain, service, data }); }, + }; + card._loading = {}; + return { card, calls }; +} + +for (const design of DESIGNS) { + test(`approvals: ${design} lists an owed reward with a hand-over button`, async () => { + const { card, calls } = approvalsCard({ design }); + const out = render(card.render()); + assert.ok(out.markup.includes("Movie night")); + assert.ok(out.markup.includes(localize("approvals.owed_section"))); + assert.ok(out.markup.includes(localize("approvals.owed_days", { count: 3 }))); + const btn = out.control(localize("approvals.owed_delivered")); + assert.ok(btn, "a Handed over button is drawn"); + await btn.click(); + assert.deepEqual(plain(calls), [{ domain: "taskmate", service: "mark_reward_delivered", data: { claim_id: "c1" } }]); + }); + + test(`approvals: ${design} owed rewards do not count as pending`, () => { + const { card } = approvalsCard({ design }); + const { markup } = render(card.render()); + assert.doesNotMatch(markup, /class="(pending-count|cnt)"/, "no header badge for owed-only"); + assert.ok(markup.includes("Movie night"), "but the owed list itself is still drawn"); + }); + + test(`approvals: ${design} hides owed rewards for other children when filtered`, () => { + const { card } = approvalsCard({ design, childId: "kid2" }); + const { markup } = render(card.render()); + assert.ok(!markup.includes("Movie night")); + }); +} + +test("approvals: classic says redeemed today for a fresh one", () => { + const { card } = approvalsCard({ owedList: [owed({ days_owed: 0 })] }); + assert.ok(render(card.render()).markup.includes(localize("approvals.owed_today"))); +}); + +test("approvals: nothing owed and nothing pending is the usual empty state", () => { + const { card } = approvalsCard({ owedList: [] }); + const { markup } = render(card.render()); + assert.ok(markup.includes(localize("approvals.no_pending_approvals")) || markup.includes(localize("approvals.all_caught_up"))); + assert.ok(!markup.includes(localize("approvals.owed_section"))); +}); + +// ── child card ──────────────────────────────────────────────────────────── + +const ChildCard = loadCard("taskmate-child-card.js").get("taskmate-child-card"); + +function childCard({ design = "classic", child = {}, config = {} } = {}) { + const card = new ChildCard(); + card.setConfig({ entity: ENTITY, child_id: "kid1", card_design: design, ...config }); + const calls = []; + card.hass = { + states: { + [ENTITY]: { + state: "ok", + attributes: { + children: [{ id: "kid1", name: "Mia", chore_order: [], ...child }], + chores: [{ id: "tidy", name: "Tidy room", points: 5, time_category: "anytime", assigned_to: [], due_days: [], + schedule_mode: "specific_days", assignment_mode: "everyone" }], + todays_completions: [], + chore_availability: {}, + today_day_of_week: "wednesday", + points_icon: "mdi:star", + points_name: "Stars", + }, + }, + }, + callService: async (domain, service, data) => { calls.push({ domain, service, data }); }, + }; + card._loading = {}; + card._optimisticCompletions = {}; + card._playSound = () => {}; + card._spawnConfetti = () => {}; + return { card, calls }; +} + +const OWED_CHILD = { owed_rewards: [{ claim_id: "c1", reward_name: "Movie night", reward_icon: "mdi:movie", days_owed: 2 }] }; +const RECORD_CHILD = { + personal_bests: { + best_day_points: { value: 25, date: "2026-10-18", previous: 20 }, + longest_streak: { value: 9, date: "2026-10-10" }, + new_today: ["best_day_points"], + }, +}; + +for (const design of DESIGNS) { + test(`child: ${design} shows what is owed and "Got it!" confirms as the child`, async () => { + const { card, calls } = childCard({ design, child: OWED_CHILD }); + const out = render(card.render()); + assert.ok(out.markup.includes(localize("child.owed_title"))); + assert.ok(out.markup.includes("Movie night")); + assert.ok(out.markup.includes(localize("child.owed_days", { count: 2 }))); + const btn = out.control(localize("child.owed_got_it")); + assert.ok(btn, "Got it! is drawn"); + await btn.click(); + assert.deepEqual(plain(calls), [{ domain: "taskmate", service: "mark_reward_delivered", data: { claim_id: "c1", child_id: "kid1" } }]); + }); + + test(`child: ${design} celebrates today's record and names it with the family's points`, () => { + const { card } = childCard({ design, child: RECORD_CHILD }); + const { markup } = render(card.render()); + assert.ok(markup.includes(localize("child.record_title"))); + assert.ok(markup.includes(`${localize("child.record_best_day_points", { points_name: "Stars" })}: 25`)); + assert.ok(!markup.includes(localize("child.record_longest_streak")), "only today's records are announced"); + }); + + test(`child: ${design} stays quiet with no owed reward or new record`, () => { + const { card } = childCard({ design, child: { personal_bests: { longest_streak: { value: 9, date: "" } } } }); + const { markup } = render(card.render()); + assert.ok(!markup.includes(localize("child.owed_title"))); + assert.ok(!markup.includes(localize("child.record_title"))); + }); +} + +test("child: the record banner can be switched off per card", () => { + const { card } = childCard({ child: RECORD_CHILD, config: { show_personal_bests: false } }); + assert.ok(!render(card.render()).markup.includes(localize("child.record_title"))); +}); + +// ── personal bests card ─────────────────────────────────────────────────── + +const BestsCard = loadCard("taskmate-personal-bests-card.js").get("taskmate-personal-bests-card"); + +function bestsCard({ design = "classic", children, config = {} } = {}) { + const card = new BestsCard(); + card.setConfig({ entity: ENTITY, card_design: design, ...config }); + card.hass = { + states: { + [ENTITY]: { + state: "ok", + attributes: { + points_name: "Stars", + children: children || [ + { id: "kid1", name: "Mia", ...RECORD_CHILD }, + { id: "kid2", name: "Leo" }, + ], + }, + }, + }, + }; + return card; +} + +for (const design of DESIGNS) { + test(`bests card: ${design} draws every record tile, marks today's and shows the empty child`, () => { + const { markup } = render(bestsCard({ design }).render()); + for (const key of ["longest_streak", "best_day_points", "best_day_chores", "best_week_points", "best_single_award"]) { + assert.ok(markup.includes(localize(`bests.record_${key}`, { points_name: "Stars" })), key); + } + assert.ok(markup.includes(">25<") || markup.includes(">25 { + const { markup } = render(bestsCard({ design, config: { child_id: "kid2" } }).render()); + assert.ok(markup.includes("Leo") && !markup.includes("Mia")); + assert.ok(!markup.includes(localize("bests.new_tag"))); + }); +} + +test("bests card: the stub config points at the overview and the editor exists", () => { + assert.equal(BestsCard.getStubConfig().entity, ENTITY); + assert.throws(() => new BestsCard().setConfig({}), /entity/i); +}); + +// ── admin panel Today page ──────────────────────────────────────────────── + +const TaskMatePanel = loadCard("taskmate-panel.js").get("taskmate-panel"); + +function panel(extra = {}) { + const p = Object.create(TaskMatePanel.prototype); + p._t = (key, params = {}) => (params.count != null ? `${key}:${params.count}` : key); + p._timeAgo = () => "just now"; + p._activeTab = "today"; + p._state = { + children: [{ id: "kid1", name: "Malia", points: 10 }, { id: "kid2", name: "Vaiha", points: 5 }], + chores: [], rewards: [], pending_completions: [], pending_reward_claims: [], swap_requests: [], wishes: [], + settings: { points_name: "Stars" }, + today: { board: { date: "2026-10-18", children: [] }, history: {} }, + owed_rewards: [owed({ child_id: "kid2", child_name: "Vaiha" })], + ...extra, + }; + return p; +} + +test("panel: the Today page lists owed rewards with a hand-over button", () => { + const html = panel()._renderTodayTab(); + assert.match(html, /panel\.today_owed_title/); + assert.match(html, /Vaiha/); + assert.match(html, /Movie night/); + assert.match(html, /panel\.today_owed_days:3/); + assert.match(html, /data-act="reward-delivered" data-id="c1"/); +}); + +test("panel: the owed card follows the child filter and disappears when empty", () => { + const p = panel(); + assert.equal(p._renderOwedCard([{ id: "kid1" }]), ""); + assert.equal(panel({ owed_rewards: [] })._renderOwedCard(p._state.children), ""); +}); + +test("panel: the settings page offers the fulfilment switch and the reminder days", () => { + const src = require("node:fs").readFileSync( + require("node:path").join(__dirname, "../../custom_components/taskmate/www/taskmate-panel.js"), "utf8"); + assert.match(src, /data-setting="reward_fulfilment_enabled"/); + assert.match(src, /data-setting="reward_fulfilment_reminder_days"/); +}); diff --git a/tests/test_personal_bests.py b/tests/test_personal_bests.py new file mode 100644 index 00000000..31f72a99 --- /dev/null +++ b/tests/test_personal_bests.py @@ -0,0 +1,170 @@ +"""Personal bests (#1099): a child's own records, and the moment they beat one.""" + +from __future__ import annotations + +import datetime as dt +from unittest.mock import AsyncMock, patch + +from .test_completion_eligibility import _now, run +from .test_family_care import _at, _family, _fired + +BESTS = "custom_components.taskmate.coord_bests.dt_util" +CHORES = "custom_components.taskmate.coord_chores.dt_util" +POINTS = "custom_components.taskmate.coord_points.dt_util" + + +def _clock(mod, when=None): + when = when or _now() + import contextlib + + stack = contextlib.ExitStack() + for ctx in ( + _at(mod, when), + patch(f"{BESTS}.now", return_value=when), + patch(f"{CHORES}.now", return_value=when), + patch(f"{POINTS}.now", return_value=when), + ): + stack.enter_context(ctx) + return stack + + +def _setup(**chore_fields): + coord, storage, mod, kid, chore = _family(requires_approval=False, daily_limit=5, **chore_fields) + coord._celebrate = AsyncMock() + coord.badges = None + return coord, storage, mod, kid, chore + + +def _do(coord, chore, kid): + return run(coord.async_complete_chore(chore.id, kid.id)) + + +class TestRecords: + def test_first_values_are_stored_quietly_as_the_baseline(self): + coord, storage, mod, kid, chore = _setup() + with _clock(mod): + _do(coord, chore, kid) + state = coord.personal_bests_state(storage.get_child(kid.id)) + assert state["best_day_points"]["value"] == 5 + assert state["best_day_chores"]["value"] == 1 + assert state["best_week_points"]["value"] == 5 + assert state["best_single_award"]["value"] == 5 + assert state["longest_streak"]["value"] == 1 + assert state["best_day_points"]["date"] == "2024-03-20" + assert state["best_week_points"]["date"] == "2024-03-18" + assert state.get("new_today", []) == [] + assert _fired(coord, "taskmate_personal_best") == [] + coord.notifications.fire.assert_not_called() + coord._celebrate.assert_not_called() + + def test_beating_a_record_celebrates_once(self): + coord, storage, mod, kid, chore = _setup() + with _clock(mod): + _do(coord, chore, kid) + coord.notifications.fire.reset_mock() + tomorrow = _now() + dt.timedelta(days=1) + with _clock(mod, tomorrow): + _do(coord, chore, kid) + # Day one again (5 pts / 1 chore) ties the day records; the week total + # and the streak are genuinely longer than before. + events = _fired(coord, "taskmate_personal_best") + assert {e["record"] for e in events} == {"longest_streak", "best_week_points"} + coord.hass.bus.async_fire.reset_mock() + coord.notifications.fire.reset_mock() + coord._celebrate.reset_mock() + with _clock(mod, tomorrow + dt.timedelta(hours=1)): + _do(coord, chore, kid) # day: 10 pts / 2 chores — beats both, and the week again + events = _fired(coord, "taskmate_personal_best") + assert {e["record"] for e in events} == {"best_day_points", "best_day_chores", "best_week_points"} + day_pts = next(e for e in events if e["record"] == "best_day_points") + assert day_pts["value"] == 10 and day_pts["previous"] == 5 and day_pts["child_id"] == kid.id + fired_types = [c.args[0] for c in coord.notifications.fire.call_args_list] + assert fired_types.count("personal_best") == 3 + ctx = next(c.args[1] for c in coord.notifications.fire.call_args_list if c.args[0] == "personal_best") + assert ctx["child_name"] == "Vaiha" and ctx["record_label"] and ctx["value"] + assert coord._celebrate.await_count == 3 + assert coord._celebrate.await_args.kwargs["tier"] == 2 + assert coord._celebrate.await_args.args[1] == "personal_best" + with _clock(mod, tomorrow + dt.timedelta(hours=1)): + state = coord.personal_bests_state(storage.get_child(kid.id)) + assert sorted(state["new_today"]) == [ + "best_day_chores", + "best_day_points", + "best_week_points", + "longest_streak", + ] + assert state["best_day_points"] == {"value": 10, "date": "2024-03-21", "previous": 5} + assert state["longest_streak"] == {"value": 2, "date": "2024-03-21", "previous": 1} + # Ties never re-celebrate. + coord.hass.bus.async_fire.reset_mock() + with _clock(mod, tomorrow + dt.timedelta(hours=2)): + run(coord.async_check_personal_bests(kid.id)) + assert _fired(coord, "taskmate_personal_best") == [] + + def test_streak_record_seeds_from_best_streak_and_beats_it(self): + coord, storage, mod, kid, chore = _setup() + kid.best_streak = 7 + storage.update_child(kid) + with _clock(mod): + _do(coord, chore, kid) + state = coord.personal_bests_state(storage.get_child(kid.id)) + assert state["longest_streak"] == {"value": 7, "date": ""} + assert "longest_streak" not in {e["record"] for e in _fired(coord, "taskmate_personal_best")} + # Walk the streak up to 8 days. + for i in range(1, 8): + with _clock(mod, _now() + dt.timedelta(days=i)): + _do(coord, chore, kid) + streak_events = [e for e in _fired(coord, "taskmate_personal_best") if e["record"] == "longest_streak"] + assert [e["value"] for e in streak_events] == [8] + assert streak_events[0]["previous"] == 7 + + def test_no_state_until_there_is_something_to_show(self): + coord, storage, _mod, kid, _chore = _setup() + assert coord.personal_bests_state(storage.get_child(kid.id)) == {} + + +class TestReversals: + def test_undoing_the_record_day_pulls_the_record_back(self): + coord, storage, mod, kid, chore = _setup() + storage.set_setting("chore_undo_seconds", 600) + with _clock(mod): + _do(coord, chore, kid) + day2 = _now() + dt.timedelta(days=1) + with _clock(mod, day2): + _do(coord, chore, kid) + with _clock(mod, day2 + dt.timedelta(hours=1)): + comp = _do(coord, chore, kid) + assert coord.personal_bests_state(storage.get_child(kid.id))["best_day_points"]["value"] == 10 + with _clock(mod, day2 + dt.timedelta(hours=1, minutes=1)): + run(coord.async_undo_chore(comp.id)) + state = coord.personal_bests_state(storage.get_child(kid.id)) + # Day 2 is back to 5, which ties day 1: the record falls to 5 and stops being "new today". + assert state["best_day_points"]["value"] == 5 + assert state["best_day_chores"]["value"] == 1 + assert not {"best_day_points", "best_day_chores"} & set(state.get("new_today", [])) + # The streak (2 days) still stands: one completion is left on day 2. + assert state["longest_streak"]["value"] == 2 + + def test_rejecting_an_approved_record_chore_recomputes_too(self): + coord, storage, mod, kid, chore = _setup() + chore.requires_approval = True + storage.update_chore(chore) + with _clock(mod): + c1 = _do(coord, chore, kid) + run(coord.async_approve_chore(c1.id)) + c2 = _do(coord, chore, kid) + run(coord.async_approve_chore(c2.id)) + assert coord.personal_bests_state(storage.get_child(kid.id))["best_day_points"]["value"] == 10 + with _clock(mod): + run(coord.async_reject_chore(c2.id)) + assert coord.personal_bests_state(storage.get_child(kid.id))["best_day_points"]["value"] == 5 + + def test_a_record_older_than_kept_history_is_left_alone(self): + coord, storage, mod, kid, chore = _setup() + storage.get_section("personal_bests", dict)[kid.id] = { + "best_day_points": {"value": 99, "date": "2020-01-01", "set_at": "2020-01-01T10:00:00+00:00"} + } + with _clock(mod): + _do(coord, chore, kid) + run(coord.async_recompute_personal_bests(kid.id)) + assert coord.personal_bests_state(storage.get_child(kid.id))["best_day_points"]["value"] == 99 diff --git a/tests/test_reward_fulfilment.py b/tests/test_reward_fulfilment.py new file mode 100644 index 00000000..41cfef33 --- /dev/null +++ b/tests/test_reward_fulfilment.py @@ -0,0 +1,184 @@ +"""Reward fulfilment tracker (#1098): an approved reward stays owed until it is +handed over.""" + +from __future__ import annotations + +import datetime as dt +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from custom_components.taskmate.models import Reward, RewardClaim + +from .test_completion_eligibility import _make_system, _now, run + +FUL = "custom_components.taskmate.coord_fulfilment.dt_util" + + +def _system(enabled=True, days=3): + coord, storage, mod = _make_system() + coord.notifications = AsyncMock() + coord.notifications._has_outstanding_chores_today = lambda _cid: True + coord.hass.bus = MagicMock() + coord.badges = None + coord.async_start_unlock = AsyncMock() + if enabled: + storage.set_setting("reward_fulfilment_enabled", True) + storage.set_setting("reward_fulfilment_reminder_days", days) + with patch.object(mod.dt_util, "now", return_value=_now()): + kid = run(coord.async_add_child("Vaiha")) + reward = run(coord.async_add_reward("Movie night", cost=10)) + kid.points = 100 + storage.update_child(kid) + return coord, storage, mod, kid, reward + + +def _claim_and_approve(coord, storage, mod, kid, reward, when=None): + when = when or _now() + with ( + patch.object(mod.dt_util, "now", return_value=when), + patch("custom_components.taskmate.coord_rewards.dt_util.now", return_value=when), + patch(f"{FUL}.now", return_value=when), + ): + claim = run(coord.async_claim_reward(reward.id, kid.id)) + run(coord.async_approve_reward(claim.id)) + return next(c for c in storage.get_reward_claims() if c.id == claim.id) + + +def _fired(coord, name): + return [c.args[1] for c in coord.hass.bus.async_fire.call_args_list if c.args[0] == name] + + +class TestOwed: + def test_approving_while_on_puts_the_reward_in_the_owed_list(self): + coord, storage, mod, kid, reward = _system() + claim = _claim_and_approve(coord, storage, mod, kid, reward) + assert claim.needs_delivery is True and claim.delivered_at is None + with patch(f"{FUL}.now", return_value=_now()): + owed = coord.owed_rewards() + assert [o["claim_id"] for o in owed] == [claim.id] + assert owed[0]["reward_name"] == "Movie night" and owed[0]["child_id"] == kid.id + assert owed[0]["days_owed"] == 0 + with patch(f"{FUL}.now", return_value=_now()): + assert [o["claim_id"] for o in coord.owed_rewards(kid.id)] == [claim.id] + assert coord.owed_rewards("someone-else") == [] + + def test_switched_off_means_nothing_is_tracked_or_shown(self): + coord, storage, mod, kid, reward = _system(enabled=False) + claim = _claim_and_approve(coord, storage, mod, kid, reward) + assert claim.needs_delivery is False + assert coord.owed_rewards() == [] + # Turning it on later does not back-fill old purchases. + storage.set_setting("reward_fulfilment_enabled", True) + assert coord.owed_rewards() == [] + + def test_rewards_the_system_hands_over_itself_are_not_tracked(self): + coord, storage, mod, kid, reward = _system() + reward.streak_freeze = True + storage.update_reward(reward) + claim = _claim_and_approve(coord, storage, mod, kid, reward) + assert claim.needs_delivery is False + unlock = Reward(name="TV", cost=5, unlock_entity="switch.tv") + storage.add_reward(unlock) + claim = _claim_and_approve(coord, storage, mod, kid, unlock) + assert claim.needs_delivery is False + + def test_marking_delivered_clears_it_and_fires_an_event(self): + coord, storage, mod, kid, reward = _system() + claim = _claim_and_approve(coord, storage, mod, kid, reward) + later = _now() + dt.timedelta(days=1) + with patch(f"{FUL}.now", return_value=later): + run(coord.async_mark_reward_delivered(claim.id)) + assert coord.owed_rewards() == [] + stored = next(c for c in storage.get_reward_claims() if c.id == claim.id) + assert stored.delivered_at == later and stored.delivered_by == "parent" + (evt,) = _fired(coord, "taskmate_reward_delivered") + assert evt["claim_id"] == claim.id and evt["child_id"] == kid.id and evt["reward_name"] == "Movie night" + assert evt["delivered_by"] == "parent" + + def test_child_can_confirm_their_own_reward_only(self): + coord, storage, mod, kid, reward = _system() + claim = _claim_and_approve(coord, storage, mod, kid, reward) + with pytest.raises(ValueError, match="not .*Vaiha|someone else|belong"): + run(coord.async_mark_reward_delivered(claim.id, by="child", child_id="other")) + with patch(f"{FUL}.now", return_value=_now()): + run(coord.async_mark_reward_delivered(claim.id, by="child", child_id=kid.id)) + stored = next(c for c in storage.get_reward_claims() if c.id == claim.id) + assert stored.delivered_by == "child" + + def test_marking_twice_or_an_unknown_claim_is_refused(self): + coord, storage, mod, kid, reward = _system() + claim = _claim_and_approve(coord, storage, mod, kid, reward) + with patch(f"{FUL}.now", return_value=_now()): + run(coord.async_mark_reward_delivered(claim.id)) + with pytest.raises(ValueError): + run(coord.async_mark_reward_delivered(claim.id)) + with pytest.raises(ValueError): + run(coord.async_mark_reward_delivered("nope")) + + def test_old_claims_load_with_safe_defaults_and_shape_is_unchanged(self): + old = RewardClaim.from_dict({"reward_id": "r", "child_id": "c", "claimed_at": _now().isoformat()}) + assert old.needs_delivery is False and old.delivered_at is None and old.delivered_by == "" + assert "needs_delivery" not in old.to_dict() and "delivered_at" not in old.to_dict() + old.needs_delivery = True + old.delivered_at = _now() + old.delivered_by = "child" + again = RewardClaim.from_dict(old.to_dict()) + assert again.needs_delivery is True and again.delivered_at == _now() and again.delivered_by == "child" + + +class TestReminder: + def test_parents_are_nagged_after_n_days_and_then_every_n_days(self): + coord, storage, mod, kid, reward = _system(days=3) + claim = _claim_and_approve(coord, storage, mod, kid, reward) + coord.notifications.fire.reset_mock() + with patch(f"{FUL}.now", return_value=_now() + dt.timedelta(days=2, hours=23)): + assert run(coord.async_check_owed_rewards()) == 0 + coord.notifications.fire.assert_not_called() + with patch(f"{FUL}.now", return_value=_now() + dt.timedelta(days=3, minutes=1)): + assert run(coord.async_check_owed_rewards()) == 1 + # Same minute again: nothing. + assert run(coord.async_check_owed_rewards()) == 0 + type_id, ctx = coord.notifications.fire.call_args.args + assert type_id == "reward_owed" + assert ctx["child_name"] == "Vaiha" and ctx["reward_name"] == "Movie night" and ctx["days"] == 3 + assert ctx["claim_id"] == claim.id + with patch(f"{FUL}.now", return_value=_now() + dt.timedelta(days=5)): + assert run(coord.async_check_owed_rewards()) == 0 + with patch(f"{FUL}.now", return_value=_now() + dt.timedelta(days=6, minutes=1)): + assert run(coord.async_check_owed_rewards()) == 1 + assert coord.notifications.fire.call_args.args[1]["days"] == 6 + + def test_zero_days_or_feature_off_sends_nothing(self): + coord, storage, mod, kid, reward = _system(days=0) + _claim_and_approve(coord, storage, mod, kid, reward) + coord.notifications.fire.reset_mock() + with patch(f"{FUL}.now", return_value=_now() + dt.timedelta(days=30)): + assert run(coord.async_check_owed_rewards()) == 0 + storage.set_setting("reward_fulfilment_reminder_days", 1) + storage.set_setting("reward_fulfilment_enabled", False) + with patch(f"{FUL}.now", return_value=_now() + dt.timedelta(days=30)): + assert run(coord.async_check_owed_rewards()) == 0 + coord.notifications.fire.assert_not_called() + + def test_delivered_rewards_are_never_nagged(self): + coord, storage, mod, kid, reward = _system(days=1) + claim = _claim_and_approve(coord, storage, mod, kid, reward) + with patch(f"{FUL}.now", return_value=_now()): + run(coord.async_mark_reward_delivered(claim.id)) + coord.notifications.fire.reset_mock() + with patch(f"{FUL}.now", return_value=_now() + dt.timedelta(days=9)): + assert run(coord.async_check_owed_rewards()) == 0 + coord.notifications.fire.assert_not_called() + + +class TestSettings: + def test_reminder_days_are_clamped_and_default_to_three(self): + coord, storage, _mod, _kid, _reward = _system() + assert coord.reward_fulfilment_reminder_days() == 3 + storage.set_setting("reward_fulfilment_reminder_days", "bogus") + assert coord.reward_fulfilment_reminder_days() == 0 + storage.set_setting("reward_fulfilment_reminder_days", -4) + assert coord.reward_fulfilment_reminder_days() == 0 + storage.set_setting("reward_fulfilment_enabled", "true") + assert coord.reward_fulfilment_enabled() is True diff --git a/wiki/Automations.md b/wiki/Automations.md index 00d023e8..450ab4f1 100644 --- a/wiki/Automations.md +++ b/wiki/Automations.md @@ -254,6 +254,9 @@ All auction events carry `auction_id`, `chore_id`, `chore_name`, `occurrence` (t | `taskmate_penalty_redeemed` | `child_id`, `child_name`, `penalty_name`, `chore_name`, `points`, `points_name`, `recipients` (no `timestamp`) — a child did the [make-it-right](Make-It-Right-Penalties.md) chore and earned `points` back | | `taskmate_raffle_won` | `child_id`, `child_name`, `raffle_id`, `raffle_name`, `prize`, `tickets`, `timestamp` — the winner of a [raffle](Raffles.md); `tickets` is how many they held | | `taskmate_raffle_drawn` | `raffle_id`, `raffle_name`, `prize`, `winner_id`, `winner_name`, `tickets`, `recipients` (no `timestamp`) — a raffle was drawn; `tickets` is the total in the draw. Fires even when nobody entered (`winner_id` empty) | +| `taskmate_reward_delivered` | `child_id`, `child_name`, `reward_id`, `reward_name`, `claim_id`, `delivered_by` (`parent` / `child`), `timestamp` — (v7.1.0) a redeemed reward was handed over. See [Reward Fulfilment](Reward-Fulfilment.md) | +| `taskmate_reward_owed` | `child_id`, `child_name`, `reward_id`, `reward_name`, `claim_id`, `days`, `points_name`, `recipients` (no `timestamp`) — (v7.1.0) the reminder that a reward is still owed went out | +| `taskmate_personal_best` | `child_id`, `child_name`, `record`, `value`, `previous`, `date`, `timestamp` — (v7.1.0) a child beat one of their own records. `record` is `longest_streak`, `best_day_points`, `best_day_chores`, `best_week_points` or `best_single_award`. See [Personal Bests](Personal-Bests.md). The notification's own event is `taskmate_personal_best_notification` | | `taskmate_pet_evolved` | `child_id`, `child_name`, `pet_name`, `species`, `kind`, `news`, `recipients` (no `timestamp`), plus `pet_level` when `kind` is `level` — a [virtual pet](Virtual-Pets.md) reached something new. `kind` is `stage`, `legendary`, `egg` or `level`; `news` is the ready-made sentence | The events with a `recipients` list come from the notification system and fire whether or not the matching notification is switched on (`recipients` is then empty). See [Notifications](Notifications.md#bus-events). diff --git a/wiki/Celebrations.md b/wiki/Celebrations.md index defdc6b3..e95410c5 100644 --- a/wiki/Celebrations.md +++ b/wiki/Celebrations.md @@ -18,6 +18,7 @@ The following moments fire a celebration, each with a tier set by how big a deal | **Quest completed** | 3 (epic) | See [Quests](Quests.md) | | **Challenge completed** | 2 (medium) | See [Challenges](Challenges.md) | | **Weekly target reached** | 2 (medium) | v6.2.0. Only on chores with a weekly target bonus. See [Weekly Targets](Weekly-Targets.md#weekly-target-bonus) | +| **Personal best** | 2 (medium) | v7.1.0. A child beat one of their own records. See [Personal Bests](Personal-Bests.md) | --- @@ -43,12 +44,12 @@ Every celebration fires a single `taskmate_celebration` bus event. This is the i |-------|-------------| | `child_id` | The child the celebration is for | | `child_name` | The child's name | -| `kind` | The moment type: `level_up`, `streak_milestone`, `perfect_week`, `quest_completed`, `challenge_completed`, `weekly_target_reached` | +| `kind` | The moment type: `level_up`, `streak_milestone`, `perfect_week`, `quest_completed`, `challenge_completed`, `weekly_target_reached`, `personal_best` | | `tier` | `1` (small), `2` (medium), or `3` (epic) | | `message` | A ready-made human-readable message | | `timestamp` | ISO timestamp | -Some kinds add extras, e.g. `level`, `days`, `bonus`, or `quest_id` / `challenge_id` / `chore_id`, depending on the moment. +Some kinds add extras, e.g. `level`, `days`, `bonus`, `quest_id` / `challenge_id` / `chore_id`, or `record` / `value` / `previous` for a personal best, depending on the moment. Since v6.2.0 the `message` is written in Home Assistant's language (**Settings → System → General**), so a TTS automation that speaks it talks in your language too. See [Localization](Localization.md#push-notifications). diff --git a/wiki/Dashboard-Cards.md b/wiki/Dashboard-Cards.md index 251dcedb..c19f5103 100644 --- a/wiki/Dashboard-Cards.md +++ b/wiki/Dashboard-Cards.md @@ -1,6 +1,6 @@ # Dashboard Cards -TaskMate includes 30 Lovelace cards. They are automatically registered as resources when the integration loads. +TaskMate includes 31 Lovelace cards. They are automatically registered as resources when the integration loads. All cards support a `header_color` config option — a hex colour for the card header background. Each card has its own vibrant default. @@ -54,6 +54,7 @@ Everything else is optional and can be added later as you explore: | Sticker Chart Card | A young child's whole day as stickers to tap, with stars filling towards a reward | | Pet Card | A virtual pet that hatches and grows as the child does chores | | Raffle Card | Children buy raffle tickets with points, and watch the draw | +| Personal Bests Card | A child's own records — longest streak, best day, best week, biggest award — with today's new ones marked | --- @@ -1010,6 +1011,27 @@ header_color: "#8e44ad" --- +## Personal Bests Card + +**(v7.1.0)** **Purpose:** A child's [personal bests](Personal-Bests.md): longest streak, most points in a day and in a week, most chores in a day and the biggest single award, each with the day it was set. A record beaten today is marked **New** and shows what it beat. Without a `child_id` every child gets a block. + +**Default header colour:** Burnt orange `#d35400` + +```yaml +type: custom:taskmate-personal-bests-card +entity: sensor.taskmate_overview +child_id: a8c8376a # optional — leave out to show every child +title: My records # optional — default: Personal Bests +header_color: "#d35400" +``` + +**Behaviour:** +- One tile per record; a child with none yet reads *No records yet — the first chores set the bar* +- The header counts how many records were beaten today +- Available in every [design style](Card-Design-Styles.md) + +--- + ## Completion Sounds The child card plays a sound when a chore is ticked off. All synth sounds use the Web Audio API — no external files needed. diff --git a/wiki/Event-Entities.md b/wiki/Event-Entities.md index 4b3a3a8c..f55f744c 100644 --- a/wiki/Event-Entities.md +++ b/wiki/Event-Entities.md @@ -43,7 +43,8 @@ The entity's state is the time of the latest event. Its `event_type` attribute s | `streak_freeze_used` | Streak freeze used | `penalty_redeemed` | Penalty earned back | | `perfect_week` | Perfect week | `raffle_won` | Raffle won | | `weekly_target_reached` | Weekly target reached | `pet_evolved` | Pet grew up | -| `quest_completed` | Quest completed | | | +| `quest_completed` | Quest completed | `reward_delivered` | Reward handed over | +| `personal_best` | Personal best | | | An event about more than one child lands on each of their entities: a [gift of points](Gifting-Points.md) on both the giver's and the receiver's, a [teamwork chore](Teamwork-Chores.md) on everyone in the team. diff --git a/wiki/Notifications.md b/wiki/Notifications.md index c93e9120..d7ab8bcd 100644 --- a/wiki/Notifications.md +++ b/wiki/Notifications.md @@ -66,6 +66,8 @@ TaskMate ships with these built-in notification types: | **Penalty earned back** | `penalty_redeemed` | Both | No | No | Off | | **Raffle drawn** | `raffle_drawn` | Both | No | No | Off | | **Pet grew up** | `pet_evolved` | Both | No | No | Off | +| **Reward still owed** | `reward_owed` | Parents | No | No | **On** | +| **Personal best** | `personal_best` | Both | No | No | Off | The 15 rows from **Birthday** to **Auction Result** were added in v6.0.0. All of them start **switched off**, apart from the parent **Inspection Reminder**, which only ever fires for an inspection a parent started. Turn on the ones you want on the Notifications tab. @@ -463,7 +465,7 @@ Each notification type fires a matching `taskmate_` event, except the 1 | `taskmate_family_goal_reached_notification` † | Combined family points reached the goal | | `taskmate_birthday`, `taskmate_presence_arrival`, `taskmate_item_rejected`, `taskmate_recap_ready`, `taskmate_bounty_claim_lapsing`, `taskmate_wish_requested`, `taskmate_inspection_reminder`, `taskmate_auction_closing`, `taskmate_auction_result` | (v6.0.0) The matching notification was dispatched | | `taskmate_streak_freeze_used_notification` †, `taskmate_bounty_posted_notification` †, `taskmate_wish_pledged_notification` †, `taskmate_inspection_started_notification` †, `taskmate_inspection_passed_notification` †, `taskmate_auction_opened_notification` † | (v6.0.0) The matching notification was dispatched | -| `taskmate_help_requested`, `taskmate_mood_low`, `taskmate_weekly_goal_reached`, `taskmate_penalty_redeemed`, `taskmate_raffle_drawn`, `taskmate_pet_evolved` | (v7.0.0) The matching notification was dispatched. These features have no separate event of their own, so automate on these. The per-child and family [event entities](Event-Entities.md) re-publish them too | +| `taskmate_help_requested`, `taskmate_mood_low`, `taskmate_weekly_goal_reached`, `taskmate_penalty_redeemed`, `taskmate_raffle_drawn`, `taskmate_pet_evolved`, `taskmate_reward_owed` | (v7.0.0) The matching notification was dispatched. These features have no separate event of their own, so automate on these. The per-child and family [event entities](Event-Entities.md) re-publish them too | | `taskmate_custom_notification` | Custom notification dispatched | > **† Renamed in v6.1.0.** Before v6.1.0 these 11 notification events used the same name as the feature's own event, so an automation on, say, `taskmate_level_up` ran twice whenever the notification was sent. Now the feature event (no `recipients`, see [Automations](Automations.md)) fires once, and the notification event has the `_notification` suffix. If an automation reads `recipients` from one of these, trigger it on the `_notification` event. A `recipients is not defined` condition added to filter out the duplicate can be removed. diff --git a/wiki/Personal-Bests.md b/wiki/Personal-Bests.md new file mode 100644 index 00000000..c10e5cc3 --- /dev/null +++ b/wiki/Personal-Bests.md @@ -0,0 +1,100 @@ +# Personal Bests + +[Badges](Achievement-Badges.md) and [streak milestones](Bonus-Points.md) celebrate round numbers a parent chose. A **personal best** is the child's own bar: the most points they have ever earned in a day, their longest streak, their biggest single award. Beating one is celebrated whatever the number is, which is exactly what keeps a child who will never top the leaderboard trying. + +> **New in v7.1.0** + +Personal bests are always on. There is nothing to configure; the first chores set the baseline. + +--- + +## The Records + +| Record | Key | What counts | +|--------|-----|-------------| +| **Longest streak** | `longest_streak` | The child's longest run of consecutive days with a chore done. Seeded from the streak history TaskMate already kept, so nobody starts from zero | +| **Best day (points)** | `best_day_points` | Most points from approved chores in one day, bonus sub-tasks included | +| **Most chores in a day** | `best_day_chores` | Most approved chores in one day (bonus sub-tasks don't count as chores) | +| **Best week (points)** | `best_week_points` | Most points from approved chores in one Monday-to-Sunday week | +| **Biggest award** | `best_single_award` | The most points one approval ever paid, multipliers included | + +Each record keeps the value and the day it was set. + +--- + +## How Records Are Set + +Records are checked after every approval. The **first** time a measure is seen it is stored quietly as the baseline — a child's first chore is not "five records at once". From then on, going past a stored value is a new personal best: + +- the `taskmate_personal_best` event fires +- the **Personal best** notification goes out, if switched on +- a tier-2 [celebration](Celebrations.md) fires (`kind: personal_best`) +- the child's card shows **New personal best!** naming the record, with confetti the first time that device sees it +- the record glows as **New** on the Personal Bests card for the rest of the day + +Ties never re-celebrate. A record only moves when the number is bigger. + +### Undo and reject + +When an approval is undone or rejected, the day (or week) it belonged to is recomputed. A record that no longer holds falls back to the best the kept completion history can show, and stops being "new today". Records older than the kept history are left alone. The longest-streak record never goes down, matching the rest of TaskMate's streak handling. + +--- + +## The Personal Bests Card + +![Personal Bests Card](images/personal-bests-card.png) + +```yaml +type: custom:taskmate-personal-bests-card +entity: sensor.taskmate_overview +child_id: a8c8376a # optional — leave out to show every child +title: My records # optional +header_color: "#d35400" +``` + +One tile per record with the value and the day it was set. A record beaten today is marked **New** and shows what it beat. A child with no records yet gets *No records yet — the first chores set the bar*. Available in every [design style](Card-Design-Styles.md). + +| Option | Type | Default | Description | +| --- | --- | --- | --- | +| `entity` | string | **required** | The TaskMate overview sensor | +| `child_id` | string | _none_ | One child's board. Without it, every child gets a block | +| `title` | string | `"Personal Bests"` | Card title | +| `header_color` | string | `#d35400` | Header colour | +| `card_design` | string | global default | Design style | + +### On the child card + +The child card announces today's new records in a banner above the chores. Switch it off per card with `show_personal_bests: false`. + +--- + +## Notification + +| Type | Audience | Default | +|------|----------|---------| +| **Personal best** `personal_best` | Children and parents | Off | + +> 🏆 New personal best! Malia: most Stars in a day — 35. + +Turn it on under **TaskMate panel → Notifications**. + +--- + +## Event + +`taskmate_personal_best` fires with `child_id`, `child_name`, `record` (one of the keys above), `value`, `previous`, `date` and `timestamp`. It shows as **Personal best** on the child's [event entity](Event-Entities.md). The matching notification's bus event is `taskmate_personal_best_notification`. + +--- + +## Sensors + +Each child's entry in `sensor.taskmate_overview`'s `children` attribute carries `personal_bests` once there is a record: one object per record key with `value` and `date` (empty for a seeded streak), plus `previous` on a record beaten today and `new_today`, the list of record keys beaten today. + +--- + +## Related + +- [Celebrations](Celebrations.md) +- [Streak Freezes](Streak-Freezes.md) and [Bonus Points](Bonus-Points.md) — streak milestones +- [Achievement Badges](Achievement-Badges.md) +- [Insights](Insights.md) diff --git a/wiki/Reward-Fulfilment.md b/wiki/Reward-Fulfilment.md new file mode 100644 index 00000000..b53c8ec4 --- /dev/null +++ b/wiki/Reward-Fulfilment.md @@ -0,0 +1,87 @@ +# Reward Fulfilment + +Approving a reward spends the child's points. Until now that was the end of the story: nothing recorded whether the movie night actually happened or the Lego set ever arrived. **Reward fulfilment** keeps every approved reward on an **owed** list until somebody hands it over, shows the child what they are still due, and nags the parents when something has sat there too long. + +> **New in v7.1.0** + +--- + +## Turning It On + +**TaskMate panel → Settings → Reward fulfilment** + +| Setting | Key | Default | Description | +|---------|-----|---------|-------------| +| **Track rewards until they are handed over** | `reward_fulfilment_enabled` | off | Approved rewards go on the owed list instead of disappearing into history | +| **Remind me after** | `reward_fulfilment_reminder_days` | `3` | Days before parents get a push saying a reward is still owed, repeated every that many days while it stays owed. `0` = never remind (0–60) | + +Only rewards approved **while the tracker is on** are tracked. Turning it on does not back-fill old purchases, and turning it off hides the list without deleting anything. + +### What is never tracked + +- **Streak freezes** — TaskMate hands the token over itself the moment the claim is approved +- **Timed unlocks** — the entity switches on by itself (see [Timed Unlock Rewards](Timed-Unlock-Rewards.md)) +- **Wishlist redemptions** — a [wish](Wishlist.md) already has its own hand-over step + +--- + +## Handing a Reward Over + +Anywhere a parent approves rewards, the owed ones sit underneath with a **Handed over** button: + +- **Approvals card** — a *Still to hand over* section after the pending items, in every design style. Owed rewards never count towards the card's pending badge; they are not approvals. +- **Admin panel → Today** — a *Rewards to hand over* card beside *Asked for help*, following the child filter. + +The child can also confirm for themselves: their card shows **You're owed a reward** with the reward, how long ago it was redeemed and a **Got it!** button. Tapping it marks the reward delivered by the child. + +Marking a reward delivered is final; there is no "un-deliver". If a parent taps by mistake, nothing is lost: the purchase is still in history, it just leaves the owed list. + +--- + +## Reminders + +With the reminder set to *N* days, the parents receive the **Reward still owed** push the first time a reward has been owed for *N* days, then again every *N* days until it is handed over: + +> 🎁 Malia is still waiting for 'Movie night' — redeemed 3 days ago. + +The notification type `reward_owed` is **on by default** because it only ever fires once a parent has switched the tracker on. Route it or switch it off on the Notifications tab like any other type. It follows [multi-parent routing](Multi-Parent-Approval-Routing.md). + +--- + +## Service + +| Service | Fields | Description | +|---------|--------|-------------| +| `taskmate.mark_reward_delivered` | `claim_id`, `child_id` (optional) | Mark an owed reward as handed over. Without `child_id` it is a parent action (an HA admin or a [TaskMate parent](Parent-Access.md); automations are allowed). With `child_id` it is the child confirming their own, through the same linked-child check as `complete_chore`, and the claim must belong to that child | + +```yaml +action: taskmate.mark_reward_delivered +data: + claim_id: 9c2f6d1e4a7b8c3d +``` + +Find the `claim_id` in the `owed_rewards` attribute of `sensor.taskmate_pending_approvals`, or in the `taskmate_reward_approved` event. + +--- + +## Event + +Handing a reward over fires `taskmate_reward_delivered` with `child_id`, `child_name`, `reward_id`, `reward_name`, `claim_id`, `delivered_by` (`parent` or `child`) and `timestamp`. It shows as **Reward handed over** on the child's [event entity](Event-Entities.md). + +--- + +## Sensors + +| Where | Attribute | Contents | +|-------|-----------|----------| +| `sensor.taskmate_pending_approvals` | `owed_rewards` | Every owed reward, oldest first: `claim_id`, `child_id`, `child_name`, `reward_id`, `reward_name`, `reward_icon`, `cost`, `approved_at`, `days_owed`. Present only while the tracker is on and something is owed | +| `sensor.taskmate_overview` → `children[]` | `owed_rewards` | That child's owed rewards, compact: `claim_id`, `reward_name`, `reward_icon`, `days_owed`. Present only when there is one | + +--- + +## Related + +- [Rewards](Rewards.md) — the rewards card and how claims work +- [Managing Rewards](Managing-Rewards.md) +- [Approvals Card](Approvals-Card.md) +- [Notifications](Notifications.md) diff --git a/wiki/Rewards.md b/wiki/Rewards.md index 77ad4146..e7be287e 100644 --- a/wiki/Rewards.md +++ b/wiki/Rewards.md @@ -130,6 +130,7 @@ Restock only makes sense for rewards with a limited **Quantity**. See [Managing ## Related +- [Reward Fulfilment](Reward-Fulfilment.md) — keep an approved reward on an owed list until it is handed over - [Managing Rewards](Managing-Rewards.md) — create and edit rewards - [Pool Mode (Savings Jars)](Pool-Mode-(Savings-Jars).md) — the savings-jar feature - [Wishlist](Wishlist.md) — children save towards things they want, and the family can chip in diff --git a/wiki/Sensor-Attributes.md b/wiki/Sensor-Attributes.md index db07aacb..54958a0e 100644 --- a/wiki/Sensor-Attributes.md +++ b/wiki/Sensor-Attributes.md @@ -88,6 +88,8 @@ This is the main sensor. Its `extra_state_attributes` contain everything most ca | `pet` | object | (v7.0.0) The child's [virtual pet](Virtual-Pets.md) (`adopted`, `name`, `species`, `stage`, `mood`, `xp`, `accessories`, …). Present only while pets are on | | `weekly_goal` | object | (v7.0.0) The child's [weekly goal](Weekly-Goals.md) (`target`, `bonus`, `earned`, `reached`). Present only while weekly goals are on | | `redemptions` | list | (v7.0.0) Open [make-it-right](Make-It-Right-Penalties.md) offers (`id`, `chore_id`, `chore_name`, `penalty_name`, `points`, `expires`). Present only when there is one | +| `owed_rewards` | list | (v7.1.0) Redeemed rewards still to hand over (`claim_id`, `reward_name`, `reward_icon`, `days_owed`). Present only while [reward fulfilment](Reward-Fulfilment.md) is on and something is owed | +| `personal_bests` | object | (v7.1.0) The child's [records](Personal-Bests.md): one object per record key (`value`, `date`, plus `previous` on a record beaten today) and `new_today`, the keys beaten today. Present once there is a record | > Full field-by-field tables for `quests`, `challenges`, and `avatar_options` are in the [Sensor Reference](Sensor-Reference.md#children-array). diff --git a/wiki/Sensor-Reference.md b/wiki/Sensor-Reference.md index 68c10e2b..8374354e 100644 --- a/wiki/Sensor-Reference.md +++ b/wiki/Sensor-Reference.md @@ -101,6 +101,8 @@ Each entry in the `children` array: | `pet` | object | (v7.0.0) The child's [virtual pet](Virtual-Pets.md), present only while pets are switched on. `{adopted: false}` before adoption; afterwards `adopted`, `name`, `species`, `stage` (`egg` / `baby` / `kid` / `teen` / `grown`), `chores` (since adoption), `xp` (growth), `mood` (`happy` / `proud` / `content` / `sleepy`), `accessories`, plus, when reached or switched on, `goal`, `legendary`, `home`, `pet_level`, `egg_ready` and `family` (earlier pets: `species`, `name`, `legendary`) | | `weekly_goal` | object | (v7.0.0) The child's own [weekly goal](Weekly-Goals.md), present only while weekly goals are switched on: `target` (0 = not set), `bonus`, and once a target is set `earned` (points since Monday) and `reached` | | `redemptions` | list | (v7.0.0) Open [make-it-right](Make-It-Right-Penalties.md) offers — each has `id`, `chore_id`, `chore_name`, `penalty_name`, `points` (what doing the chore gives back) and `expires` (ISO date, the last day it can be done). Present only when there is one | +| `owed_rewards` | list | (v7.1.0) Redeemed rewards still to hand over, oldest first — each has `claim_id`, `reward_name`, `reward_icon` and `days_owed`. Present only while [reward fulfilment](Reward-Fulfilment.md) is switched on and something is owed | +| `personal_bests` | object | (v7.1.0) The child's [personal bests](Personal-Bests.md), present once there is a record: for each of `longest_streak`, `best_day_points`, `best_day_chores`, `best_week_points`, `best_single_award` an object with `value` and `date` (ISO; empty for the seeded streak), plus `previous` when it was beaten today; and `new_today`, the list of record keys beaten today | **`quests` entry fields:** @@ -328,6 +330,7 @@ Numeric count of total pending items. | `reward_claims` | list | Full details of pending reward claims | | `mandatory_misses` | list | Unresolved mandatory-miss review items (see below). See [Mandatory Chores](Mandatory-Chores.md) | | `quality_rating` | object | (v6.0.0) Present only while [quality ratings](Quality-Ratings.md) are on: `multipliers`, a list of the three star multipliers (★, ★★, ★★★). Approval cards use it to draw the star picker | +| `owed_rewards` | list | (v7.1.0) Redeemed rewards still to hand over, oldest first: `claim_id`, `child_id`, `child_name`, `reward_id`, `reward_name`, `reward_icon`, `cost`, `approved_at`, `days_owed`. Present only while [reward fulfilment](Reward-Fulfilment.md) is on and something is owed | **`chore_completions` entries:** @@ -720,7 +723,7 @@ TaskMate's [bus events](Automations.md#bus-events-reference) are also published | Entity | Description | |--------|-------------| -| `event.taskmate__activity` | One per child (friendly name ` activity`). Fires for that child's moments: `chore_completed`, `chore_approved`, `chore_rejected`, `chore_approval_undone`, `chore_auto_approved`, `reward_claimed`, `reward_approved`, `reward_rejected`, `badge_earned`, `level_up`, `streak_updated`, `streak_freeze_used`, `perfect_week`, `weekly_target_reached`, `weekly_goal_reached`, `quest_completed`, `challenge_completed`, `bonus_applied`, `penalty_applied`, `penalty_redeemed`, `surprise_bonus`, `mandatory_missed`, `team_chore_completed`, `points_gifted`, `help_requested`, `mood_logged`, `sick_day`, `raffle_won`, `pet_evolved` | +| `event.taskmate__activity` | One per child (friendly name ` activity`). Fires for that child's moments: `chore_completed`, `chore_approved`, `chore_rejected`, `chore_approval_undone`, `chore_auto_approved`, `reward_claimed`, `reward_approved`, `reward_rejected`, `badge_earned`, `level_up`, `streak_updated`, `streak_freeze_used`, `perfect_week`, `weekly_target_reached`, `weekly_goal_reached`, `quest_completed`, `challenge_completed`, `bonus_applied`, `penalty_applied`, `penalty_redeemed`, `surprise_bonus`, `mandatory_missed`, `team_chore_completed`, `points_gifted`, `help_requested`, `mood_logged`, `sick_day`, `raffle_won`, `pet_evolved`, `reward_delivered`, `personal_best` | | `event.taskmate_family_activity` | One for the family (friendly name `Family activity`). Fires for `family_goal_reached`, `season_champion`, `bounty_posted`, `scheduled_change_applied`, `raffle_drawn` | The state is the time of the latest event; the `event_type` attribute says which one, and the event's own data (child, chore, points and so on) is copied onto the entity as attributes. A gift shows on both children's entities, and a teamwork chore on every team member's. diff --git a/wiki/Services.md b/wiki/Services.md index 2d8b8177..cbc9af6c 100644 --- a/wiki/Services.md +++ b/wiki/Services.md @@ -312,6 +312,16 @@ Approve a pending reward claim. For wallet rewards, deducts cost from the child' | --- | --- | --- | | `claim_id` | ✅ | ID of the pending reward claim | +### `taskmate.mark_reward_delivered` +(v7.1.0) An approved reward has been handed over, so it leaves the owed list. See [Reward Fulfilment](Reward-Fulfilment.md). + +| Field | Required | Description | +| --- | --- | --- | +| `claim_id` | ✅ | The approved reward claim (from `owed_rewards` on `sensor.taskmate_pending_approvals`) | +| `child_id` | ❌ | Give it when the child is confirming their own reward: the call then goes through the linked-child check and the claim must be theirs. Leave it out for a parent action | + +Refused for a claim that is not owed, or already marked delivered. + ### `taskmate.reject_reward` Reject a pending reward claim. For wallet rewards, no action (points were never deducted). For pool rewards, allocation stays in place — child can Redeem again. diff --git a/wiki/images/personal-bests-card.png b/wiki/images/personal-bests-card.png new file mode 100644 index 00000000..e1ce2909 Binary files /dev/null and b/wiki/images/personal-bests-card.png differ diff --git a/wiki/index.md b/wiki/index.md index 5f1ff94a..0fd869a2 100644 --- a/wiki/index.md +++ b/wiki/index.md @@ -97,6 +97,7 @@ Welcome to the TaskMate documentation. TaskMate is a Home Assistant integration | [Recaps](Recaps.md) | A "Wrapped"-style story of each child's week, month or year | | [Virtual Pets](Virtual-Pets.md) | A pet that hatches and grows as the child does chores | | [Weekly Goals](Weekly-Goals.md) | Children set their own weekly points goal and earn a bonus for reaching it | +| [Personal Bests](Personal-Bests.md) | Each child's own records — longest streak, best day, best week, biggest award — celebrated when beaten | ## Notifications & Admin