diff --git a/README.md b/README.md index 38e38fef..8413e4dc 100755 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ 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, 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. +**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, [boost windows](https://tempus2016.github.io/taskmate/Boost-Windows/) make chosen chores pay ×2 for a while (and can wake the wall tablet to say so), 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, [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. diff --git a/custom_components/taskmate/const.py b/custom_components/taskmate/const.py index 7c6941a9..6e8763c7 100644 --- a/custom_components/taskmate/const.py +++ b/custom_components/taskmate/const.py @@ -373,6 +373,8 @@ def is_valid_completion_sound(value: str | None) -> bool: 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" +# Boost windows: a boost covering the child just began. +NOTIF_TYPE_BOOST_STARTED: Final = "boost_started" # Reward fulfilment (#1098): default days before parents are reminded a # redeemed reward is still owed (0 = never remind), and the most allowed. diff --git a/custom_components/taskmate/coord_boosts.py b/custom_components/taskmate/coord_boosts.py new file mode 100644 index 00000000..1fcf1eda --- /dev/null +++ b/custom_components/taskmate/coord_boosts.py @@ -0,0 +1,577 @@ +"""Boost windows: a timed multiplier on chosen chores the children can see coming. + +A parent opens a boost on the spot ("the next hour, bins and dishes pay +double"), schedules one for later the same way, or sets a recurring rule in +Settings ("Saturdays 09:00-11:00, garden jobs ×2"). Automations can do the +same through the ``start_boost`` / ``end_boost`` services. + +A chore *completed* inside the window is priced with the multiplier, so +approving it after the window has closed still pays what the child saw. +When two boosts cover the same chore the bigger one applies. + +On special days (the weekend multiplier, a birthday) ``boost_stacking`` +decides: "highest" (default) pays the bigger of the boost and the special-day +multiplier, "stack" multiplies both. + +When a boost begins TaskMate can wake wall tablets: a Fully Kiosk Browser +screen switch is turned on (with its screensaver turned off and the browser +brought to the front), and a Companion-app device is sent +``command_screen_on``. Only those two kinds of target are ever called. +""" + +from __future__ import annotations + +import logging +import re +from datetime import datetime, time, timedelta +from typing import Any + +from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt as dt_util + +from . import notify_strings +from .const import NOTIF_TYPE_BOOST_STARTED +from .models import generate_id + +_LOGGER = logging.getLogger(__name__) + +BOOST_MIN_MULTIPLIER = 1.1 +BOOST_MAX_MULTIPLIER = 5.0 +BOOST_MAX_MINUTES = 24 * 60 +BOOST_NAME_MAX = 60 +BOOST_KEEP = 20 +BOOST_MAX_RULES = 20 +BOOST_STACKING_MODES = ("highest", "stack") +# A boost can be scheduled up to a week ahead. +BOOST_MAX_LEAD = timedelta(days=7) +# How far in the past a start time may be (clock skew on a quick tap). +_PAST_GRACE = timedelta(minutes=5) + +_TIME_RE = re.compile(r"^([01]\d|2[0-3]):([0-5]\d)$") +_MOBILE_APP_RE = re.compile(r"^mobile_app_[a-z0-9_]+$") +_FKB_SCREEN_SUFFIX = "-screenOn" + + +def _parse_time(value: Any) -> time: + m = _TIME_RE.match(str(value or "")) + if not m: + raise ValueError(f"'{value}' is not a time like 09:30.") + return time(int(m.group(1)), int(m.group(2))) + + +def _fmt_multiplier(value: float) -> str: + return f"{value:g}" + + +class BoostsMixin: + """Boost windows, recurring boost rules and the tablet wake-up.""" + + # ── settings ───────────────────────────────────────────────────────── + + def boost_stacking(self) -> str: + mode = str(self.storage.get_setting("boost_stacking", "highest") or "highest") + return mode if mode in BOOST_STACKING_MODES else "highest" + + def _boost_list_setting(self, key: str) -> list[str]: + raw = self.storage.get_setting(key, []) + return [str(v) for v in raw if isinstance(v, str)] if isinstance(raw, list) else [] + + # ── storage ────────────────────────────────────────────────────────── + + def _boosts(self) -> list[dict[str, Any]]: + return self.storage.get_section("boosts", list) + + def boost_rules(self) -> list[dict[str, Any]]: + return self.storage.get_section("boost_rules", list) + + @staticmethod + def _boost_times(boost: dict[str, Any]) -> tuple[datetime, datetime] | None: + try: + return datetime.fromisoformat(boost["starts_at"]), datetime.fromisoformat(boost["ends_at"]) + except (KeyError, TypeError, ValueError): + return None + + def _boost_status(self, boost: dict[str, Any], now: datetime) -> str: + if boost.get("ended_early"): + return "ended" + times = self._boost_times(boost) + if times is None: + return "ended" + start, end = times + if now < start: + return "upcoming" + if now < end: + return "active" + return "ended" + + # ── validation ─────────────────────────────────────────────────────── + + def _check_boost_fields(self, multiplier, chore_ids, child_ids) -> tuple[float, list[str], list[str]]: + try: + mult = round(float(multiplier), 2) + except (TypeError, ValueError): + raise ValueError("The multiplier must be a number.") from None + if not BOOST_MIN_MULTIPLIER <= mult <= BOOST_MAX_MULTIPLIER: + raise ValueError(f"Pick a multiplier from ×{BOOST_MIN_MULTIPLIER:g} to ×{BOOST_MAX_MULTIPLIER:g}.") + chores = list(dict.fromkeys(str(c) for c in (chore_ids or []))) + for cid in chores: + if self.get_chore(cid) is None: + raise ValueError(f"Chore {cid} not found.") + kids = list(dict.fromkeys(str(c) for c in (child_ids or []))) + for cid in kids: + if self.get_child(cid) is None: + raise ValueError(f"Child {cid} not found.") + return mult, chores, kids + + # ── start / end ────────────────────────────────────────────────────── + + async def async_start_boost( + self, + multiplier: float, + minutes: int, + chore_ids: list[str] | None = None, + child_ids: list[str] | None = None, + starts_at: datetime | None = None, + name: str = "", + notify: bool = True, + rule_id: str = "", + rule_day: str = "", + ends_at: datetime | None = None, + ) -> dict[str, Any]: + """Open a boost now, or schedule one. Empty chore/child lists mean all of them.""" + mult, chores, kids = self._check_boost_fields(multiplier, chore_ids, child_ids) + now = dt_util.now() + start = starts_at or now + if start.tzinfo is None: + start = dt_util.as_local(start) if hasattr(dt_util, "as_local") else start + # A recurring rule's window can already be under way when the tick + # (or a rule saved mid-window) picks it up; it runs for what's left. + if start < now - _PAST_GRACE and not rule_id: + raise ValueError("A boost can't start in the past.") + if start > now + BOOST_MAX_LEAD: + raise ValueError("A boost can be scheduled up to a week ahead.") + if ends_at is None: + try: + minutes = int(minutes) + except (TypeError, ValueError): + raise ValueError("The minutes must be a whole number.") from None + if not 1 <= minutes <= BOOST_MAX_MINUTES: + raise ValueError(f"Pick from 1 to {BOOST_MAX_MINUTES} minutes.") + end = start + timedelta(minutes=minutes) + else: + end = ends_at + minutes = max(1, int((end - start).total_seconds() // 60)) + boost = { + "id": generate_id(), + "name": str(name or "").strip()[:BOOST_NAME_MAX], + "multiplier": mult, + "chore_ids": chores, + "child_ids": kids, + "starts_at": start.isoformat(), + "ends_at": end.isoformat(), + "minutes": minutes, + "notify": bool(notify), + "announced": False, + "ended": False, + "ended_early": False, + "created_at": now.isoformat(), + } + if rule_id: + boost["rule_id"] = rule_id + boost["rule_day"] = rule_day + self._boosts().append(boost) + await self.storage.async_save() + if start <= now: + await self._async_announce_boost(boost) + await self.async_refresh() + return {**self._boost_payload(boost), "status": self._boost_status(boost, dt_util.now())} + + async def async_end_boost(self, boost_id: str) -> None: + """End a boost early, or call off one that hasn't started. + + A recurring rule's boost for today has the id ``rule::`` + until it begins; ending that one skips the rule for the day. + """ + now = dt_util.now() + if boost_id.startswith("rule:"): + row = next((b for s, b in self._boost_rows(now) if b.get("id") == boost_id), None) + if row is None: + raise ValueError("That boost has already ended.") + day = boost_id.rsplit(":", 1)[-1] + self._boosts().append( + { + **{ + k: row[k] + for k in ("name", "multiplier", "chore_ids", "child_ids", "starts_at", "ends_at", "minutes") + }, + "id": generate_id(), + "rule_id": row["rule_id"], + "rule_day": day, + "notify": False, + "announced": False, + "ended": True, + "ended_early": True, + "ended_at": now.isoformat(), + "created_at": now.isoformat(), + } + ) + await self.storage.async_save() + await self.async_refresh() + return + boost = next((b for b in self._boosts() if b.get("id") == boost_id), None) + if boost is None or self._boost_status(boost, now) == "ended": + raise ValueError("That boost has already ended.") + was_active = self._boost_status(boost, now) == "active" + boost["ended_early"] = True + boost["ended"] = True + boost["ended_at"] = now.isoformat() + await self.storage.async_save() + if was_active or boost.get("announced"): + self._fire_boost_ended(boost, early=True) + await self.async_refresh() + + def _boost_payload(self, boost: dict[str, Any]) -> dict[str, Any]: + return { + "id": boost["id"], + "name": boost.get("name", ""), + "multiplier": boost["multiplier"], + "chore_ids": list(boost.get("chore_ids") or []), + "child_ids": list(boost.get("child_ids") or []), + "starts_at": boost["starts_at"], + "ends_at": boost["ends_at"], + "minutes": boost.get("minutes", 0), + **({"rule_id": boost["rule_id"]} if boost.get("rule_id") else {}), + } + + def _fire_boost_ended(self, boost: dict[str, Any], early: bool) -> None: + self.hass.bus.async_fire( + "taskmate_boost_ended", + {**self._boost_payload(boost), "ended_early": early, "timestamp": dt_util.now().isoformat()}, + ) + + async def _async_announce_boost(self, boost: dict[str, Any]) -> None: + """The moment a boost goes live: event, push, wake the tablets. Once.""" + if boost.get("announced"): + return + boost["announced"] = True + await self.storage.async_save() + payload = self._boost_payload(boost) + self.hass.bus.async_fire("taskmate_boost_started", {**payload, "timestamp": dt_util.now().isoformat()}) + if boost.get("notify", True) and getattr(self, "notifications", None): + chore_names = [c.name for cid in payload["chore_ids"] if (c := self.get_chore(cid))] + kids = payload["child_ids"] or [c.id for c in self.storage.get_children()] + try: + await self.notifications.fire( + NOTIF_TYPE_BOOST_STARTED, + { + "boost_id": boost["id"], + "name": payload["name"], + "multiplier": _fmt_multiplier(payload["multiplier"]), + "minutes": payload["minutes"], + "chores_text": ", ".join(chore_names) or notify_strings.text(self.hass, "boost_all_chores"), + "points_name": self.storage.get_points_name(), + }, + only_recipients={f"child:{cid}" for cid in kids}, + ) + except Exception: # noqa: BLE001 - a push must never stop the boost + _LOGGER.exception("TaskMate: boost notification failed") + await self.async_wake_boost_screens() + + # ── the tick ───────────────────────────────────────────────────────── + + async def async_check_boosts(self, refresh: bool = True) -> int: + """Start rule boosts, announce scheduled ones that began, close finished ones. + + Returns how many boosts changed state. Runs from the minutely tick. + """ + now = dt_util.now() + changed = 0 + changed += await self._async_fire_rules(now) + for boost in list(self._boosts()): + status = self._boost_status(boost, now) + if status == "active" and not boost.get("announced"): + await self._async_announce_boost(boost) + changed += 1 + elif status == "ended" and not boost.get("ended"): + boost["ended"] = True + if boost.get("announced"): + self._fire_boost_ended(boost, early=False) + changed += 1 + if changed: + self._prune_boosts() + await self.storage.async_save() + if refresh: + await self.async_refresh() + return changed + + def _prune_boosts(self) -> None: + boosts = self._boosts() + done = [b for b in boosts if b.get("ended")] + extra = len(done) - BOOST_KEEP + if extra > 0: + drop = {id(b) for b in sorted(done, key=lambda b: b.get("ends_at", ""))[:extra]} + boosts[:] = [b for b in boosts if id(b) not in drop] + + # ── recurring rules ────────────────────────────────────────────────── + + def _rule_window(self, rule: dict[str, Any], day) -> tuple[datetime, datetime]: + tz = dt_util.now().tzinfo + start = datetime.combine(day, _parse_time(rule["start"]), tzinfo=tz) + end = datetime.combine(day, _parse_time(rule["end"]), tzinfo=tz) + return start, end + + def _rule_ran(self, rule_id: str, day_iso: str) -> bool: + return any(b.get("rule_id") == rule_id and b.get("rule_day") == day_iso for b in self._boosts()) + + async def _async_fire_rules(self, now: datetime) -> int: + started = 0 + today = now.date() + for rule in self.boost_rules(): + if not rule.get("enabled", True) or today.weekday() not in (rule.get("days") or []): + continue + try: + start, end = self._rule_window(rule, today) + except ValueError: + continue + if not start <= now < end or self._rule_ran(rule["id"], today.isoformat()): + continue + try: + await self.async_start_boost( + multiplier=rule["multiplier"], + minutes=0, + chore_ids=[c for c in rule.get("chore_ids") or [] if self.get_chore(c)], + child_ids=[c for c in rule.get("child_ids") or [] if self.get_child(c)], + starts_at=start, + ends_at=end, + name=rule.get("name", ""), + notify=rule.get("notify", True), + rule_id=rule["id"], + rule_day=today.isoformat(), + ) + started += 1 + except ValueError as err: + _LOGGER.warning("TaskMate: boost rule '%s' could not start: %s", rule.get("name"), err) + return started + + async def async_save_boost_rule(self, rule: dict[str, Any]) -> dict[str, Any]: + """Add or replace a recurring boost rule (matched on ``id``).""" + days = sorted({int(d) for d in (rule.get("days") or []) if str(d).lstrip("-").isdigit() and 0 <= int(d) <= 6}) + if not days: + raise ValueError("Pick at least one day.") + start, end = _parse_time(rule.get("start")), _parse_time(rule.get("end")) + if end <= start: + raise ValueError("The end time must be after the start time.") + mult, chores, kids = self._check_boost_fields( + rule.get("multiplier"), rule.get("chore_ids"), rule.get("child_ids") + ) + rules = self.boost_rules() + rule_id = str(rule.get("id") or "") + existing = next((r for r in rules if r.get("id") == rule_id), None) if rule_id else None + if existing is None and len(rules) >= BOOST_MAX_RULES: + raise ValueError(f"Up to {BOOST_MAX_RULES} recurring boosts.") + clean = { + "id": rule_id if existing else generate_id(), + "name": str(rule.get("name") or "").strip()[:BOOST_NAME_MAX], + "days": days, + "start": start.strftime("%H:%M"), + "end": end.strftime("%H:%M"), + "multiplier": mult, + "chore_ids": chores, + "child_ids": kids, + "notify": bool(rule.get("notify", True)), + "enabled": bool(rule.get("enabled", True)), + } + if existing is not None: + existing.clear() + existing.update(clean) + else: + rules.append(clean) + await self.storage.async_save() + await self.async_refresh() + return dict(clean) + + async def async_delete_boost_rule(self, rule_id: str) -> None: + rules = self.boost_rules() + if not any(r.get("id") == rule_id for r in rules): + raise ValueError("That recurring boost no longer exists.") + rules[:] = [r for r in rules if r.get("id") != rule_id] + await self.storage.async_save() + await self.async_refresh() + + # ── what applies to a completion ───────────────────────────────────── + + def _boost_rows(self, now: datetime) -> list[tuple[str, dict[str, Any]]]: + """Every live boost plus today's enabled rules that haven't produced one yet.""" + rows = [(self._boost_status(b, now), b) for b in self._boosts()] + rows = [(s, b) for s, b in rows if s != "ended"] + today = now.date() + for rule in self.boost_rules(): + if not rule.get("enabled", True) or today.weekday() not in (rule.get("days") or []): + continue + if self._rule_ran(rule["id"], today.isoformat()): + continue + try: + start, end = self._rule_window(rule, today) + except ValueError: + continue + if now >= end: + continue + rows.append( + ( + "upcoming" if now < start else "active", + { + "id": f"rule:{rule['id']}:{today.isoformat()}", + "name": rule.get("name", ""), + "multiplier": rule["multiplier"], + "chore_ids": rule.get("chore_ids") or [], + "child_ids": rule.get("child_ids") or [], + "starts_at": start.isoformat(), + "ends_at": end.isoformat(), + "minutes": int((end - start).total_seconds() // 60), + "rule_id": rule["id"], + }, + ) + ) + return rows + + def boost_multiplier_for(self, chore_id: str, child_id: str, when: datetime | None = None) -> float: + """The biggest active boost covering this chore for this child, or 0.""" + now = when or dt_util.now() + best = 0.0 + for status, boost in self._boost_rows(now): + if status != "active": + continue + if boost.get("chore_ids") and chore_id not in boost["chore_ids"]: + continue + if boost.get("child_ids") and child_id not in boost["child_ids"]: + continue + best = max(best, float(boost.get("multiplier") or 0)) + return best + + def special_day_multiplier(self, child, day) -> float: + """What the weekend and birthday bonuses together would multiply a chore by.""" + mult = 1.0 + if day.weekday() in (5, 6): + try: + weekend = float(self.storage.get_setting("weekend_multiplier", "2.0")) + except (ValueError, TypeError): + weekend = 2.0 + if weekend > 1.0: + mult *= weekend + if hasattr(self, "is_birthday") and self.is_birthday(child, day): + mult *= self.birthday_multiplier() + return mult + + def apply_boost(self, chore, child, points: int, when: datetime) -> tuple[int, float, bool]: + """Price a completion made at ``when``. Returns (points, multiplier used or 0, stacked).""" + if points <= 0: + return points, 0.0, False + mult = self.boost_multiplier_for(chore.id, child.id, when) + if mult <= 1.0: + return points, 0.0, False + if self.boost_stacking() == "stack": + return round(points * mult), mult, True + special = self.special_day_multiplier(child, dt_util.as_local(when).date()) + if mult <= special: + # Highest wins and the special day already pays more. + return points, 0.0, False + return round(points * mult), mult, False + + # ── what the cards and panel see ───────────────────────────────────── + + def boosts_state(self) -> list[dict[str, Any]]: + """Active and upcoming boosts, active first, then by start time.""" + now = dt_util.now() + out = [] + for status, boost in self._boost_rows(now): + row = self._boost_payload(boost) + row["status"] = status + row["chore_names"] = [c.name for cid in row["chore_ids"] if (c := self.get_chore(cid))] + out.append(row) + out.sort(key=lambda r: (r["status"] != "active", r["starts_at"])) + return out + + # ── waking the wall tablets ────────────────────────────────────────── + + def _fkb_targets(self) -> list[tuple[str, str, str]]: + """(domain, service, entity_id) calls for each chosen Fully Kiosk screen.""" + wanted = self._boost_list_setting("boost_wake_screens") + if not wanted: + return [] + registry = er.async_get(self.hass) + entities = getattr(registry, "entities", {}) or {} + calls: list[tuple[str, str, str]] = [] + for entity_id in wanted: + entry = registry.async_get(entity_id) if hasattr(registry, "async_get") else entities.get(entity_id) + if entry is None or getattr(entry, "platform", "") != "fully_kiosk": + continue + unique_id = str(getattr(entry, "unique_id", "") or "") + if not entity_id.startswith("switch.") or not unique_id.endswith(_FKB_SCREEN_SUFFIX): + continue + device_prefix = unique_id[: -len(_FKB_SCREEN_SUFFIX)] + calls.append(("switch", "turn_on", entity_id)) + for other in entities.values() if isinstance(entities, dict) else []: + if getattr(other, "platform", "") != "fully_kiosk": + continue + other_uid = str(getattr(other, "unique_id", "") or "") + if other_uid == f"{device_prefix}-screensaver" and other.entity_id.startswith("switch."): + calls.append(("switch", "turn_off", other.entity_id)) + elif other_uid == f"{device_prefix}-toForeground" and other.entity_id.startswith("button."): + calls.append(("button", "press", other.entity_id)) + return calls + + def _companion_targets(self) -> list[str]: + return [s for s in self._boost_list_setting("boost_wake_devices") if _MOBILE_APP_RE.match(s)] + + async def async_wake_boost_screens(self) -> int: + """Turn the chosen tablets' screens on. Failures are logged, never raised.""" + sent = 0 + try: + calls = self._fkb_targets() + except Exception: # noqa: BLE001 + _LOGGER.exception("TaskMate: could not read the Fully Kiosk screens to wake") + calls = [] + for domain, service, entity_id in calls: + try: + await self.hass.services.async_call(domain, service, {"entity_id": entity_id}, blocking=False) + sent += 1 + except Exception: # noqa: BLE001 + _LOGGER.warning("TaskMate: could not wake %s", entity_id, exc_info=True) + for notify_service in self._companion_targets(): + if not self.hass.services.has_service("notify", notify_service): + continue + try: + await self.hass.services.async_call( + "notify", notify_service, {"message": "command_screen_on"}, blocking=False + ) + sent += 1 + except Exception: # noqa: BLE001 + _LOGGER.warning("TaskMate: could not wake %s", notify_service, exc_info=True) + return sent + + def boost_wake_candidates(self) -> dict[str, list[dict[str, str]]]: + """Fully Kiosk screen switches and Companion-app devices a parent can pick.""" + screens: list[dict[str, str]] = [] + try: + registry = er.async_get(self.hass) + for entry in (getattr(registry, "entities", {}) or {}).values(): + if getattr(entry, "platform", "") != "fully_kiosk" or not entry.entity_id.startswith("switch."): + continue + if not str(getattr(entry, "unique_id", "") or "").endswith(_FKB_SCREEN_SUFFIX): + continue + state = self.hass.states.get(entry.entity_id) + name = (state.attributes.get("friendly_name") if state else None) or entry.entity_id + screens.append({"entity_id": entry.entity_id, "name": str(name)}) + except Exception: # noqa: BLE001 + _LOGGER.debug("TaskMate: no entity registry for wake candidates", exc_info=True) + devices: list[dict[str, str]] = [] + try: + services = self.hass.services.async_services().get("notify", {}) + for name in services: + if _MOBILE_APP_RE.match(name): + devices.append({"service": name, "name": name[len("mobile_app_") :].replace("_", " ").title()}) + except Exception: # noqa: BLE001 + _LOGGER.debug("TaskMate: could not list notify services", exc_info=True) + screens.sort(key=lambda s: s["name"].lower()) + devices.sort(key=lambda d: d["name"].lower()) + return {"screens": screens, "devices": devices} diff --git a/custom_components/taskmate/coord_chores.py b/custom_components/taskmate/coord_chores.py index 8fb238f7..db42b679 100644 --- a/custom_components/taskmate/coord_chores.py +++ b/custom_components/taskmate/coord_chores.py @@ -987,6 +987,8 @@ async def async_complete_chore( note=note, suggested_points=suggested_points, quantity=quantity, + # A won auction pays exactly the bid. + boostable=auction_price is None, ) if redo is not None: self._inspection_on_redo_recorded(redo, completion.id) @@ -1056,9 +1058,13 @@ async def _async_record_completion( suggested_points: int = 0, backdated: bool = False, quantity: int = 0, + boostable: bool = True, ) -> ChoreCompletion: """Store one completion and, when auto-approved, pay it. + ``boostable`` lets an active boost window multiply ``points``. Off for + a job logged for an earlier day and for a won auction's fixed price. + The part of completing a chore that is per child. The caller has already run every eligibility gate and then calls _async_after_completions once for the whole batch (one child normally, a whole team for #928). @@ -1068,6 +1074,9 @@ async def _async_record_completion( bonuses follow the day) but leaves the streak alone, and only moves the recurrence window forward, never back. """ + boost_mult, boost_stacked = 0.0, False + if boostable and not backdated and hasattr(self, "apply_boost"): + points, boost_mult, boost_stacked = self.apply_boost(chore, child, points, now) completion = ChoreCompletion( chore_id=chore.id, child_id=child.id, @@ -1084,6 +1093,8 @@ async def _async_record_completion( child_undo_allowed=not as_parent and undo_window_seconds(self.storage) > 0, backdated=backdated, quantity=quantity, + boost_multiplier=boost_mult, + boost_stacked=boost_stacked, ) # Record the completion before awarding anything. Awarding can suspend @@ -1103,7 +1114,13 @@ async def _async_record_completion( chore_id=chore.id, ) else: - total_awarded = await self._award_points(child, points, chore_id=chore.id) + total_awarded = await self._award_points( + child, + points, + chore_id=chore.id, + # A boost that replaced the weekend/birthday bonuses. + **({"special_bonuses": False} if boost_mult and not boost_stacked else {}), + ) completion.approved = True completion.approved_at = dt_util.now() completion.points_awarded = total_awarded @@ -1642,6 +1659,14 @@ async def async_approve_chore( completion_date=comp_date, skip_streak=is_bonus, chore_id=completion.chore_id, + # A boost that replaced the weekend/birthday bonuses + # at submission keeps them out here too. + **( + {"special_bonuses": False} + if getattr(completion, "boost_multiplier", 0) + and not getattr(completion, "boost_stacked", False) + else {} + ), ) completion.points_awarded = total_awarded self.storage.update_completion(completion) diff --git a/custom_components/taskmate/coord_notifications.py b/custom_components/taskmate/coord_notifications.py index fddc19eb..5f62e71c 100644 --- a/custom_components/taskmate/coord_notifications.py +++ b/custom_components/taskmate/coord_notifications.py @@ -32,6 +32,7 @@ NOTIF_TYPE_BADGE_EARNED, NOTIF_TYPE_BEDTIME_REMINDER, NOTIF_TYPE_BIRTHDAY, + NOTIF_TYPE_BOOST_STARTED, NOTIF_TYPE_BOUNTY_CLAIM_LAPSING, NOTIF_TYPE_BOUNTY_POSTED, NOTIF_TYPE_CELEBRATION, @@ -194,6 +195,8 @@ class NotificationTypeMeta: 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), + # Boost windows: the children a boost covers hear it began. Opt-in. + NotificationTypeMeta(NOTIF_TYPE_BOOST_STARTED, "child", False, False, False, False), ] NOTIFICATION_TYPES_BY_ID: dict[str, NotificationTypeMeta] = {t.id: t for t in NOTIFICATION_TYPES} @@ -204,6 +207,7 @@ class NotificationTypeMeta: _OWN_EVENT_TYPES: frozenset[str] = frozenset( { NOTIF_TYPE_AUCTION_OPENED, + NOTIF_TYPE_BOOST_STARTED, NOTIF_TYPE_PERSONAL_BEST, NOTIF_TYPE_BADGE_EARNED, NOTIF_TYPE_BOUNTY_POSTED, @@ -506,6 +510,7 @@ async def send_test(self, type_id: str) -> list[str]: "winner_name": "Alex", "tickets": 12, "record_label": notify_strings.text(self.hass, "record_longest_streak"), + "chores_text": sample["chore"], "value": 12, } ctx["result_text"] = notify_strings.render( diff --git a/custom_components/taskmate/coord_points.py b/custom_components/taskmate/coord_points.py index ae0618a7..80709b8b 100644 --- a/custom_components/taskmate/coord_points.py +++ b/custom_components/taskmate/coord_points.py @@ -1028,9 +1028,13 @@ async def _award_points( completion_date: date | None = None, skip_streak: bool = False, chore_id: str | None = None, + special_bonuses: bool = True, ) -> int: """Award points to a child, update streak, and apply bonus systems. + ``special_bonuses=False`` leaves out the weekend and birthday bonuses: + a boost already replaced them ("highest wins", see coord_boosts). + Returns the total points awarded (base + weekend + birthday bonus), excluding milestone bonuses (which are logged as separate transactions). @@ -1051,7 +1055,7 @@ async def _award_points( multiplier = 2.0 weekend_bonus = 0 - if effective_date.weekday() in (5, 6) and multiplier > 1.0: + if special_bonuses and effective_date.weekday() in (5, 6) and multiplier > 1.0: weekend_bonus = round(points * (multiplier - 1.0)) # ── Birthday multiplier (#924) ────────────────────────────────────── @@ -1060,7 +1064,7 @@ async def _award_points( # (Named "celebration_*" rather than after the birthday so CodeQL's # personal-data heuristics don't read a points multiplier as PII.) celebration_bonus = 0 - if self.is_birthday(child, effective_date): + if special_bonuses and self.is_birthday(child, effective_date): celebration_multiplier = self.birthday_multiplier() if celebration_multiplier > 1.0: celebration_bonus = round((points + weekend_bonus) * (celebration_multiplier - 1.0)) diff --git a/custom_components/taskmate/coordinator.py b/custom_components/taskmate/coordinator.py index df21bfce..eec460f3 100644 --- a/custom_components/taskmate/coordinator.py +++ b/custom_components/taskmate/coordinator.py @@ -25,6 +25,7 @@ from .coord_badges import BadgeCoordinator from .coord_bests import PersonalBestsMixin from .coord_birthdays import BirthdaysMixin +from .coord_boosts import BoostsMixin from .coord_bounties import BountiesMixin from .coord_calendar import CalendarMixin from .coord_challenges import ChallengesMixin @@ -112,6 +113,7 @@ class TaskMateCoordinator( WishlistMixin, FulfilmentMixin, PersonalBestsMixin, + BoostsMixin, BountiesMixin, AuctionsMixin, RecapsMixin, @@ -512,6 +514,7 @@ async def async_run_minutely_checks(self) -> None: self.async_draw_due_raffles, self.async_check_pet_growth, self.async_check_owed_rewards, + self.async_check_boosts, ): try: await step() diff --git a/custom_components/taskmate/event.py b/custom_components/taskmate/event.py index c4121817..a61996f9 100644 --- a/custom_components/taskmate/event.py +++ b/custom_components/taskmate/event.py @@ -73,6 +73,8 @@ "bounty_posted": "taskmate_bounty_posted", "scheduled_change_applied": "taskmate_scheduled_change_applied", "raffle_drawn": notification_event_name(NOTIF_TYPE_RAFFLE_DRAWN), + "boost_started": "taskmate_boost_started", + "boost_ended": "taskmate_boost_ended", } # Payload keys never copied onto the entity: they are long or internal. diff --git a/custom_components/taskmate/family_services.py b/custom_components/taskmate/family_services.py index fad30f0d..6471087b 100644 --- a/custom_components/taskmate/family_services.py +++ b/custom_components/taskmate/family_services.py @@ -16,6 +16,7 @@ from homeassistant.helpers import config_validation as cv from .const import DOMAIN +from .coord_boosts import BOOST_MAX_MINUTES, BOOST_MAX_MULTIPLIER, BOOST_MIN_MULTIPLIER, BOOST_NAME_MAX from .coord_goals import WEEKLY_GOAL_MAX from .coord_pets import PET_NAME_MAX, PET_SPECIES from .coord_wellbeing import HELP_MESSAGE_MAX, MOODS @@ -31,9 +32,13 @@ SERVICE_DRAW_RAFFLE = "draw_raffle" SERVICE_ADOPT_PET = "adopt_pet" SERVICE_MARK_REWARD_DELIVERED = "mark_reward_delivered" +SERVICE_START_BOOST = "start_boost" +SERVICE_END_BOOST = "end_boost" FAMILY_SERVICES: tuple[str, ...] = ( SERVICE_MARK_REWARD_DELIVERED, + SERVICE_START_BOOST, + SERVICE_END_BOOST, SERVICE_REQUEST_HELP, SERVICE_ANSWER_HELP, SERVICE_LOG_MOOD, @@ -97,6 +102,20 @@ async def adopt_pet(coordinator, call): async def draw_raffle(coordinator, call): await coordinator.async_draw_raffle(call.data["raffle_id"]) + async def start_boost(coordinator, call): + await coordinator.async_start_boost( + multiplier=call.data["multiplier"], + minutes=call.data["minutes"], + chore_ids=call.data.get("chore_ids") or [], + child_ids=call.data.get("child_ids") or [], + starts_at=call.data.get("starts_at"), + name=call.data.get("name", ""), + notify=call.data.get("notify", True), + ) + + async def end_boost(coordinator, call): + await coordinator.async_end_boost(call.data["boost_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. @@ -169,6 +188,23 @@ async def mark_reward_delivered(call: ServiceCall) -> None: }, ), (SERVICE_DRAW_RAFFLE, draw_raffle, False, {vol.Required("raffle_id"): cv.string}), + ( + SERVICE_START_BOOST, + start_boost, + False, + { + vol.Required("multiplier"): vol.All( + vol.Coerce(float), vol.Range(min=BOOST_MIN_MULTIPLIER, max=BOOST_MAX_MULTIPLIER) + ), + vol.Required("minutes"): vol.All(vol.Coerce(int), vol.Range(min=1, max=BOOST_MAX_MINUTES)), + vol.Optional("chore_ids", default=[]): vol.All(cv.ensure_list, [cv.string]), + vol.Optional("child_ids", default=[]): vol.All(cv.ensure_list, [cv.string]), + vol.Optional("starts_at"): cv.datetime, + vol.Optional("name", default=""): vol.All(cv.string, vol.Length(max=BOOST_NAME_MAX)), + vol.Optional("notify", default=True): cv.boolean, + }, + ), + (SERVICE_END_BOOST, end_boost, False, {vol.Required("boost_id"): cv.string}), ( SERVICE_ADOPT_PET, adopt_pet, diff --git a/custom_components/taskmate/models.py b/custom_components/taskmate/models.py index aacfcce3..17a1335f 100644 --- a/custom_components/taskmate/models.py +++ b/custom_components/taskmate/models.py @@ -1178,6 +1178,12 @@ class ChoreCompletion: # Set on a parent completion (child_id "__parent__") that dismisses the # chore for this one child only (#1096). Empty = the whole pool. for_child_id: str = "" + # Boost windows: the multiplier a boost applied to submitted_points, or 0. + # ``boost_stacked`` is True when it was stacked on the weekend/birthday + # bonuses; otherwise the boost replaced them ("highest wins") and approval + # must not add them again. + boost_multiplier: float = 0.0 + boost_stacked: bool = False @classmethod def from_dict(cls, data: dict[str, Any]) -> ChoreCompletion: @@ -1206,6 +1212,8 @@ def from_dict(cls, data: dict[str, Any]) -> ChoreCompletion: quantity=_clamp_int(data.get("quantity"), 0, MAX_QUANTITY), auto_approved=data.get("auto_approved") is True, for_child_id=str(data.get("for_child_id", "") or ""), + boost_multiplier=float(data.get("boost_multiplier", 0) or 0), + boost_stacked=bool(data.get("boost_stacked", False)), ) def to_dict(self) -> dict[str, Any]: @@ -1240,6 +1248,10 @@ def to_dict(self) -> dict[str, Any]: data["auto_approved"] = True if self.for_child_id: data["for_child_id"] = self.for_child_id + if self.boost_multiplier: + data["boost_multiplier"] = self.boost_multiplier + if self.boost_stacked: + data["boost_stacked"] = True return data diff --git a/custom_components/taskmate/notify_strings.py b/custom_components/taskmate/notify_strings.py index 132d8e60..b3623ba4 100644 --- a/custom_components/taskmate/notify_strings.py +++ b/custom_components/taskmate/notify_strings.py @@ -76,6 +76,8 @@ "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}.", + "boost_started": "⚡ Boost! {chores_text} pay ×{multiplier} for the next {minutes} minutes.", + "boost_all_chores": "All chores", # 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", diff --git a/custom_components/taskmate/sensor.py b/custom_components/taskmate/sensor.py index 1c1aad22..3e5d6c15 100644 --- a/custom_components/taskmate/sensor.py +++ b/custom_components/taskmate/sensor.py @@ -1107,7 +1107,7 @@ class TaskMateOverallStatsSensor(_CachedAttrsSensor): # The per-child summary grows with the family; the scalars stay recorded # so history/statistics on them keep working (#817). _unrecorded_attributes = frozenset( - {"children", "vacation_periods", "season_champions", "companion_entities", "badge_entities"} + {"children", "vacation_periods", "season_champions", "companion_entities", "badge_entities", "boosts"} ) def __init__( @@ -1195,7 +1195,10 @@ def _build_attributes(self) -> dict: time_boundaries[f"{cat}_start"] = period["start"] if period else def_start time_boundaries[f"{cat}_end"] = period["end"] if period else def_end + boosts = self.coordinator.boosts_state() return { + # Boost windows: live and upcoming boosts, only when there is one. + **({"boosts": boosts} if boosts else {}), "today_day_of_week": today_dow, "streak_reset_mode": settings.get("streak_reset_mode", "reset"), "weekend_multiplier": _safe_float(settings.get("weekend_multiplier"), 2.0), diff --git a/custom_components/taskmate/services.yaml b/custom_components/taskmate/services.yaml index 80c7407b..155c0bcc 100644 --- a/custom_components/taskmate/services.yaml +++ b/custom_components/taskmate/services.yaml @@ -1873,6 +1873,72 @@ mark_reward_delivered: selector: text: +start_boost: + name: Start a boost + description: Open a boost window. Chores done inside it pay the multiplier. Leave chore_ids or child_ids empty for all of them. + fields: + multiplier: + name: Multiplier + description: What the covered chores pay, e.g. 2 for double (1.1 to 5) + required: true + selector: + number: + min: 1.1 + max: 5 + step: 0.1 + mode: box + minutes: + name: Minutes + description: How long the boost lasts + required: true + selector: + number: + min: 1 + max: 1440 + mode: box + chore_ids: + name: Chore IDs + description: The chores it covers (empty = all chores) + required: false + selector: + object: + child_ids: + name: Child IDs + description: The children it covers (empty = everyone) + required: false + selector: + object: + starts_at: + name: Starts at + description: Start later instead of now (up to a week ahead) + required: false + selector: + datetime: + name: + name: Name + description: Optional label shown on the cards, e.g. "Guests at six" + required: false + selector: + text: + notify: + name: Notify + description: Send the boost push to the children it covers (when that notification is switched on) + required: false + default: true + selector: + boolean: + +end_boost: + name: End a boost + description: Stop a boost early, or call off one that hasn't started yet. + fields: + boost_id: + name: Boost ID + description: The boost, from the boosts attribute of sensor.taskmate_overview + required: true + 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 d515d579..d59c7dd5 100644 --- a/custom_components/taskmate/strings.json +++ b/custom_components/taskmate/strings.json @@ -1462,6 +1462,50 @@ "description": "The child confirming they received it (leave empty when a parent marks it)" } } + }, + "start_boost": { + "name": "Start a boost", + "description": "Open a boost window. Chores done inside it pay the multiplier. Leave chore_ids or child_ids empty for all of them.", + "fields": { + "multiplier": { + "name": "Multiplier", + "description": "What the covered chores pay, e.g. 2 for double (1.1 to 5)" + }, + "minutes": { + "name": "Minutes", + "description": "How long the boost lasts" + }, + "chore_ids": { + "name": "Chore IDs", + "description": "The chores it covers (empty = all chores)" + }, + "child_ids": { + "name": "Child IDs", + "description": "The children it covers (empty = everyone)" + }, + "starts_at": { + "name": "Starts at", + "description": "Start later instead of now (up to a week ahead)" + }, + "name": { + "name": "Name", + "description": "Optional label shown on the cards, e.g. \"Guests at six\"" + }, + "notify": { + "name": "Notify", + "description": "Send the boost push to the children it covers (when that notification is switched on)" + } + } + }, + "end_boost": { + "name": "End a boost", + "description": "Stop a boost early, or call off one that hasn't started yet.", + "fields": { + "boost_id": { + "name": "Boost ID", + "description": "The boost, from the boosts attribute of sensor.taskmate_overview" + } + } } }, "entity": { @@ -1546,7 +1590,9 @@ "season_champion": "Season champion", "bounty_posted": "Bounty posted", "scheduled_change_applied": "Scheduled change applied", - "raffle_drawn": "Raffle drawn" + "raffle_drawn": "Raffle drawn", + "boost_started": "Boost started", + "boost_ended": "Boost ended" } } } diff --git a/custom_components/taskmate/translations/da.json b/custom_components/taskmate/translations/da.json index 452e2ca7..fabe7cbe 100644 --- a/custom_components/taskmate/translations/da.json +++ b/custom_components/taskmate/translations/da.json @@ -1462,6 +1462,50 @@ "description": "Barnet, der bekræfter at have fået den (lad stå tomt, når en forælder markerer)" } } + }, + "start_boost": { + "name": "Start et boost", + "description": "Åbner et boost-vindue. Opgaver udført i det giver multiplikatoren. Lad chore_ids eller child_ids stå tomme for alle.", + "fields": { + "multiplier": { + "name": "Multiplikator", + "description": "Hvad opgaverne giver, f.eks. 2 for dobbelt (1,1 til 5)" + }, + "minutes": { + "name": "Minutter", + "description": "Hvor længe boostet varer" + }, + "chore_ids": { + "name": "Opgave-ID'er", + "description": "De opgaver, det gælder for (tom = alle)" + }, + "child_ids": { + "name": "Barn-ID'er", + "description": "De børn, det gælder for (tom = alle)" + }, + "starts_at": { + "name": "Starter", + "description": "Start senere i stedet for nu (op til en uge frem)" + }, + "name": { + "name": "Navn", + "description": "Valgfri tekst på kortene, f.eks. \"Gæster kl. 18\"" + }, + "notify": { + "name": "Giv besked", + "description": "Send boost-beskeden til børnene (når den besked er slået til)" + } + } + }, + "end_boost": { + "name": "Afslut et boost", + "description": "Stop et boost før tid, eller aflys et, der ikke er begyndt endnu.", + "fields": { + "boost_id": { + "name": "Boost-ID", + "description": "Boostet, fra attributten boosts på sensor.taskmate_overview" + } + } } }, "entity": { @@ -1546,7 +1590,9 @@ "season_champion": "Sæsonmester", "bounty_posted": "Dusør slået op", "scheduled_change_applied": "Planlagt ændring udført", - "raffle_drawn": "Lodtrækning trukket" + "raffle_drawn": "Lodtrækning trukket", + "boost_started": "Boost startet", + "boost_ended": "Boost slut" } } } diff --git a/custom_components/taskmate/translations/de.json b/custom_components/taskmate/translations/de.json index 3d2d5cf5..ffb0b0ed 100644 --- a/custom_components/taskmate/translations/de.json +++ b/custom_components/taskmate/translations/de.json @@ -1462,6 +1462,50 @@ "description": "Das Kind, das den Erhalt bestätigt (leer lassen, wenn ein Elternteil markiert)" } } + }, + "start_boost": { + "name": "Boost starten", + "description": "Öffnet ein Boost-Fenster. Aufgaben darin zählen mit dem Multiplikator. chore_ids oder child_ids leer lassen für alle.", + "fields": { + "multiplier": { + "name": "Multiplikator", + "description": "Was die Aufgaben zählen, z. B. 2 für doppelt (1,1 bis 5)" + }, + "minutes": { + "name": "Minuten", + "description": "Wie lange der Boost dauert" + }, + "chore_ids": { + "name": "Aufgaben-IDs", + "description": "Die Aufgaben, für die er gilt (leer = alle)" + }, + "child_ids": { + "name": "Kind-IDs", + "description": "Die Kinder, für die er gilt (leer = alle)" + }, + "starts_at": { + "name": "Beginnt um", + "description": "Später statt jetzt beginnen (bis eine Woche im Voraus)" + }, + "name": { + "name": "Name", + "description": "Optionale Bezeichnung auf den Karten, z. B. „Besuch um sechs“" + }, + "notify": { + "name": "Benachrichtigen", + "description": "Die Boost-Nachricht an die Kinder schicken (wenn diese Benachrichtigung eingeschaltet ist)" + } + } + }, + "end_boost": { + "name": "Boost beenden", + "description": "Einen Boost vorzeitig beenden oder einen noch nicht begonnenen absagen.", + "fields": { + "boost_id": { + "name": "Boost-ID", + "description": "Der Boost, aus dem Attribut boosts von sensor.taskmate_overview" + } + } } }, "entity": { @@ -1546,7 +1590,9 @@ "season_champion": "Saisonsieger", "bounty_posted": "Kopfgeld ausgeschrieben", "scheduled_change_applied": "Geplante Änderung angewendet", - "raffle_drawn": "Verlosung gezogen" + "raffle_drawn": "Verlosung gezogen", + "boost_started": "Boost gestartet", + "boost_ended": "Boost beendet" } } } diff --git a/custom_components/taskmate/translations/en-GB.json b/custom_components/taskmate/translations/en-GB.json index d515d579..d59c7dd5 100644 --- a/custom_components/taskmate/translations/en-GB.json +++ b/custom_components/taskmate/translations/en-GB.json @@ -1462,6 +1462,50 @@ "description": "The child confirming they received it (leave empty when a parent marks it)" } } + }, + "start_boost": { + "name": "Start a boost", + "description": "Open a boost window. Chores done inside it pay the multiplier. Leave chore_ids or child_ids empty for all of them.", + "fields": { + "multiplier": { + "name": "Multiplier", + "description": "What the covered chores pay, e.g. 2 for double (1.1 to 5)" + }, + "minutes": { + "name": "Minutes", + "description": "How long the boost lasts" + }, + "chore_ids": { + "name": "Chore IDs", + "description": "The chores it covers (empty = all chores)" + }, + "child_ids": { + "name": "Child IDs", + "description": "The children it covers (empty = everyone)" + }, + "starts_at": { + "name": "Starts at", + "description": "Start later instead of now (up to a week ahead)" + }, + "name": { + "name": "Name", + "description": "Optional label shown on the cards, e.g. \"Guests at six\"" + }, + "notify": { + "name": "Notify", + "description": "Send the boost push to the children it covers (when that notification is switched on)" + } + } + }, + "end_boost": { + "name": "End a boost", + "description": "Stop a boost early, or call off one that hasn't started yet.", + "fields": { + "boost_id": { + "name": "Boost ID", + "description": "The boost, from the boosts attribute of sensor.taskmate_overview" + } + } } }, "entity": { @@ -1546,7 +1590,9 @@ "season_champion": "Season champion", "bounty_posted": "Bounty posted", "scheduled_change_applied": "Scheduled change applied", - "raffle_drawn": "Raffle drawn" + "raffle_drawn": "Raffle drawn", + "boost_started": "Boost started", + "boost_ended": "Boost ended" } } } diff --git a/custom_components/taskmate/translations/en.json b/custom_components/taskmate/translations/en.json index d515d579..d59c7dd5 100644 --- a/custom_components/taskmate/translations/en.json +++ b/custom_components/taskmate/translations/en.json @@ -1462,6 +1462,50 @@ "description": "The child confirming they received it (leave empty when a parent marks it)" } } + }, + "start_boost": { + "name": "Start a boost", + "description": "Open a boost window. Chores done inside it pay the multiplier. Leave chore_ids or child_ids empty for all of them.", + "fields": { + "multiplier": { + "name": "Multiplier", + "description": "What the covered chores pay, e.g. 2 for double (1.1 to 5)" + }, + "minutes": { + "name": "Minutes", + "description": "How long the boost lasts" + }, + "chore_ids": { + "name": "Chore IDs", + "description": "The chores it covers (empty = all chores)" + }, + "child_ids": { + "name": "Child IDs", + "description": "The children it covers (empty = everyone)" + }, + "starts_at": { + "name": "Starts at", + "description": "Start later instead of now (up to a week ahead)" + }, + "name": { + "name": "Name", + "description": "Optional label shown on the cards, e.g. \"Guests at six\"" + }, + "notify": { + "name": "Notify", + "description": "Send the boost push to the children it covers (when that notification is switched on)" + } + } + }, + "end_boost": { + "name": "End a boost", + "description": "Stop a boost early, or call off one that hasn't started yet.", + "fields": { + "boost_id": { + "name": "Boost ID", + "description": "The boost, from the boosts attribute of sensor.taskmate_overview" + } + } } }, "entity": { @@ -1546,7 +1590,9 @@ "season_champion": "Season champion", "bounty_posted": "Bounty posted", "scheduled_change_applied": "Scheduled change applied", - "raffle_drawn": "Raffle drawn" + "raffle_drawn": "Raffle drawn", + "boost_started": "Boost started", + "boost_ended": "Boost ended" } } } diff --git a/custom_components/taskmate/translations/fr.json b/custom_components/taskmate/translations/fr.json index 9487970e..e7ad25e6 100644 --- a/custom_components/taskmate/translations/fr.json +++ b/custom_components/taskmate/translations/fr.json @@ -1462,6 +1462,50 @@ "description": "L’enfant qui confirme l’avoir reçue (laisser vide quand un parent la marque)" } } + }, + "start_boost": { + "name": "Lancer un boost", + "description": "Ouvre une fenêtre de boost. Les tâches faites pendant ce temps rapportent le multiplicateur. Laissez chore_ids ou child_ids vides pour tout le monde.", + "fields": { + "multiplier": { + "name": "Multiplicateur", + "description": "Ce que rapportent les tâches, p. ex. 2 pour le double (1,1 à 5)" + }, + "minutes": { + "name": "Minutes", + "description": "Durée du boost" + }, + "chore_ids": { + "name": "ID des tâches", + "description": "Les tâches concernées (vide = toutes)" + }, + "child_ids": { + "name": "ID des enfants", + "description": "Les enfants concernés (vide = tous)" + }, + "starts_at": { + "name": "Début", + "description": "Commencer plus tard plutôt que maintenant (jusqu’à une semaine à l’avance)" + }, + "name": { + "name": "Nom", + "description": "Libellé facultatif affiché sur les cartes, p. ex. « Invités à 18 h »" + }, + "notify": { + "name": "Notifier", + "description": "Envoyer la notification du boost aux enfants concernés (si elle est activée)" + } + } + }, + "end_boost": { + "name": "Terminer un boost", + "description": "Arrêter un boost plus tôt, ou annuler un boost qui n’a pas encore commencé.", + "fields": { + "boost_id": { + "name": "ID du boost", + "description": "Le boost, depuis l’attribut boosts de sensor.taskmate_overview" + } + } } }, "entity": { @@ -1546,7 +1590,9 @@ "season_champion": "Champion de la saison", "bounty_posted": "Prime publiée", "scheduled_change_applied": "Modification planifiée appliquée", - "raffle_drawn": "Tombola tirée" + "raffle_drawn": "Tombola tirée", + "boost_started": "Boost lancé", + "boost_ended": "Boost terminé" } } } diff --git a/custom_components/taskmate/translations/nb.json b/custom_components/taskmate/translations/nb.json index d5f76fd4..a99fdae1 100644 --- a/custom_components/taskmate/translations/nb.json +++ b/custom_components/taskmate/translations/nb.json @@ -1462,6 +1462,50 @@ "description": "Barnet som bekrefter å ha fått den (la stå tom når en forelder merker)" } } + }, + "start_boost": { + "name": "Start et boost", + "description": "Åpner et boost-vindu. Oppgaver gjort i det gir multiplikatoren. La chore_ids eller child_ids stå tomme for alle.", + "fields": { + "multiplier": { + "name": "Multiplikator", + "description": "Hva oppgavene gir, f.eks. 2 for dobbelt (1,1 til 5)" + }, + "minutes": { + "name": "Minutter", + "description": "Hvor lenge boostet varer" + }, + "chore_ids": { + "name": "Oppgave-ID-er", + "description": "Oppgavene det gjelder (tom = alle)" + }, + "child_ids": { + "name": "Barn-ID-er", + "description": "Barna det gjelder (tom = alle)" + }, + "starts_at": { + "name": "Starter", + "description": "Start senere i stedet for nå (opptil en uke frem)" + }, + "name": { + "name": "Navn", + "description": "Valgfri tekst på kortene, f.eks. «Gjester kl. 18»" + }, + "notify": { + "name": "Varsle", + "description": "Send boost-varselet til barna (når det varselet er på)" + } + } + }, + "end_boost": { + "name": "Avslutt et boost", + "description": "Stopp et boost tidlig, eller avlys et som ikke har begynt.", + "fields": { + "boost_id": { + "name": "Boost-ID", + "description": "Boostet, fra attributtet boosts på sensor.taskmate_overview" + } + } } }, "entity": { @@ -1546,7 +1590,9 @@ "season_champion": "Sesongmester", "bounty_posted": "Dusør lagt ut", "scheduled_change_applied": "Planlagt endring utført", - "raffle_drawn": "Loddtrekning trukket" + "raffle_drawn": "Loddtrekning trukket", + "boost_started": "Boost startet", + "boost_ended": "Boost avsluttet" } } } diff --git a/custom_components/taskmate/translations/nl.json b/custom_components/taskmate/translations/nl.json index 4adf6bed..9ebc480c 100644 --- a/custom_components/taskmate/translations/nl.json +++ b/custom_components/taskmate/translations/nl.json @@ -1462,6 +1462,50 @@ "description": "Het kind dat bevestigt dat het de beloning heeft gekregen (leeg laten als een ouder het markeert)" } } + }, + "start_boost": { + "name": "Boost starten", + "description": "Open een boostvenster. Taken die je daarin doet, leveren de vermenigvuldiger op. Laat chore_ids of child_ids leeg voor allemaal.", + "fields": { + "multiplier": { + "name": "Vermenigvuldiger", + "description": "Wat de gekozen taken opleveren, bijv. 2 voor dubbel (1,1 tot 5)" + }, + "minutes": { + "name": "Minuten", + "description": "Hoe lang de boost duurt" + }, + "chore_ids": { + "name": "Taak-ID's", + "description": "De taken die meedoen (leeg = alle taken)" + }, + "child_ids": { + "name": "Kind-ID's", + "description": "De kinderen die meedoen (leeg = iedereen)" + }, + "starts_at": { + "name": "Begint om", + "description": "Later beginnen in plaats van nu (tot een week vooruit)" + }, + "name": { + "name": "Naam", + "description": "Optioneel label op de kaarten, bijv. \"Om zes uur visite\"" + }, + "notify": { + "name": "Melden", + "description": "Stuur de boostmelding naar de kinderen die meedoen (als die melding aanstaat)" + } + } + }, + "end_boost": { + "name": "Boost beëindigen", + "description": "Stop een boost eerder, of annuleer er een die nog niet begonnen is.", + "fields": { + "boost_id": { + "name": "Boost-ID", + "description": "De boost, uit het attribuut boosts van sensor.taskmate_overview" + } + } } }, "entity": { @@ -1546,7 +1590,9 @@ "season_champion": "Maandkampioen", "bounty_posted": "Klus geplaatst", "scheduled_change_applied": "Geplande wijziging toegepast", - "raffle_drawn": "Loterij getrokken" + "raffle_drawn": "Loterij getrokken", + "boost_started": "Boost gestart", + "boost_ended": "Boost beëindigd" } } } diff --git a/custom_components/taskmate/translations/nn.json b/custom_components/taskmate/translations/nn.json index 5b0b2be7..90c4cf13 100644 --- a/custom_components/taskmate/translations/nn.json +++ b/custom_components/taskmate/translations/nn.json @@ -1462,6 +1462,50 @@ "description": "Barnet som stadfestar å ha fått henne (la stå tom når ein forelder merkjer)" } } + }, + "start_boost": { + "name": "Start eit boost", + "description": "Opnar eit boost-vindauge. Oppgåver gjorde i det gjev multiplikatoren. La chore_ids eller child_ids stå tomme for alle.", + "fields": { + "multiplier": { + "name": "Multiplikator", + "description": "Kva oppgåvene gjev, t.d. 2 for dobbelt (1,1 til 5)" + }, + "minutes": { + "name": "Minutt", + "description": "Kor lenge boostet varer" + }, + "chore_ids": { + "name": "Oppgåve-ID-ar", + "description": "Oppgåvene det gjeld (tom = alle)" + }, + "child_ids": { + "name": "Barn-ID-ar", + "description": "Borna det gjeld (tom = alle)" + }, + "starts_at": { + "name": "Startar", + "description": "Start seinare i staden for no (opptil ei veke fram)" + }, + "name": { + "name": "Namn", + "description": "Valfri tekst på korta, t.d. «Gjester kl. 18»" + }, + "notify": { + "name": "Varsle", + "description": "Send boost-varselet til borna (når det varselet er på)" + } + } + }, + "end_boost": { + "name": "Avslutt eit boost", + "description": "Stopp eit boost tidleg, eller avlys eitt som ikkje har byrja.", + "fields": { + "boost_id": { + "name": "Boost-ID", + "description": "Boostet, frå attributtet boosts på sensor.taskmate_overview" + } + } } }, "entity": { @@ -1546,7 +1590,9 @@ "season_champion": "Sesongmeister", "bounty_posted": "Dusør lagd ut", "scheduled_change_applied": "Planlagd endring utført", - "raffle_drawn": "Loddtrekning trekt" + "raffle_drawn": "Loddtrekning trekt", + "boost_started": "Boost starta", + "boost_ended": "Boost slutt" } } } diff --git a/custom_components/taskmate/translations/pl.json b/custom_components/taskmate/translations/pl.json index cb911a4c..21282b10 100644 --- a/custom_components/taskmate/translations/pl.json +++ b/custom_components/taskmate/translations/pl.json @@ -1462,6 +1462,50 @@ "description": "Dziecko potwierdzające odbiór (zostaw puste, gdy oznacza rodzic)" } } + }, + "start_boost": { + "name": "Rozpocznij boost", + "description": "Otwiera okno boostu. Obowiązki wykonane w tym czasie są warte mnożnik. Zostaw chore_ids lub child_ids puste dla wszystkich.", + "fields": { + "multiplier": { + "name": "Mnożnik", + "description": "Ile są warte obowiązki, np. 2 to podwójnie (1,1 do 5)" + }, + "minutes": { + "name": "Minuty", + "description": "Jak długo trwa boost" + }, + "chore_ids": { + "name": "ID obowiązków", + "description": "Obowiązki objęte boostem (puste = wszystkie)" + }, + "child_ids": { + "name": "ID dzieci", + "description": "Dzieci objęte boostem (puste = wszystkie)" + }, + "starts_at": { + "name": "Początek", + "description": "Zacznij później zamiast teraz (do tygodnia naprzód)" + }, + "name": { + "name": "Nazwa", + "description": "Opcjonalna etykieta na kartach, np. „Goście o 18”" + }, + "notify": { + "name": "Powiadom", + "description": "Wyślij powiadomienie o boostcie dzieciom (gdy jest włączone)" + } + } + }, + "end_boost": { + "name": "Zakończ boost", + "description": "Zakończ boost wcześniej albo odwołaj taki, który jeszcze się nie zaczął.", + "fields": { + "boost_id": { + "name": "ID boostu", + "description": "Boost z atrybutu boosts w sensor.taskmate_overview" + } + } } }, "entity": { @@ -1546,7 +1590,9 @@ "season_champion": "Mistrz sezonu", "bounty_posted": "Ogłoszono nagrodę", "scheduled_change_applied": "Zastosowano zaplanowaną zmianę", - "raffle_drawn": "Rozlosowano" + "raffle_drawn": "Rozlosowano", + "boost_started": "Boost rozpoczęty", + "boost_ended": "Boost zakończony" } } } diff --git a/custom_components/taskmate/translations/pt-BR.json b/custom_components/taskmate/translations/pt-BR.json index d8e317f2..9a075f66 100644 --- a/custom_components/taskmate/translations/pt-BR.json +++ b/custom_components/taskmate/translations/pt-BR.json @@ -1462,6 +1462,50 @@ "description": "A criança que confirma ter recebido (deixe vazio quando um dos pais marca)" } } + }, + "start_boost": { + "name": "Iniciar um boost", + "description": "Abre uma janela de boost. As tarefas feitas nela valem o multiplicador. Deixe chore_ids ou child_ids vazios para todos.", + "fields": { + "multiplier": { + "name": "Multiplicador", + "description": "Quanto valem as tarefas, ex. 2 para o dobro (1,1 a 5)" + }, + "minutes": { + "name": "Minutos", + "description": "Quanto tempo dura o boost" + }, + "chore_ids": { + "name": "IDs das tarefas", + "description": "As tarefas incluídas (vazio = todas)" + }, + "child_ids": { + "name": "IDs das crianças", + "description": "As crianças incluídas (vazio = todas)" + }, + "starts_at": { + "name": "Começa às", + "description": "Começar mais tarde em vez de agora (até uma semana antes)" + }, + "name": { + "name": "Nome", + "description": "Rótulo opcional nos cards, ex. \"Visitas às seis\"" + }, + "notify": { + "name": "Notificar", + "description": "Enviar a notificação do boost às crianças (quando estiver ligada)" + } + } + }, + "end_boost": { + "name": "Encerrar um boost", + "description": "Parar um boost antes ou cancelar um que ainda não começou.", + "fields": { + "boost_id": { + "name": "ID do boost", + "description": "O boost, do atributo boosts de sensor.taskmate_overview" + } + } } }, "entity": { @@ -1546,7 +1590,9 @@ "season_champion": "Campeão da temporada", "bounty_posted": "Recompensa publicada", "scheduled_change_applied": "Alteração agendada aplicada", - "raffle_drawn": "Sorteio realizado" + "raffle_drawn": "Sorteio realizado", + "boost_started": "Boost iniciado", + "boost_ended": "Boost encerrado" } } } diff --git a/custom_components/taskmate/translations/pt.json b/custom_components/taskmate/translations/pt.json index 33e70701..8aef8edf 100644 --- a/custom_components/taskmate/translations/pt.json +++ b/custom_components/taskmate/translations/pt.json @@ -1462,6 +1462,50 @@ "description": "A criança que confirma ter recebido (deixar vazio quando é um dos pais a marcar)" } } + }, + "start_boost": { + "name": "Iniciar um boost", + "description": "Abre uma janela de boost. As tarefas feitas nela valem o multiplicador. Deixa chore_ids ou child_ids vazios para todos.", + "fields": { + "multiplier": { + "name": "Multiplicador", + "description": "Quanto valem as tarefas, p. ex. 2 para o dobro (1,1 a 5)" + }, + "minutes": { + "name": "Minutos", + "description": "Quanto tempo dura o boost" + }, + "chore_ids": { + "name": "IDs das tarefas", + "description": "As tarefas abrangidas (vazio = todas)" + }, + "child_ids": { + "name": "IDs das crianças", + "description": "As crianças abrangidas (vazio = todas)" + }, + "starts_at": { + "name": "Começa às", + "description": "Começar mais tarde em vez de agora (até uma semana antes)" + }, + "name": { + "name": "Nome", + "description": "Etiqueta opcional nos cartões, p. ex. \"Visitas às seis\"" + }, + "notify": { + "name": "Notificar", + "description": "Enviar a notificação do boost às crianças (quando estiver ligada)" + } + } + }, + "end_boost": { + "name": "Terminar um boost", + "description": "Parar um boost mais cedo ou cancelar um que ainda não começou.", + "fields": { + "boost_id": { + "name": "ID do boost", + "description": "O boost, do atributo boosts de sensor.taskmate_overview" + } + } } }, "entity": { @@ -1546,7 +1590,9 @@ "season_champion": "Campeão da temporada", "bounty_posted": "Recompensa publicada", "scheduled_change_applied": "Alteração agendada aplicada", - "raffle_drawn": "Sorteio realizado" + "raffle_drawn": "Sorteio realizado", + "boost_started": "Boost iniciado", + "boost_ended": "Boost terminado" } } } diff --git a/custom_components/taskmate/websocket.py b/custom_components/taskmate/websocket.py index 19b89425..5b9407f6 100644 --- a/custom_components/taskmate/websocket.py +++ b/custom_components/taskmate/websocket.py @@ -89,6 +89,7 @@ ) from .coord_auto_approve import AUTO_APPROVE_MAX_HOURS from .coord_birthdays import normalize_birthday +from .coord_boosts import BOOST_STACKING_MODES from .coord_inspections import INSPECTION_BONUS_MAX, INSPECTION_FAIL_MODES, INSPECTION_WINDOWS from .coord_pets import PET_BOOL_SETTINGS, PET_INT_SETTINGS from .coord_teamwork import teamwork_config_error @@ -497,6 +498,11 @@ def _build_state_snapshot(coordinator: TaskMateCoordinator) -> dict[str, Any]: "help_requests": coordinator.open_help_requests(), # Reward fulfilment (#1098): redeemed rewards still to be handed over. "owed_rewards": coordinator.owed_rewards(), + # Boost windows: live and upcoming boosts, the recurring rules, and + # the tablets the Settings page can offer to wake. + "boosts": coordinator.boosts_state(), + "boost_rules": [dict(r) for r in coordinator.boost_rules()], + "boost_wake_candidates": coordinator.boost_wake_candidates(), "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(), @@ -1990,6 +1996,9 @@ async def _ws_remove_custom_sound(hass, connection, msg, coordinator): "weekly_goal_bonus", "reward_fulfilment_enabled", "reward_fulfilment_reminder_days", + "boost_stacking", + "boost_wake_screens", + "boost_wake_devices", "pets_enabled", *PET_INT_SETTINGS, *PET_BOOL_SETTINGS, @@ -2283,6 +2292,11 @@ 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("boost_stacking"): vol.In(BOOST_STACKING_MODES), + vol.Optional("boost_wake_screens"): vol.All([vol.All(str, vol.Match(r"^switch\.[a-z0-9_]+$"))], vol.Length(max=10)), + vol.Optional("boost_wake_devices"): vol.All( + [vol.All(str, vol.Match(r"^mobile_app_[a-z0-9_]+$"))], vol.Length(max=10) + ), vol.Optional("reward_fulfilment_reminder_days"): vol.All( vol.Coerce(int), vol.Range(min=0, max=REWARD_FULFILMENT_REMINDER_MAX_DAYS) ), @@ -3362,6 +3376,7 @@ def async_register_websocket_commands(hass: HomeAssistant) -> None: # build on this module's _admin_only / _get_coordinator helpers. from .kiosk import KIOSK_COMMANDS from .websocket_auctions import AUCTION_COMMANDS + from .websocket_boosts import BOOST_COMMANDS from .websocket_inspections import INSPECTION_COMMANDS from .websocket_raffles import RAFFLE_COMMANDS from .websocket_recaps import RECAP_COMMANDS @@ -3374,6 +3389,7 @@ def async_register_websocket_commands(hass: HomeAssistant) -> None: *SETUP_COMMANDS, *AUCTION_COMMANDS, *RAFFLE_COMMANDS, + *BOOST_COMMANDS, ) for cmd in (*_COMMANDS, *extra): websocket_api.async_register_command(hass, cmd) diff --git a/custom_components/taskmate/websocket_boosts.py b/custom_components/taskmate/websocket_boosts.py new file mode 100644 index 00000000..891b449d --- /dev/null +++ b/custom_components/taskmate/websocket_boosts.py @@ -0,0 +1,65 @@ +"""WebSocket commands for boost windows — the admin panel's, admin-only like every panel command. + +Starting and ending a boost from the panel goes through the ``start_boost`` / +``end_boost`` services (parent gate), the same door automations use. These +commands cover what only the panel does: the recurring rules and a test wake +of the chosen tablets. +""" + +from __future__ import annotations + +import voluptuous as vol +from homeassistant.components import websocket_api + +from .coord_boosts import BOOST_MAX_MULTIPLIER, BOOST_MIN_MULTIPLIER, BOOST_NAME_MAX +from .websocket import _admin_only + +WS_BOOST_RULE_SAVE = "taskmate/boosts/save_rule" +WS_BOOST_RULE_DELETE = "taskmate/boosts/delete_rule" +WS_BOOST_TEST_WAKE = "taskmate/boosts/test_wake" + + +@websocket_api.websocket_command( + { + vol.Required("type"): WS_BOOST_RULE_SAVE, + vol.Optional("rule_id"): str, + vol.Optional("name", default=""): vol.All(str, vol.Length(max=BOOST_NAME_MAX)), + vol.Required("days"): vol.All([vol.All(vol.Coerce(int), vol.Range(min=0, max=6))], vol.Length(max=7)), + vol.Required("start"): vol.Match(r"^([01]\d|2[0-3]):[0-5]\d$"), + vol.Required("end"): vol.Match(r"^([01]\d|2[0-3]):[0-5]\d$"), + vol.Required("multiplier"): vol.All( + vol.Coerce(float), vol.Range(min=BOOST_MIN_MULTIPLIER, max=BOOST_MAX_MULTIPLIER) + ), + vol.Optional("chore_ids", default=[]): [str], + vol.Optional("child_ids", default=[]): [str], + vol.Optional("notify", default=True): bool, + vol.Optional("enabled", default=True): bool, + } +) +@websocket_api.async_response +@_admin_only +async def ws_boost_rule_save(hass, connection, msg, coordinator): + data = {k: v for k, v in msg.items() if k not in ("id", "type", "rule_id")} + if msg.get("rule_id"): + data["id"] = msg["rule_id"] + rule = await coordinator.async_save_boost_rule(data) + connection.send_result(msg["id"], {"rule": rule}) + + +@websocket_api.websocket_command({vol.Required("type"): WS_BOOST_RULE_DELETE, vol.Required("rule_id"): str}) +@websocket_api.async_response +@_admin_only +async def ws_boost_rule_delete(hass, connection, msg, coordinator): + await coordinator.async_delete_boost_rule(msg["rule_id"]) + connection.send_result(msg["id"], {}) + + +@websocket_api.websocket_command({vol.Required("type"): WS_BOOST_TEST_WAKE}) +@websocket_api.async_response +@_admin_only +async def ws_boost_test_wake(hass, connection, msg, coordinator): + sent = await coordinator.async_wake_boost_screens() + connection.send_result(msg["id"], {"sent": sent}) + + +BOOST_COMMANDS = (ws_boost_rule_save, ws_boost_rule_delete, ws_boost_test_wake) diff --git a/custom_components/taskmate/www/locales/da.json b/custom_components/taskmate/www/locales/da.json index 1beedc15..dd01c57d 100644 --- a/custom_components/taskmate/www/locales/da.json +++ b/custom_components/taskmate/www/locales/da.json @@ -3480,5 +3480,94 @@ "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" + "bests.record_best_single_award": "Største belønning", + "notify.boost_started": "⚡ Boost! {chores_text} giver ×{multiplier} de næste {minutes} minutter.", + "notify.boost_all_chores": "Alle opgaver", + "notification.boost_started.name": "Boost startet", + "notification.boost_started.description": "Fortæller børnene, et boost gælder for, at det er begyndt, hvor stort det er, og hvor længe det varer.", + "child.boost_title": "Boost ×{multiplier}", + "child.boost_title_named": "Boost ×{multiplier} — {name}", + "child.boost_left": "tilbage", + "child.boost_next": "Næste boost: {time} · {what} ×{multiplier}", + "child.boost_all_chores": "Alle opgaver", + "child.boost_some_chores": "{first} og {count} mere", + "child.boost_takeover_title": "BOOST!", + "child.boost_takeover_all": "Alle opgaver giver ×{multiplier} de næste", + "child.boost_takeover_some": "giver ×{multiplier} de næste", + "child.boost_takeover_go": "Kom så! 🚀", + "child.editor.show_boost_alert": "Vis boost-besked, når et boost starter", + "kiosk.boost_title": "BOOST-TID!", + "kiosk.boost_sub": "{what} giver ×{multiplier} de næste", + "kiosk.boost_go": "Tryk for at starte! 🚀", + "kiosk.boost_strip": "Boost ×{multiplier} · {what}", + "kiosk.editor.show_boost_alert": "Fyld skærmen, når et boost starter", + "panel.boost_start": "Start et boost", + "panel.boost_card_title": "Boosts", + "panel.boost_live": "{count} aktive", + "panel.boost_ends": "slutter {time} ({minutes} min tilbage)", + "panel.boost_starts": "{start}–{end}", + "panel.boost_recurring": "gentages", + "panel.boost_end_now": "Stop nu", + "panel.boost_skip": "Spring over denne gang", + "panel.boost_call_off": "Aflys", + "panel.boost_all_chores": "Alle opgaver", + "panel.boost_n_chores": "{first} og {count} mere", + "panel.boost_everyone": "Alle", + "panel.boost_dialog_title": "Start et boost", + "panel.boost_when": "Hvornår", + "panel.boost_now": "Nu", + "panel.boost_later": "Senere i dag", + "panel.boost_pays": "Giver", + "panel.boost_for": "Varighed", + "panel.boost_minutes": "{count} min", + "panel.boost_hour": "1 time", + "panel.boost_hours": "{count} timer", + "panel.boost_which_chores": "Hvilke opgaver", + "panel.boost_who": "Hvem", + "panel.boost_name": "Navn (valgfrit)", + "panel.boost_name_ph": "Vises på kortene, f.eks. Gæster kl. 18", + "panel.boost_notify": "Giv børnene besked", + "panel.boost_notify_hint": "Besked på deres telefon, hvis beskeden Boost startet er slået til. Tablets vækkes uanset.", + "panel.boost_preview_now": "{what} giver ×{multiplier} for {who}, fra nu og {minutes} minutter frem (til {until}).", + "panel.boost_preview_later": "{what} giver ×{multiplier} for {who}, fra {time} i {minutes} minutter.", + "panel.boost_preview_rule": "Hver {days}, {start}–{end}: {what} giver ×{multiplier} for {who}.", + "panel.boost_later_invalid": "Vælg et senere tidspunkt i dag.", + "panel.boost_started": "Boost startet", + "panel.boost_scheduled": "Boost planlagt", + "panel.boost_ended": "Boost stoppet", + "panel.boost_settings_title": "Boost-vinduer", + "panel.boost_settings_hint": "Tidsbegrænsede multiplikatorer på valgte opgaver. Start et fra I dag, opret gentagne her, eller brug tjenesten taskmate.start_boost i en automatisering.", + "panel.boost_stacking": "I weekenden eller på en fødselsdag", + "panel.boost_stacking_hint": "De dage giver allerede en multiplikator. Vælg, hvordan et boost lægges oven i.", + "panel.boost_stacking_highest": "Den højeste gælder", + "panel.boost_stacking_highest_hint": "Et ×2-boost på en ×2-lørdag giver ×2. Den største gælder.", + "panel.boost_stacking_stack": "Læg dem sammen", + "panel.boost_stacking_stack_hint": "Et ×2-boost på en ×2-lørdag giver ×4.", + "panel.boost_wake_screens": "Væk disse tablets, når et boost starter", + "panel.boost_wake_screens_hint": "Fully Kiosk Browser: skærm tændt, pauseskærm slukket, browser forrest.", + "panel.boost_wake_screens_none": "Ingen Fully Kiosk-tablets fundet. Tilføj Fully Kiosk Browser-integrationen til Home Assistant for at vække en.", + "panel.boost_wake_devices": "Væk disse telefoner og tablets", + "panel.boost_wake_devices_hint": "Home Assistant Companion-appen på Android: sender command_screen_on.", + "panel.boost_wake_devices_none": "Ingen enheder med Companion-appen fundet.", + "panel.boost_companion_tag": "Companion-app", + "panel.boost_test_wake": "Test vækning nu", + "panel.boost_test_wake_hint": "Bruger de gemte valg. Gem først efter ændringer.", + "panel.boost_test_wake_sent": "Vækning sendt til {count} skærm(e)", + "panel.boost_rules": "Gentagne boosts", + "panel.boost_rules_hint": "Kører af sig selv. I dag viser hvert boost, før det starter.", + "panel.boost_rule_add": "Tilføj gentaget boost", + "panel.boost_rule_edit": "Rediger gentaget boost", + "panel.boost_rule_edit_btn": "Rediger", + "panel.boost_rule_delete": "Slet", + "panel.boost_rule_delete_confirm": "Slet dette gentagne boost?", + "panel.boost_rule_deleted": "Gentaget boost slettet", + "panel.boost_rule_saved": "Gentaget boost gemt", + "panel.boost_rule_need_day": "Vælg mindst én dag.", + "panel.boost_rule_bad_times": "Sluttidspunktet skal være efter starttidspunktet.", + "panel.boost_rule_on": "Til", + "panel.boost_rule_unnamed": "Boost", + "panel.boost_days": "Dage", + "panel.boost_from": "Fra", + "panel.boost_to": "Til", + "panel.boost_everyone_lc": "alle" } diff --git a/custom_components/taskmate/www/locales/de.json b/custom_components/taskmate/www/locales/de.json index e697ec13..89859026 100644 --- a/custom_components/taskmate/www/locales/de.json +++ b/custom_components/taskmate/www/locales/de.json @@ -3480,5 +3480,94 @@ "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" + "bests.record_best_single_award": "Größte Belohnung", + "notify.boost_started": "⚡ Boost! {chores_text} zählen ×{multiplier} für die nächsten {minutes} Minuten.", + "notify.boost_all_chores": "Alle Aufgaben", + "notification.boost_started.name": "Boost gestartet", + "notification.boost_started.description": "Sagt den Kindern, für die ein Boost gilt, dass er begonnen hat, wie groß er ist und wie lange er dauert.", + "child.boost_title": "Boost ×{multiplier}", + "child.boost_title_named": "Boost ×{multiplier} – {name}", + "child.boost_left": "übrig", + "child.boost_next": "Nächster Boost: {time} · {what} ×{multiplier}", + "child.boost_all_chores": "Alle Aufgaben", + "child.boost_some_chores": "{first} und {count} weitere", + "child.boost_takeover_title": "BOOST!", + "child.boost_takeover_all": "Jede Aufgabe zählt ×{multiplier} für die nächsten", + "child.boost_takeover_some": "zählen ×{multiplier} für die nächsten", + "child.boost_takeover_go": "Los geht's! 🚀", + "child.editor.show_boost_alert": "Boost-Hinweis zeigen, wenn ein Boost startet", + "kiosk.boost_title": "BOOST-ZEIT!", + "kiosk.boost_sub": "{what} zählen ×{multiplier} für die nächsten", + "kiosk.boost_go": "Tippen zum Start! 🚀", + "kiosk.boost_strip": "Boost ×{multiplier} · {what}", + "kiosk.editor.show_boost_alert": "Bildschirm füllen, wenn ein Boost startet", + "panel.boost_start": "Boost starten", + "panel.boost_card_title": "Boosts", + "panel.boost_live": "{count} aktiv", + "panel.boost_ends": "endet {time} (noch {minutes} Min.)", + "panel.boost_starts": "{start}–{end}", + "panel.boost_recurring": "wiederkehrend", + "panel.boost_end_now": "Jetzt beenden", + "panel.boost_skip": "Diesmal auslassen", + "panel.boost_call_off": "Absagen", + "panel.boost_all_chores": "Alle Aufgaben", + "panel.boost_n_chores": "{first} und {count} weitere", + "panel.boost_everyone": "Alle", + "panel.boost_dialog_title": "Boost starten", + "panel.boost_when": "Wann", + "panel.boost_now": "Jetzt", + "panel.boost_later": "Später heute", + "panel.boost_pays": "Zählt", + "panel.boost_for": "Dauer", + "panel.boost_minutes": "{count} Min.", + "panel.boost_hour": "1 Stunde", + "panel.boost_hours": "{count} Stunden", + "panel.boost_which_chores": "Welche Aufgaben", + "panel.boost_who": "Wer", + "panel.boost_name": "Name (optional)", + "panel.boost_name_ph": "Steht auf den Karten, z. B. Besuch um sechs", + "panel.boost_notify": "Kinder benachrichtigen", + "panel.boost_notify_hint": "Push aufs Handy, wenn die Benachrichtigung „Boost gestartet“ an ist. Tablets werden so oder so geweckt.", + "panel.boost_preview_now": "{what} zählen ×{multiplier} für {who}, ab jetzt, {minutes} Minuten lang (bis {until}).", + "panel.boost_preview_later": "{what} zählen ×{multiplier} für {who}, ab {time} für {minutes} Minuten.", + "panel.boost_preview_rule": "Jeden {days}, {start}–{end}: {what} zählen ×{multiplier} für {who}.", + "panel.boost_later_invalid": "Wähle eine spätere Uhrzeit heute.", + "panel.boost_started": "Boost gestartet", + "panel.boost_scheduled": "Boost geplant", + "panel.boost_ended": "Boost beendet", + "panel.boost_settings_title": "Boost-Fenster", + "panel.boost_settings_hint": "Zeitlich begrenzte Multiplikatoren für ausgewählte Aufgaben. Starte einen über Heute, lege wiederkehrende hier an oder nutze den Dienst taskmate.start_boost in einer Automatisierung.", + "panel.boost_stacking": "Am Wochenende oder Geburtstag", + "panel.boost_stacking_hint": "An diesen Tagen gibt es schon einen Multiplikator. Wähle, wie ein Boost damit zusammenwirkt.", + "panel.boost_stacking_highest": "Der höhere gilt", + "panel.boost_stacking_highest_hint": "Ein ×2-Boost an einem ×2-Samstag zählt ×2. Der größere gilt.", + "panel.boost_stacking_stack": "Zusammenrechnen", + "panel.boost_stacking_stack_hint": "Ein ×2-Boost an einem ×2-Samstag zählt ×4.", + "panel.boost_wake_screens": "Diese Tablets wecken, wenn ein Boost startet", + "panel.boost_wake_screens_hint": "Fully Kiosk Browser: Bildschirm an, Bildschirmschoner aus, Browser nach vorne.", + "panel.boost_wake_screens_none": "Keine Fully-Kiosk-Tablets gefunden. Füge die Fully-Kiosk-Browser-Integration zu Home Assistant hinzu, um eines zu wecken.", + "panel.boost_wake_devices": "Diese Handys und Tablets wecken", + "panel.boost_wake_devices_hint": "Home Assistant Companion-App auf Android: sendet command_screen_on.", + "panel.boost_wake_devices_none": "Keine Geräte mit Companion-App gefunden.", + "panel.boost_companion_tag": "Companion-App", + "panel.boost_test_wake": "Jetzt testweise wecken", + "panel.boost_test_wake_hint": "Nutzt die gespeicherte Auswahl. Nach Änderungen zuerst speichern.", + "panel.boost_test_wake_sent": "Weckruf an {count} Bildschirm(e) gesendet", + "panel.boost_rules": "Wiederkehrende Boosts", + "panel.boost_rules_hint": "Laufen von selbst. Heute zeigt jeden vor dem Start an.", + "panel.boost_rule_add": "Wiederkehrenden Boost hinzufügen", + "panel.boost_rule_edit": "Wiederkehrenden Boost bearbeiten", + "panel.boost_rule_edit_btn": "Bearbeiten", + "panel.boost_rule_delete": "Löschen", + "panel.boost_rule_delete_confirm": "Diesen wiederkehrenden Boost löschen?", + "panel.boost_rule_deleted": "Wiederkehrender Boost gelöscht", + "panel.boost_rule_saved": "Wiederkehrender Boost gespeichert", + "panel.boost_rule_need_day": "Wähle mindestens einen Tag.", + "panel.boost_rule_bad_times": "Die Endzeit muss nach der Startzeit liegen.", + "panel.boost_rule_on": "An", + "panel.boost_rule_unnamed": "Boost", + "panel.boost_days": "Tage", + "panel.boost_from": "Von", + "panel.boost_to": "Bis", + "panel.boost_everyone_lc": "alle" } diff --git a/custom_components/taskmate/www/locales/en-GB.json b/custom_components/taskmate/www/locales/en-GB.json index b8475062..9833c117 100644 --- a/custom_components/taskmate/www/locales/en-GB.json +++ b/custom_components/taskmate/www/locales/en-GB.json @@ -3480,5 +3480,94 @@ "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" + "bests.record_best_single_award": "Biggest award", + "notify.boost_started": "⚡ Boost! {chores_text} pay ×{multiplier} for the next {minutes} minutes.", + "notify.boost_all_chores": "All chores", + "notification.boost_started.name": "Boost started", + "notification.boost_started.description": "Tells the children a boost covers that it has begun, how big it is and how long it lasts.", + "child.boost_title": "Boost ×{multiplier}", + "child.boost_title_named": "Boost ×{multiplier} — {name}", + "child.boost_left": "left", + "child.boost_next": "Next boost: {time} · {what} ×{multiplier}", + "child.boost_all_chores": "All chores", + "child.boost_some_chores": "{first} and {count} more", + "child.boost_takeover_title": "BOOST!", + "child.boost_takeover_all": "Every chore pays ×{multiplier} for the next", + "child.boost_takeover_some": "pay ×{multiplier} for the next", + "child.boost_takeover_go": "Let's go! 🚀", + "child.editor.show_boost_alert": "Show the boost alert when a boost starts", + "kiosk.boost_title": "BOOST TIME!", + "kiosk.boost_sub": "{what} pay ×{multiplier} for the next", + "kiosk.boost_go": "Tap to start! 🚀", + "kiosk.boost_strip": "Boost ×{multiplier} · {what}", + "kiosk.editor.show_boost_alert": "Fill the screen when a boost starts", + "panel.boost_start": "Start a boost", + "panel.boost_card_title": "Boosts", + "panel.boost_live": "{count} live", + "panel.boost_ends": "ends {time} ({minutes} min left)", + "panel.boost_starts": "{start}–{end}", + "panel.boost_recurring": "recurring", + "panel.boost_end_now": "End now", + "panel.boost_skip": "Skip this one", + "panel.boost_call_off": "Call off", + "panel.boost_all_chores": "All chores", + "panel.boost_n_chores": "{first} and {count} more", + "panel.boost_everyone": "Everyone", + "panel.boost_dialog_title": "Start a boost", + "panel.boost_when": "When", + "panel.boost_now": "Now", + "panel.boost_later": "Later today", + "panel.boost_pays": "Pays", + "panel.boost_for": "For", + "panel.boost_minutes": "{count} min", + "panel.boost_hour": "1 hour", + "panel.boost_hours": "{count} hours", + "panel.boost_which_chores": "Which chores", + "panel.boost_who": "Who", + "panel.boost_name": "Name (optional)", + "panel.boost_name_ph": "Shown on the cards, e.g. Guests at six", + "panel.boost_notify": "Tell the children", + "panel.boost_notify_hint": "Push to their phones, if the Boost started notification is on. Tablets wake either way.", + "panel.boost_preview_now": "{what} pay ×{multiplier} for {who}, starting now, for {minutes} minutes (until {until}).", + "panel.boost_preview_later": "{what} pay ×{multiplier} for {who}, from {time} for {minutes} minutes.", + "panel.boost_preview_rule": "Every {days}, {start}–{end}: {what} pay ×{multiplier} for {who}.", + "panel.boost_later_invalid": "Pick a time later today.", + "panel.boost_started": "Boost started", + "panel.boost_scheduled": "Boost scheduled", + "panel.boost_ended": "Boost ended", + "panel.boost_settings_title": "Boost windows", + "panel.boost_settings_hint": "Time-boxed multipliers on chosen chores. Start one from Today, set recurring ones here, or use the taskmate.start_boost service in an automation.", + "panel.boost_stacking": "On the weekend or a birthday", + "panel.boost_stacking_hint": "Those days already pay a multiplier. Pick how a boost combines with it.", + "panel.boost_stacking_highest": "Highest wins", + "panel.boost_stacking_highest_hint": "A ×2 boost on a ×2 Saturday pays ×2. The bigger one applies.", + "panel.boost_stacking_stack": "Stack them", + "panel.boost_stacking_stack_hint": "A ×2 boost on a ×2 Saturday pays ×4.", + "panel.boost_wake_screens": "Wake these tablets when a boost starts", + "panel.boost_wake_screens_hint": "Fully Kiosk Browser: screen on, screensaver off, browser to the front.", + "panel.boost_wake_screens_none": "No Fully Kiosk Browser tablets found. Add the Fully Kiosk Browser integration to Home Assistant to wake one.", + "panel.boost_wake_devices": "Wake these phones and tablets", + "panel.boost_wake_devices_hint": "Home Assistant Companion app on Android: sends command_screen_on.", + "panel.boost_wake_devices_none": "No Companion app devices found.", + "panel.boost_companion_tag": "Companion app", + "panel.boost_test_wake": "Test wake now", + "panel.boost_test_wake_hint": "Uses the saved choices. Save first after changing them.", + "panel.boost_test_wake_sent": "Wake sent to {count} screen(s)", + "panel.boost_rules": "Recurring boosts", + "panel.boost_rules_hint": "Repeat on their own. Today shows each one before it starts.", + "panel.boost_rule_add": "Add a recurring boost", + "panel.boost_rule_edit": "Edit recurring boost", + "panel.boost_rule_edit_btn": "Edit", + "panel.boost_rule_delete": "Delete", + "panel.boost_rule_delete_confirm": "Delete this recurring boost?", + "panel.boost_rule_deleted": "Recurring boost deleted", + "panel.boost_rule_saved": "Recurring boost saved", + "panel.boost_rule_need_day": "Pick at least one day.", + "panel.boost_rule_bad_times": "The end time must be after the start time.", + "panel.boost_rule_on": "On", + "panel.boost_rule_unnamed": "Boost", + "panel.boost_days": "Days", + "panel.boost_from": "From", + "panel.boost_to": "To", + "panel.boost_everyone_lc": "everyone" } diff --git a/custom_components/taskmate/www/locales/en.json b/custom_components/taskmate/www/locales/en.json index b8475062..9833c117 100644 --- a/custom_components/taskmate/www/locales/en.json +++ b/custom_components/taskmate/www/locales/en.json @@ -3480,5 +3480,94 @@ "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" + "bests.record_best_single_award": "Biggest award", + "notify.boost_started": "⚡ Boost! {chores_text} pay ×{multiplier} for the next {minutes} minutes.", + "notify.boost_all_chores": "All chores", + "notification.boost_started.name": "Boost started", + "notification.boost_started.description": "Tells the children a boost covers that it has begun, how big it is and how long it lasts.", + "child.boost_title": "Boost ×{multiplier}", + "child.boost_title_named": "Boost ×{multiplier} — {name}", + "child.boost_left": "left", + "child.boost_next": "Next boost: {time} · {what} ×{multiplier}", + "child.boost_all_chores": "All chores", + "child.boost_some_chores": "{first} and {count} more", + "child.boost_takeover_title": "BOOST!", + "child.boost_takeover_all": "Every chore pays ×{multiplier} for the next", + "child.boost_takeover_some": "pay ×{multiplier} for the next", + "child.boost_takeover_go": "Let's go! 🚀", + "child.editor.show_boost_alert": "Show the boost alert when a boost starts", + "kiosk.boost_title": "BOOST TIME!", + "kiosk.boost_sub": "{what} pay ×{multiplier} for the next", + "kiosk.boost_go": "Tap to start! 🚀", + "kiosk.boost_strip": "Boost ×{multiplier} · {what}", + "kiosk.editor.show_boost_alert": "Fill the screen when a boost starts", + "panel.boost_start": "Start a boost", + "panel.boost_card_title": "Boosts", + "panel.boost_live": "{count} live", + "panel.boost_ends": "ends {time} ({minutes} min left)", + "panel.boost_starts": "{start}–{end}", + "panel.boost_recurring": "recurring", + "panel.boost_end_now": "End now", + "panel.boost_skip": "Skip this one", + "panel.boost_call_off": "Call off", + "panel.boost_all_chores": "All chores", + "panel.boost_n_chores": "{first} and {count} more", + "panel.boost_everyone": "Everyone", + "panel.boost_dialog_title": "Start a boost", + "panel.boost_when": "When", + "panel.boost_now": "Now", + "panel.boost_later": "Later today", + "panel.boost_pays": "Pays", + "panel.boost_for": "For", + "panel.boost_minutes": "{count} min", + "panel.boost_hour": "1 hour", + "panel.boost_hours": "{count} hours", + "panel.boost_which_chores": "Which chores", + "panel.boost_who": "Who", + "panel.boost_name": "Name (optional)", + "panel.boost_name_ph": "Shown on the cards, e.g. Guests at six", + "panel.boost_notify": "Tell the children", + "panel.boost_notify_hint": "Push to their phones, if the Boost started notification is on. Tablets wake either way.", + "panel.boost_preview_now": "{what} pay ×{multiplier} for {who}, starting now, for {minutes} minutes (until {until}).", + "panel.boost_preview_later": "{what} pay ×{multiplier} for {who}, from {time} for {minutes} minutes.", + "panel.boost_preview_rule": "Every {days}, {start}–{end}: {what} pay ×{multiplier} for {who}.", + "panel.boost_later_invalid": "Pick a time later today.", + "panel.boost_started": "Boost started", + "panel.boost_scheduled": "Boost scheduled", + "panel.boost_ended": "Boost ended", + "panel.boost_settings_title": "Boost windows", + "panel.boost_settings_hint": "Time-boxed multipliers on chosen chores. Start one from Today, set recurring ones here, or use the taskmate.start_boost service in an automation.", + "panel.boost_stacking": "On the weekend or a birthday", + "panel.boost_stacking_hint": "Those days already pay a multiplier. Pick how a boost combines with it.", + "panel.boost_stacking_highest": "Highest wins", + "panel.boost_stacking_highest_hint": "A ×2 boost on a ×2 Saturday pays ×2. The bigger one applies.", + "panel.boost_stacking_stack": "Stack them", + "panel.boost_stacking_stack_hint": "A ×2 boost on a ×2 Saturday pays ×4.", + "panel.boost_wake_screens": "Wake these tablets when a boost starts", + "panel.boost_wake_screens_hint": "Fully Kiosk Browser: screen on, screensaver off, browser to the front.", + "panel.boost_wake_screens_none": "No Fully Kiosk Browser tablets found. Add the Fully Kiosk Browser integration to Home Assistant to wake one.", + "panel.boost_wake_devices": "Wake these phones and tablets", + "panel.boost_wake_devices_hint": "Home Assistant Companion app on Android: sends command_screen_on.", + "panel.boost_wake_devices_none": "No Companion app devices found.", + "panel.boost_companion_tag": "Companion app", + "panel.boost_test_wake": "Test wake now", + "panel.boost_test_wake_hint": "Uses the saved choices. Save first after changing them.", + "panel.boost_test_wake_sent": "Wake sent to {count} screen(s)", + "panel.boost_rules": "Recurring boosts", + "panel.boost_rules_hint": "Repeat on their own. Today shows each one before it starts.", + "panel.boost_rule_add": "Add a recurring boost", + "panel.boost_rule_edit": "Edit recurring boost", + "panel.boost_rule_edit_btn": "Edit", + "panel.boost_rule_delete": "Delete", + "panel.boost_rule_delete_confirm": "Delete this recurring boost?", + "panel.boost_rule_deleted": "Recurring boost deleted", + "panel.boost_rule_saved": "Recurring boost saved", + "panel.boost_rule_need_day": "Pick at least one day.", + "panel.boost_rule_bad_times": "The end time must be after the start time.", + "panel.boost_rule_on": "On", + "panel.boost_rule_unnamed": "Boost", + "panel.boost_days": "Days", + "panel.boost_from": "From", + "panel.boost_to": "To", + "panel.boost_everyone_lc": "everyone" } diff --git a/custom_components/taskmate/www/locales/fr.json b/custom_components/taskmate/www/locales/fr.json index c9a112ed..f01ea028 100644 --- a/custom_components/taskmate/www/locales/fr.json +++ b/custom_components/taskmate/www/locales/fr.json @@ -3480,5 +3480,94 @@ "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" + "bests.record_best_single_award": "Plus gros gain", + "notify.boost_started": "⚡ Boost ! {chores_text} rapportent ×{multiplier} pendant les {minutes} prochaines minutes.", + "notify.boost_all_chores": "Toutes les tâches", + "notification.boost_started.name": "Boost lancé", + "notification.boost_started.description": "Prévient les enfants concernés qu’un boost a commencé, sa taille et sa durée.", + "child.boost_title": "Boost ×{multiplier}", + "child.boost_title_named": "Boost ×{multiplier} — {name}", + "child.boost_left": "restant", + "child.boost_next": "Prochain boost : {time} · {what} ×{multiplier}", + "child.boost_all_chores": "Toutes les tâches", + "child.boost_some_chores": "{first} et {count} de plus", + "child.boost_takeover_title": "BOOST !", + "child.boost_takeover_all": "Chaque tâche rapporte ×{multiplier} pendant les prochaines", + "child.boost_takeover_some": "rapportent ×{multiplier} pendant les prochaines", + "child.boost_takeover_go": "C'est parti ! 🚀", + "child.editor.show_boost_alert": "Afficher l’alerte quand un boost commence", + "kiosk.boost_title": "C’EST LE BOOST !", + "kiosk.boost_sub": "{what} rapportent ×{multiplier} pendant les prochaines", + "kiosk.boost_go": "Touchez pour commencer ! 🚀", + "kiosk.boost_strip": "Boost ×{multiplier} · {what}", + "kiosk.editor.show_boost_alert": "Remplir l’écran quand un boost commence", + "panel.boost_start": "Lancer un boost", + "panel.boost_card_title": "Boosts", + "panel.boost_live": "{count} en cours", + "panel.boost_ends": "se termine à {time} (encore {minutes} min)", + "panel.boost_starts": "{start}–{end}", + "panel.boost_recurring": "récurrent", + "panel.boost_end_now": "Terminer", + "panel.boost_skip": "Sauter cette fois", + "panel.boost_call_off": "Annuler", + "panel.boost_all_chores": "Toutes les tâches", + "panel.boost_n_chores": "{first} et {count} de plus", + "panel.boost_everyone": "Tout le monde", + "panel.boost_dialog_title": "Lancer un boost", + "panel.boost_when": "Quand", + "panel.boost_now": "Maintenant", + "panel.boost_later": "Plus tard aujourd’hui", + "panel.boost_pays": "Rapporte", + "panel.boost_for": "Durée", + "panel.boost_minutes": "{count} min", + "panel.boost_hour": "1 heure", + "panel.boost_hours": "{count} heures", + "panel.boost_which_chores": "Quelles tâches", + "panel.boost_who": "Qui", + "panel.boost_name": "Nom (facultatif)", + "panel.boost_name_ph": "Affiché sur les cartes, p. ex. Invités à 18 h", + "panel.boost_notify": "Prévenir les enfants", + "panel.boost_notify_hint": "Notification sur leur téléphone si « Boost lancé » est activée. Les tablettes se réveillent dans tous les cas.", + "panel.boost_preview_now": "{what} rapportent ×{multiplier} pour {who}, dès maintenant, pendant {minutes} minutes (jusqu’à {until}).", + "panel.boost_preview_later": "{what} rapportent ×{multiplier} pour {who}, à partir de {time} pendant {minutes} minutes.", + "panel.boost_preview_rule": "Chaque {days}, {start}–{end} : {what} rapportent ×{multiplier} pour {who}.", + "panel.boost_later_invalid": "Choisissez une heure plus tard aujourd’hui.", + "panel.boost_started": "Boost lancé", + "panel.boost_scheduled": "Boost programmé", + "panel.boost_ended": "Boost terminé", + "panel.boost_settings_title": "Fenêtres de boost", + "panel.boost_settings_hint": "Des multiplicateurs limités dans le temps sur certaines tâches. Lancez-en un depuis Aujourd’hui, créez des boosts récurrents ici ou utilisez le service taskmate.start_boost dans une automatisation.", + "panel.boost_stacking": "Le week-end ou un anniversaire", + "panel.boost_stacking_hint": "Ces jours ont déjà un multiplicateur. Choisissez comment un boost s’y ajoute.", + "panel.boost_stacking_highest": "Le plus grand l’emporte", + "panel.boost_stacking_highest_hint": "Un boost ×2 un samedi ×2 rapporte ×2. Le plus grand s’applique.", + "panel.boost_stacking_stack": "Les cumuler", + "panel.boost_stacking_stack_hint": "Un boost ×2 un samedi ×2 rapporte ×4.", + "panel.boost_wake_screens": "Réveiller ces tablettes quand un boost commence", + "panel.boost_wake_screens_hint": "Fully Kiosk Browser : écran allumé, économiseur coupé, navigateur au premier plan.", + "panel.boost_wake_screens_none": "Aucune tablette Fully Kiosk trouvée. Ajoutez l’intégration Fully Kiosk Browser à Home Assistant pour en réveiller une.", + "panel.boost_wake_devices": "Réveiller ces téléphones et tablettes", + "panel.boost_wake_devices_hint": "Application Companion Home Assistant sur Android : envoie command_screen_on.", + "panel.boost_wake_devices_none": "Aucun appareil avec l’application Companion trouvé.", + "panel.boost_companion_tag": "App Companion", + "panel.boost_test_wake": "Tester le réveil", + "panel.boost_test_wake_hint": "Utilise les choix enregistrés. Enregistrez d’abord après une modification.", + "panel.boost_test_wake_sent": "Réveil envoyé à {count} écran(s)", + "panel.boost_rules": "Boosts récurrents", + "panel.boost_rules_hint": "Se répètent tout seuls. Aujourd’hui les affiche avant qu’ils commencent.", + "panel.boost_rule_add": "Ajouter un boost récurrent", + "panel.boost_rule_edit": "Modifier le boost récurrent", + "panel.boost_rule_edit_btn": "Modifier", + "panel.boost_rule_delete": "Supprimer", + "panel.boost_rule_delete_confirm": "Supprimer ce boost récurrent ?", + "panel.boost_rule_deleted": "Boost récurrent supprimé", + "panel.boost_rule_saved": "Boost récurrent enregistré", + "panel.boost_rule_need_day": "Choisissez au moins un jour.", + "panel.boost_rule_bad_times": "L’heure de fin doit être après l’heure de début.", + "panel.boost_rule_on": "Activé", + "panel.boost_rule_unnamed": "Boost", + "panel.boost_days": "Jours", + "panel.boost_from": "De", + "panel.boost_to": "À", + "panel.boost_everyone_lc": "tout le monde" } diff --git a/custom_components/taskmate/www/locales/nb.json b/custom_components/taskmate/www/locales/nb.json index ffaa5570..3f21f977 100644 --- a/custom_components/taskmate/www/locales/nb.json +++ b/custom_components/taskmate/www/locales/nb.json @@ -3480,5 +3480,94 @@ "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" + "bests.record_best_single_award": "Største belønning", + "notify.boost_started": "⚡ Boost! {chores_text} gir ×{multiplier} de neste {minutes} minuttene.", + "notify.boost_all_chores": "Alle oppgaver", + "notification.boost_started.name": "Boost startet", + "notification.boost_started.description": "Forteller barna et boost gjelder for at det har begynt, hvor stort det er og hvor lenge det varer.", + "child.boost_title": "Boost ×{multiplier}", + "child.boost_title_named": "Boost ×{multiplier} – {name}", + "child.boost_left": "igjen", + "child.boost_next": "Neste boost: {time} · {what} ×{multiplier}", + "child.boost_all_chores": "Alle oppgaver", + "child.boost_some_chores": "{first} og {count} til", + "child.boost_takeover_title": "BOOST!", + "child.boost_takeover_all": "Alle oppgaver gir ×{multiplier} de neste", + "child.boost_takeover_some": "gir ×{multiplier} de neste", + "child.boost_takeover_go": "Kjør på! 🚀", + "child.editor.show_boost_alert": "Vis boost-varsel når et boost starter", + "kiosk.boost_title": "BOOST-TID!", + "kiosk.boost_sub": "{what} gir ×{multiplier} de neste", + "kiosk.boost_go": "Trykk for å starte! 🚀", + "kiosk.boost_strip": "Boost ×{multiplier} · {what}", + "kiosk.editor.show_boost_alert": "Fyll skjermen når et boost starter", + "panel.boost_start": "Start et boost", + "panel.boost_card_title": "Boost", + "panel.boost_live": "{count} aktive", + "panel.boost_ends": "slutter {time} ({minutes} min igjen)", + "panel.boost_starts": "{start}–{end}", + "panel.boost_recurring": "gjentas", + "panel.boost_end_now": "Avslutt nå", + "panel.boost_skip": "Hopp over denne gangen", + "panel.boost_call_off": "Avlys", + "panel.boost_all_chores": "Alle oppgaver", + "panel.boost_n_chores": "{first} og {count} til", + "panel.boost_everyone": "Alle", + "panel.boost_dialog_title": "Start et boost", + "panel.boost_when": "Når", + "panel.boost_now": "Nå", + "panel.boost_later": "Senere i dag", + "panel.boost_pays": "Gir", + "panel.boost_for": "Varighet", + "panel.boost_minutes": "{count} min", + "panel.boost_hour": "1 time", + "panel.boost_hours": "{count} timer", + "panel.boost_which_chores": "Hvilke oppgaver", + "panel.boost_who": "Hvem", + "panel.boost_name": "Navn (valgfritt)", + "panel.boost_name_ph": "Vises på kortene, f.eks. Gjester kl. 18", + "panel.boost_notify": "Gi barna beskjed", + "panel.boost_notify_hint": "Varsel på telefonen hvis varselet Boost startet er på. Nettbrett vekkes uansett.", + "panel.boost_preview_now": "{what} gir ×{multiplier} for {who}, fra nå i {minutes} minutter (til {until}).", + "panel.boost_preview_later": "{what} gir ×{multiplier} for {who}, fra {time} i {minutes} minutter.", + "panel.boost_preview_rule": "Hver {days}, {start}–{end}: {what} gir ×{multiplier} for {who}.", + "panel.boost_later_invalid": "Velg et senere tidspunkt i dag.", + "panel.boost_started": "Boost startet", + "panel.boost_scheduled": "Boost planlagt", + "panel.boost_ended": "Boost avsluttet", + "panel.boost_settings_title": "Boost-vinduer", + "panel.boost_settings_hint": "Tidsbegrensede multiplikatorer på valgte oppgaver. Start et fra I dag, lag gjentatte her, eller bruk tjenesten taskmate.start_boost i en automasjon.", + "panel.boost_stacking": "I helgen eller på bursdager", + "panel.boost_stacking_hint": "Disse dagene gir allerede en multiplikator. Velg hvordan et boost kombineres med den.", + "panel.boost_stacking_highest": "Den høyeste gjelder", + "panel.boost_stacking_highest_hint": "Et ×2-boost på en ×2-lørdag gir ×2. Den største gjelder.", + "panel.boost_stacking_stack": "Legg dem sammen", + "panel.boost_stacking_stack_hint": "Et ×2-boost på en ×2-lørdag gir ×4.", + "panel.boost_wake_screens": "Vekk disse nettbrettene når et boost starter", + "panel.boost_wake_screens_hint": "Fully Kiosk Browser: skjerm på, skjermsparer av, nettleser foran.", + "panel.boost_wake_screens_none": "Fant ingen Fully Kiosk-nettbrett. Legg til Fully Kiosk Browser-integrasjonen i Home Assistant for å vekke et.", + "panel.boost_wake_devices": "Vekk disse telefonene og nettbrettene", + "panel.boost_wake_devices_hint": "Home Assistant Companion-appen på Android: sender command_screen_on.", + "panel.boost_wake_devices_none": "Fant ingen enheter med Companion-appen.", + "panel.boost_companion_tag": "Companion-app", + "panel.boost_test_wake": "Test vekking nå", + "panel.boost_test_wake_hint": "Bruker de lagrede valgene. Lagre først etter endringer.", + "panel.boost_test_wake_sent": "Vekking sendt til {count} skjerm(er)", + "panel.boost_rules": "Gjentatte boost", + "panel.boost_rules_hint": "Går av seg selv. I dag viser hvert av dem før det starter.", + "panel.boost_rule_add": "Legg til gjentatt boost", + "panel.boost_rule_edit": "Rediger gjentatt boost", + "panel.boost_rule_edit_btn": "Rediger", + "panel.boost_rule_delete": "Slett", + "panel.boost_rule_delete_confirm": "Slette dette gjentatte boostet?", + "panel.boost_rule_deleted": "Gjentatt boost slettet", + "panel.boost_rule_saved": "Gjentatt boost lagret", + "panel.boost_rule_need_day": "Velg minst én dag.", + "panel.boost_rule_bad_times": "Sluttiden må være etter starttiden.", + "panel.boost_rule_on": "På", + "panel.boost_rule_unnamed": "Boost", + "panel.boost_days": "Dager", + "panel.boost_from": "Fra", + "panel.boost_to": "Til", + "panel.boost_everyone_lc": "alle" } diff --git a/custom_components/taskmate/www/locales/nl.json b/custom_components/taskmate/www/locales/nl.json index dd188c36..ed27d59d 100644 --- a/custom_components/taskmate/www/locales/nl.json +++ b/custom_components/taskmate/www/locales/nl.json @@ -3480,5 +3480,94 @@ "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" + "bests.record_best_single_award": "Grootste beloning", + "notify.boost_started": "⚡ Boost! {chores_text} leveren ×{multiplier} op, de komende {minutes} minuten.", + "notify.boost_all_chores": "Alle taken", + "notification.boost_started.name": "Boost gestart", + "notification.boost_started.description": "Vertelt de kinderen voor wie een boost geldt dat hij begonnen is, hoe groot hij is en hoe lang hij duurt.", + "child.boost_title": "Boost ×{multiplier}", + "child.boost_title_named": "Boost ×{multiplier} — {name}", + "child.boost_left": "over", + "child.boost_next": "Volgende boost: {time} · {what} ×{multiplier}", + "child.boost_all_chores": "Alle taken", + "child.boost_some_chores": "{first} en nog {count}", + "child.boost_takeover_title": "BOOST!", + "child.boost_takeover_all": "Elke taak levert ×{multiplier} op, de komende", + "child.boost_takeover_some": "leveren ×{multiplier} op, de komende", + "child.boost_takeover_go": "Aan de slag! 🚀", + "child.editor.show_boost_alert": "Boostmelding tonen als een boost begint", + "kiosk.boost_title": "BOOSTTIJD!", + "kiosk.boost_sub": "{what} leveren ×{multiplier} op, de komende", + "kiosk.boost_go": "Tik om te beginnen! 🚀", + "kiosk.boost_strip": "Boost ×{multiplier} · {what}", + "kiosk.editor.show_boost_alert": "Volledig scherm tonen als een boost begint", + "panel.boost_start": "Boost starten", + "panel.boost_card_title": "Boosts", + "panel.boost_live": "{count} actief", + "panel.boost_ends": "eindigt om {time} (nog {minutes} min)", + "panel.boost_starts": "{start}–{end}", + "panel.boost_recurring": "terugkerend", + "panel.boost_end_now": "Nu stoppen", + "panel.boost_skip": "Deze overslaan", + "panel.boost_call_off": "Annuleren", + "panel.boost_all_chores": "Alle taken", + "panel.boost_n_chores": "{first} en nog {count}", + "panel.boost_everyone": "Iedereen", + "panel.boost_dialog_title": "Boost starten", + "panel.boost_when": "Wanneer", + "panel.boost_now": "Nu", + "panel.boost_later": "Later vandaag", + "panel.boost_pays": "Levert op", + "panel.boost_for": "Duur", + "panel.boost_minutes": "{count} min", + "panel.boost_hour": "1 uur", + "panel.boost_hours": "{count} uur", + "panel.boost_which_chores": "Welke taken", + "panel.boost_who": "Wie", + "panel.boost_name": "Naam (optioneel)", + "panel.boost_name_ph": "Zichtbaar op de kaarten, bijv. Om zes uur visite", + "panel.boost_notify": "De kinderen laten weten", + "panel.boost_notify_hint": "Melding naar hun telefoon, als de melding Boost gestart aanstaat. Tablets worden hoe dan ook gewekt.", + "panel.boost_preview_now": "{what} leveren ×{multiplier} op voor {who}, vanaf nu, {minutes} minuten lang (tot {until}).", + "panel.boost_preview_later": "{what} leveren ×{multiplier} op voor {who}, vanaf {time}, {minutes} minuten lang.", + "panel.boost_preview_rule": "Elke {days}, {start}–{end}: {what} leveren ×{multiplier} op voor {who}.", + "panel.boost_later_invalid": "Kies een tijd later vandaag.", + "panel.boost_started": "Boost gestart", + "panel.boost_scheduled": "Boost ingepland", + "panel.boost_ended": "Boost beëindigd", + "panel.boost_settings_title": "Boostvensters", + "panel.boost_settings_hint": "Vermenigvuldigers voor gekozen taken, voor een beperkte tijd. Start er een vanuit Vandaag, stel hier terugkerende in, of gebruik de dienst taskmate.start_boost in een automatisering.", + "panel.boost_stacking": "In het weekend of op een verjaardag", + "panel.boost_stacking_hint": "Die dagen leveren al een vermenigvuldiger op. Kies hoe een boost daarmee samengaat.", + "panel.boost_stacking_highest": "Hoogste telt", + "panel.boost_stacking_highest_hint": "Een boost van ×2 op een zaterdag met ×2 levert ×2 op. De grootste geldt.", + "panel.boost_stacking_stack": "Opstapelen", + "panel.boost_stacking_stack_hint": "Een boost van ×2 op een zaterdag met ×2 levert ×4 op.", + "panel.boost_wake_screens": "Deze tablets wekken als een boost begint", + "panel.boost_wake_screens_hint": "Fully Kiosk Browser: scherm aan, schermbeveiliging uit, browser naar voren.", + "panel.boost_wake_screens_none": "Geen Fully Kiosk Browser-tablets gevonden. Voeg de integratie Fully Kiosk Browser toe aan Home Assistant om er een te wekken.", + "panel.boost_wake_devices": "Deze telefoons en tablets wekken", + "panel.boost_wake_devices_hint": "Home Assistant Companion-app op Android: stuurt command_screen_on.", + "panel.boost_wake_devices_none": "Geen apparaten met de Companion-app gevonden.", + "panel.boost_companion_tag": "Companion-app", + "panel.boost_test_wake": "Nu testen", + "panel.boost_test_wake_hint": "Gebruikt de opgeslagen keuzes. Sla eerst op als je ze hebt gewijzigd.", + "panel.boost_test_wake_sent": "Wekken verstuurd naar {count} scherm(en)", + "panel.boost_rules": "Terugkerende boosts", + "panel.boost_rules_hint": "Herhalen vanzelf. Vandaag toont elke boost voordat hij begint.", + "panel.boost_rule_add": "Terugkerende boost toevoegen", + "panel.boost_rule_edit": "Terugkerende boost bewerken", + "panel.boost_rule_edit_btn": "Bewerken", + "panel.boost_rule_delete": "Verwijderen", + "panel.boost_rule_delete_confirm": "Deze terugkerende boost verwijderen?", + "panel.boost_rule_deleted": "Terugkerende boost verwijderd", + "panel.boost_rule_saved": "Terugkerende boost opgeslagen", + "panel.boost_rule_need_day": "Kies minstens één dag.", + "panel.boost_rule_bad_times": "De eindtijd moet na de begintijd liggen.", + "panel.boost_rule_on": "Aan", + "panel.boost_rule_unnamed": "Boost", + "panel.boost_days": "Dagen", + "panel.boost_from": "Van", + "panel.boost_to": "Tot", + "panel.boost_everyone_lc": "iedereen" } diff --git a/custom_components/taskmate/www/locales/nn.json b/custom_components/taskmate/www/locales/nn.json index c24a3958..318ec281 100644 --- a/custom_components/taskmate/www/locales/nn.json +++ b/custom_components/taskmate/www/locales/nn.json @@ -3480,5 +3480,94 @@ "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" + "bests.record_best_single_award": "Største løning", + "notify.boost_started": "⚡ Boost! {chores_text} gjev ×{multiplier} dei neste {minutes} minutta.", + "notify.boost_all_chores": "Alle oppgåver", + "notification.boost_started.name": "Boost starta", + "notification.boost_started.description": "Fortel borna eit boost gjeld for at det har byrja, kor stort det er og kor lenge det varer.", + "child.boost_title": "Boost ×{multiplier}", + "child.boost_title_named": "Boost ×{multiplier} – {name}", + "child.boost_left": "att", + "child.boost_next": "Neste boost: {time} · {what} ×{multiplier}", + "child.boost_all_chores": "Alle oppgåver", + "child.boost_some_chores": "{first} og {count} til", + "child.boost_takeover_title": "BOOST!", + "child.boost_takeover_all": "Alle oppgåver gjev ×{multiplier} dei neste", + "child.boost_takeover_some": "gjev ×{multiplier} dei neste", + "child.boost_takeover_go": "Køyr på! 🚀", + "child.editor.show_boost_alert": "Vis boost-varsel når eit boost startar", + "kiosk.boost_title": "BOOST-TID!", + "kiosk.boost_sub": "{what} gjev ×{multiplier} dei neste", + "kiosk.boost_go": "Trykk for å starte! 🚀", + "kiosk.boost_strip": "Boost ×{multiplier} · {what}", + "kiosk.editor.show_boost_alert": "Fyll skjermen når eit boost startar", + "panel.boost_start": "Start eit boost", + "panel.boost_card_title": "Boost", + "panel.boost_live": "{count} aktive", + "panel.boost_ends": "sluttar {time} ({minutes} min att)", + "panel.boost_starts": "{start}–{end}", + "panel.boost_recurring": "gjentek seg", + "panel.boost_end_now": "Avslutt no", + "panel.boost_skip": "Hopp over denne gongen", + "panel.boost_call_off": "Avlys", + "panel.boost_all_chores": "Alle oppgåver", + "panel.boost_n_chores": "{first} og {count} til", + "panel.boost_everyone": "Alle", + "panel.boost_dialog_title": "Start eit boost", + "panel.boost_when": "Når", + "panel.boost_now": "No", + "panel.boost_later": "Seinare i dag", + "panel.boost_pays": "Gjev", + "panel.boost_for": "Varigheit", + "panel.boost_minutes": "{count} min", + "panel.boost_hour": "1 time", + "panel.boost_hours": "{count} timar", + "panel.boost_which_chores": "Kva oppgåver", + "panel.boost_who": "Kven", + "panel.boost_name": "Namn (valfritt)", + "panel.boost_name_ph": "Blir vist på korta, t.d. Gjester kl. 18", + "panel.boost_notify": "Gje borna beskjed", + "panel.boost_notify_hint": "Varsel på telefonen dersom varselet Boost starta er på. Nettbrett blir vekte uansett.", + "panel.boost_preview_now": "{what} gjev ×{multiplier} for {who}, frå no i {minutes} minutt (til {until}).", + "panel.boost_preview_later": "{what} gjev ×{multiplier} for {who}, frå {time} i {minutes} minutt.", + "panel.boost_preview_rule": "Kvar {days}, {start}–{end}: {what} gjev ×{multiplier} for {who}.", + "panel.boost_later_invalid": "Vel eit seinare tidspunkt i dag.", + "panel.boost_started": "Boost starta", + "panel.boost_scheduled": "Boost planlagt", + "panel.boost_ended": "Boost avslutta", + "panel.boost_settings_title": "Boost-vindauge", + "panel.boost_settings_hint": "Tidsavgrensa multiplikatorar på valde oppgåver. Start eitt frå I dag, lag gjentekne her, eller bruk tenesta taskmate.start_boost i ein automasjon.", + "panel.boost_stacking": "I helga eller på bursdagar", + "panel.boost_stacking_hint": "Desse dagane gjev alt ein multiplikator. Vel korleis eit boost blir kombinert med han.", + "panel.boost_stacking_highest": "Den høgaste gjeld", + "panel.boost_stacking_highest_hint": "Eit ×2-boost på ein ×2-laurdag gjev ×2. Den største gjeld.", + "panel.boost_stacking_stack": "Legg dei saman", + "panel.boost_stacking_stack_hint": "Eit ×2-boost på ein ×2-laurdag gjev ×4.", + "panel.boost_wake_screens": "Vekk desse nettbretta når eit boost startar", + "panel.boost_wake_screens_hint": "Fully Kiosk Browser: skjerm på, skjermsparar av, nettlesar framme.", + "panel.boost_wake_screens_none": "Fann ingen Fully Kiosk-nettbrett. Legg til Fully Kiosk Browser-integrasjonen i Home Assistant for å vekkje eitt.", + "panel.boost_wake_devices": "Vekk desse telefonane og nettbretta", + "panel.boost_wake_devices_hint": "Home Assistant Companion-appen på Android: sender command_screen_on.", + "panel.boost_wake_devices_none": "Fann ingen einingar med Companion-appen.", + "panel.boost_companion_tag": "Companion-app", + "panel.boost_test_wake": "Test vekking no", + "panel.boost_test_wake_hint": "Brukar dei lagra vala. Lagre først etter endringar.", + "panel.boost_test_wake_sent": "Vekking sendt til {count} skjerm(ar)", + "panel.boost_rules": "Gjentekne boost", + "panel.boost_rules_hint": "Går av seg sjølv. I dag viser kvart av dei før det startar.", + "panel.boost_rule_add": "Legg til gjenteke boost", + "panel.boost_rule_edit": "Rediger gjenteke boost", + "panel.boost_rule_edit_btn": "Rediger", + "panel.boost_rule_delete": "Slett", + "panel.boost_rule_delete_confirm": "Slette dette gjentekne boostet?", + "panel.boost_rule_deleted": "Gjenteke boost sletta", + "panel.boost_rule_saved": "Gjenteke boost lagra", + "panel.boost_rule_need_day": "Vel minst éin dag.", + "panel.boost_rule_bad_times": "Sluttida må vere etter starttida.", + "panel.boost_rule_on": "På", + "panel.boost_rule_unnamed": "Boost", + "panel.boost_days": "Dagar", + "panel.boost_from": "Frå", + "panel.boost_to": "Til", + "panel.boost_everyone_lc": "alle" } diff --git a/custom_components/taskmate/www/locales/pl.json b/custom_components/taskmate/www/locales/pl.json index 6769ef48..c94580aa 100644 --- a/custom_components/taskmate/www/locales/pl.json +++ b/custom_components/taskmate/www/locales/pl.json @@ -3480,5 +3480,94 @@ "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" + "bests.record_best_single_award": "Największa nagroda", + "notify.boost_started": "⚡ Boost! {chores_text} są warte ×{multiplier} przez następne {minutes} min.", + "notify.boost_all_chores": "Wszystkie obowiązki", + "notification.boost_started.name": "Boost rozpoczęty", + "notification.boost_started.description": "Informuje dzieci objęte boostem, że się zaczął, jak duży jest i ile trwa.", + "child.boost_title": "Boost ×{multiplier}", + "child.boost_title_named": "Boost ×{multiplier} — {name}", + "child.boost_left": "zostało", + "child.boost_next": "Następny boost: {time} · {what} ×{multiplier}", + "child.boost_all_chores": "Wszystkie obowiązki", + "child.boost_some_chores": "{first} i {count} więcej", + "child.boost_takeover_title": "BOOST!", + "child.boost_takeover_all": "Każdy obowiązek jest wart ×{multiplier} przez następne", + "child.boost_takeover_some": "są warte ×{multiplier} przez następne", + "child.boost_takeover_go": "Do dzieła! 🚀", + "child.editor.show_boost_alert": "Pokaż alert, gdy zaczyna się boost", + "kiosk.boost_title": "CZAS NA BOOST!", + "kiosk.boost_sub": "{what} są warte ×{multiplier} przez następne", + "kiosk.boost_go": "Dotknij, aby zacząć! 🚀", + "kiosk.boost_strip": "Boost ×{multiplier} · {what}", + "kiosk.editor.show_boost_alert": "Wypełnij ekran, gdy zaczyna się boost", + "panel.boost_start": "Rozpocznij boost", + "panel.boost_card_title": "Boosty", + "panel.boost_live": "{count} aktywne", + "panel.boost_ends": "kończy się o {time} (zostało {minutes} min)", + "panel.boost_starts": "{start}–{end}", + "panel.boost_recurring": "cykliczny", + "panel.boost_end_now": "Zakończ teraz", + "panel.boost_skip": "Pomiń tym razem", + "panel.boost_call_off": "Odwołaj", + "panel.boost_all_chores": "Wszystkie obowiązki", + "panel.boost_n_chores": "{first} i {count} więcej", + "panel.boost_everyone": "Wszyscy", + "panel.boost_dialog_title": "Rozpocznij boost", + "panel.boost_when": "Kiedy", + "panel.boost_now": "Teraz", + "panel.boost_later": "Później dzisiaj", + "panel.boost_pays": "Mnożnik", + "panel.boost_for": "Czas trwania", + "panel.boost_minutes": "{count} min", + "panel.boost_hour": "1 godzina", + "panel.boost_hours": "{count} godz.", + "panel.boost_which_chores": "Które obowiązki", + "panel.boost_who": "Kto", + "panel.boost_name": "Nazwa (opcjonalnie)", + "panel.boost_name_ph": "Widoczna na kartach, np. Goście o 18", + "panel.boost_notify": "Powiadom dzieci", + "panel.boost_notify_hint": "Powiadomienie na telefon, jeśli „Boost rozpoczęty” jest włączony. Tablety budzą się w każdym przypadku.", + "panel.boost_preview_now": "{what} są warte ×{multiplier} dla: {who}, od teraz przez {minutes} min (do {until}).", + "panel.boost_preview_later": "{what} są warte ×{multiplier} dla: {who}, od {time} przez {minutes} min.", + "panel.boost_preview_rule": "Co {days}, {start}–{end}: {what} są warte ×{multiplier} dla: {who}.", + "panel.boost_later_invalid": "Wybierz późniejszą godzinę dzisiaj.", + "panel.boost_started": "Boost rozpoczęty", + "panel.boost_scheduled": "Boost zaplanowany", + "panel.boost_ended": "Boost zakończony", + "panel.boost_settings_title": "Okna boostów", + "panel.boost_settings_hint": "Ograniczone w czasie mnożniki dla wybranych obowiązków. Rozpocznij boost z ekranu Dziś, ustaw cykliczne tutaj albo użyj usługi taskmate.start_boost w automatyzacji.", + "panel.boost_stacking": "W weekend lub urodziny", + "panel.boost_stacking_hint": "Te dni mają już mnożnik. Wybierz, jak łączy się z nim boost.", + "panel.boost_stacking_highest": "Wygrywa większy", + "panel.boost_stacking_highest_hint": "Boost ×2 w sobotę ×2 daje ×2. Obowiązuje większy.", + "panel.boost_stacking_stack": "Łącz je", + "panel.boost_stacking_stack_hint": "Boost ×2 w sobotę ×2 daje ×4.", + "panel.boost_wake_screens": "Budź te tablety, gdy zaczyna się boost", + "panel.boost_wake_screens_hint": "Fully Kiosk Browser: ekran włączony, wygaszacz wyłączony, przeglądarka na wierzchu.", + "panel.boost_wake_screens_none": "Nie znaleziono tabletów Fully Kiosk. Dodaj integrację Fully Kiosk Browser do Home Assistant, aby je budzić.", + "panel.boost_wake_devices": "Budź te telefony i tablety", + "panel.boost_wake_devices_hint": "Aplikacja Home Assistant Companion na Androidzie: wysyła command_screen_on.", + "panel.boost_wake_devices_none": "Nie znaleziono urządzeń z aplikacją Companion.", + "panel.boost_companion_tag": "Aplikacja Companion", + "panel.boost_test_wake": "Przetestuj budzenie", + "panel.boost_test_wake_hint": "Używa zapisanych wyborów. Po zmianach najpierw zapisz.", + "panel.boost_test_wake_sent": "Wysłano budzenie do {count} ekranów", + "panel.boost_rules": "Boosty cykliczne", + "panel.boost_rules_hint": "Uruchamiają się same. Ekran Dziś pokazuje każdy przed startem.", + "panel.boost_rule_add": "Dodaj boost cykliczny", + "panel.boost_rule_edit": "Edytuj boost cykliczny", + "panel.boost_rule_edit_btn": "Edytuj", + "panel.boost_rule_delete": "Usuń", + "panel.boost_rule_delete_confirm": "Usunąć ten boost cykliczny?", + "panel.boost_rule_deleted": "Boost cykliczny usunięty", + "panel.boost_rule_saved": "Boost cykliczny zapisany", + "panel.boost_rule_need_day": "Wybierz co najmniej jeden dzień.", + "panel.boost_rule_bad_times": "Godzina końca musi być po godzinie początku.", + "panel.boost_rule_on": "Włączony", + "panel.boost_rule_unnamed": "Boost", + "panel.boost_days": "Dni", + "panel.boost_from": "Od", + "panel.boost_to": "Do", + "panel.boost_everyone_lc": "wszystkich" } diff --git a/custom_components/taskmate/www/locales/pt-BR.json b/custom_components/taskmate/www/locales/pt-BR.json index 075a31fd..7d08156a 100644 --- a/custom_components/taskmate/www/locales/pt-BR.json +++ b/custom_components/taskmate/www/locales/pt-BR.json @@ -3480,5 +3480,94 @@ "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" + "bests.record_best_single_award": "Maior prêmio", + "notify.boost_started": "⚡ Boost! {chores_text} valem ×{multiplier} pelos próximos {minutes} minutos.", + "notify.boost_all_chores": "Todas as tarefas", + "notification.boost_started.name": "Boost iniciado", + "notification.boost_started.description": "Avisa as crianças incluídas que um boost começou, o tamanho e a duração.", + "child.boost_title": "Boost ×{multiplier}", + "child.boost_title_named": "Boost ×{multiplier} — {name}", + "child.boost_left": "restantes", + "child.boost_next": "Próximo boost: {time} · {what} ×{multiplier}", + "child.boost_all_chores": "Todas as tarefas", + "child.boost_some_chores": "{first} e mais {count}", + "child.boost_takeover_title": "BOOST!", + "child.boost_takeover_all": "Todas as tarefas valem ×{multiplier} pelos próximos", + "child.boost_takeover_some": "valem ×{multiplier} pelos próximos", + "child.boost_takeover_go": "Vamos lá! 🚀", + "child.editor.show_boost_alert": "Mostrar o aviso quando um boost começar", + "kiosk.boost_title": "HORA DO BOOST!", + "kiosk.boost_sub": "{what} valem ×{multiplier} pelos próximos", + "kiosk.boost_go": "Toque para começar! 🚀", + "kiosk.boost_strip": "Boost ×{multiplier} · {what}", + "kiosk.editor.show_boost_alert": "Preencher a tela quando um boost começar", + "panel.boost_start": "Iniciar um boost", + "panel.boost_card_title": "Boosts", + "panel.boost_live": "{count} ativos", + "panel.boost_ends": "termina às {time} (faltam {minutes} min)", + "panel.boost_starts": "{start}–{end}", + "panel.boost_recurring": "recorrente", + "panel.boost_end_now": "Encerrar agora", + "panel.boost_skip": "Pular desta vez", + "panel.boost_call_off": "Cancelar", + "panel.boost_all_chores": "Todas as tarefas", + "panel.boost_n_chores": "{first} e mais {count}", + "panel.boost_everyone": "Todos", + "panel.boost_dialog_title": "Iniciar um boost", + "panel.boost_when": "Quando", + "panel.boost_now": "Agora", + "panel.boost_later": "Mais tarde hoje", + "panel.boost_pays": "Vale", + "panel.boost_for": "Duração", + "panel.boost_minutes": "{count} min", + "panel.boost_hour": "1 hora", + "panel.boost_hours": "{count} horas", + "panel.boost_which_chores": "Que tarefas", + "panel.boost_who": "Quem", + "panel.boost_name": "Nome (opcional)", + "panel.boost_name_ph": "Aparece nos cards, ex. Visitas às seis", + "panel.boost_notify": "Avisar as crianças", + "panel.boost_notify_hint": "Notificação no celular se \"Boost iniciado\" estiver ligada. Os tablets acordam de qualquer jeito.", + "panel.boost_preview_now": "{what} valem ×{multiplier} para {who}, a partir de agora, por {minutes} minutos (até {until}).", + "panel.boost_preview_later": "{what} valem ×{multiplier} para {who}, a partir das {time} por {minutes} minutos.", + "panel.boost_preview_rule": "Todas as {days}, {start}–{end}: {what} valem ×{multiplier} para {who}.", + "panel.boost_later_invalid": "Escolha um horário mais tarde hoje.", + "panel.boost_started": "Boost iniciado", + "panel.boost_scheduled": "Boost agendado", + "panel.boost_ended": "Boost encerrado", + "panel.boost_settings_title": "Janelas de boost", + "panel.boost_settings_hint": "Multiplicadores com hora marcada em tarefas escolhidas. Inicie um a partir de Hoje, crie recorrentes aqui ou use o serviço taskmate.start_boost em uma automação.", + "panel.boost_stacking": "Ao fim de semana ou num aniversário", + "panel.boost_stacking_hint": "Esses dias já têm um multiplicador. Escolha como um boost se combina com ele.", + "panel.boost_stacking_highest": "O maior ganha", + "panel.boost_stacking_highest_hint": "Um boost ×2 num sábado ×2 vale ×2. Aplica-se o maior.", + "panel.boost_stacking_stack": "Somar os dois", + "panel.boost_stacking_stack_hint": "Um boost ×2 num sábado ×2 vale ×4.", + "panel.boost_wake_screens": "Acordar estes tablets quando um boost começar", + "panel.boost_wake_screens_hint": "Fully Kiosk Browser: tela ligada, protetor de tela desligado, navegador na frente.", + "panel.boost_wake_screens_none": "Nenhum tablet Fully Kiosk encontrado. Adicione a integração Fully Kiosk Browser ao Home Assistant para acordar um.", + "panel.boost_wake_devices": "Acordar estes celulares e tablets", + "panel.boost_wake_devices_hint": "App Companion do Home Assistant no Android: envia command_screen_on.", + "panel.boost_wake_devices_none": "Nenhum dispositivo com a app Companion encontrado.", + "panel.boost_companion_tag": "App Companion", + "panel.boost_test_wake": "Testar acordar agora", + "panel.boost_test_wake_hint": "Usa as escolhas salvas. Salve primeiro depois de mudar.", + "panel.boost_test_wake_sent": "Enviado para {count} tela(s)", + "panel.boost_rules": "Boosts recorrentes", + "panel.boost_rules_hint": "Repetem sozinhos. Hoje mostra cada um antes de começar.", + "panel.boost_rule_add": "Adicionar boost recorrente", + "panel.boost_rule_edit": "Editar boost recorrente", + "panel.boost_rule_edit_btn": "Editar", + "panel.boost_rule_delete": "Excluir", + "panel.boost_rule_delete_confirm": "Excluir este boost recorrente?", + "panel.boost_rule_deleted": "Boost recorrente excluído", + "panel.boost_rule_saved": "Boost recorrente salvo", + "panel.boost_rule_need_day": "Escolha pelo menos um dia.", + "panel.boost_rule_bad_times": "O horário de fim precisa ser depois do início.", + "panel.boost_rule_on": "Ligado", + "panel.boost_rule_unnamed": "Boost", + "panel.boost_days": "Dias", + "panel.boost_from": "Das", + "panel.boost_to": "Às", + "panel.boost_everyone_lc": "todos" } diff --git a/custom_components/taskmate/www/locales/pt.json b/custom_components/taskmate/www/locales/pt.json index cb648ed6..3c890085 100644 --- a/custom_components/taskmate/www/locales/pt.json +++ b/custom_components/taskmate/www/locales/pt.json @@ -3480,5 +3480,94 @@ "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" + "bests.record_best_single_award": "Maior prémio", + "notify.boost_started": "⚡ Boost! {chores_text} valem ×{multiplier} durante os próximos {minutes} minutos.", + "notify.boost_all_chores": "Todas as tarefas", + "notification.boost_started.name": "Boost iniciado", + "notification.boost_started.description": "Avisa as crianças abrangidas que um boost começou, o tamanho e a duração.", + "child.boost_title": "Boost ×{multiplier}", + "child.boost_title_named": "Boost ×{multiplier} — {name}", + "child.boost_left": "restantes", + "child.boost_next": "Próximo boost: {time} · {what} ×{multiplier}", + "child.boost_all_chores": "Todas as tarefas", + "child.boost_some_chores": "{first} e mais {count}", + "child.boost_takeover_title": "BOOST!", + "child.boost_takeover_all": "Todas as tarefas valem ×{multiplier} durante os próximos", + "child.boost_takeover_some": "valem ×{multiplier} durante os próximos", + "child.boost_takeover_go": "Vamos lá! 🚀", + "child.editor.show_boost_alert": "Mostrar o aviso quando começa um boost", + "kiosk.boost_title": "HORA DO BOOST!", + "kiosk.boost_sub": "{what} valem ×{multiplier} durante os próximos", + "kiosk.boost_go": "Toca para começar! 🚀", + "kiosk.boost_strip": "Boost ×{multiplier} · {what}", + "kiosk.editor.show_boost_alert": "Encher o ecrã quando começa um boost", + "panel.boost_start": "Iniciar um boost", + "panel.boost_card_title": "Boosts", + "panel.boost_live": "{count} ativos", + "panel.boost_ends": "termina às {time} (faltam {minutes} min)", + "panel.boost_starts": "{start}–{end}", + "panel.boost_recurring": "recorrente", + "panel.boost_end_now": "Terminar já", + "panel.boost_skip": "Saltar desta vez", + "panel.boost_call_off": "Cancelar", + "panel.boost_all_chores": "Todas as tarefas", + "panel.boost_n_chores": "{first} e mais {count}", + "panel.boost_everyone": "Todos", + "panel.boost_dialog_title": "Iniciar um boost", + "panel.boost_when": "Quando", + "panel.boost_now": "Agora", + "panel.boost_later": "Mais tarde hoje", + "panel.boost_pays": "Vale", + "panel.boost_for": "Duração", + "panel.boost_minutes": "{count} min", + "panel.boost_hour": "1 hora", + "panel.boost_hours": "{count} horas", + "panel.boost_which_chores": "Que tarefas", + "panel.boost_who": "Quem", + "panel.boost_name": "Nome (opcional)", + "panel.boost_name_ph": "Aparece nos cartões, p. ex. Visitas às seis", + "panel.boost_notify": "Avisar as crianças", + "panel.boost_notify_hint": "Notificação no telemóvel se \"Boost iniciado\" estiver ligada. Os tablets acordam de qualquer forma.", + "panel.boost_preview_now": "{what} valem ×{multiplier} para {who}, a partir de agora, durante {minutes} minutos (até às {until}).", + "panel.boost_preview_later": "{what} valem ×{multiplier} para {who}, a partir das {time} durante {minutes} minutos.", + "panel.boost_preview_rule": "Todas as {days}, {start}–{end}: {what} valem ×{multiplier} para {who}.", + "panel.boost_later_invalid": "Escolhe uma hora mais tarde hoje.", + "panel.boost_started": "Boost iniciado", + "panel.boost_scheduled": "Boost agendado", + "panel.boost_ended": "Boost terminado", + "panel.boost_settings_title": "Janelas de boost", + "panel.boost_settings_hint": "Multiplicadores com hora marcada em tarefas escolhidas. Inicia um a partir de Hoje, cria recorrentes aqui ou usa o serviço taskmate.start_boost numa automação.", + "panel.boost_stacking": "Ao fim de semana ou num aniversário", + "panel.boost_stacking_hint": "Esses dias já têm um multiplicador. Escolhe como um boost se junta a ele.", + "panel.boost_stacking_highest": "O maior ganha", + "panel.boost_stacking_highest_hint": "Um boost ×2 num sábado ×2 vale ×2. Aplica-se o maior.", + "panel.boost_stacking_stack": "Somar os dois", + "panel.boost_stacking_stack_hint": "Um boost ×2 num sábado ×2 vale ×4.", + "panel.boost_wake_screens": "Acordar estes tablets quando começa um boost", + "panel.boost_wake_screens_hint": "Fully Kiosk Browser: ecrã ligado, proteção de ecrã desligada, navegador à frente.", + "panel.boost_wake_screens_none": "Nenhum tablet Fully Kiosk encontrado. Adiciona a integração Fully Kiosk Browser ao Home Assistant para acordar um.", + "panel.boost_wake_devices": "Acordar estes telemóveis e tablets", + "panel.boost_wake_devices_hint": "App Companion do Home Assistant em Android: envia command_screen_on.", + "panel.boost_wake_devices_none": "Nenhum dispositivo com a app Companion encontrado.", + "panel.boost_companion_tag": "App Companion", + "panel.boost_test_wake": "Testar acordar agora", + "panel.boost_test_wake_hint": "Usa as escolhas guardadas. Guarda primeiro depois de mudar.", + "panel.boost_test_wake_sent": "Enviado para {count} ecrã(s)", + "panel.boost_rules": "Boosts recorrentes", + "panel.boost_rules_hint": "Repetem-se sozinhos. Hoje mostra cada um antes de começar.", + "panel.boost_rule_add": "Adicionar boost recorrente", + "panel.boost_rule_edit": "Editar boost recorrente", + "panel.boost_rule_edit_btn": "Editar", + "panel.boost_rule_delete": "Eliminar", + "panel.boost_rule_delete_confirm": "Eliminar este boost recorrente?", + "panel.boost_rule_deleted": "Boost recorrente eliminado", + "panel.boost_rule_saved": "Boost recorrente guardado", + "panel.boost_rule_need_day": "Escolhe pelo menos um dia.", + "panel.boost_rule_bad_times": "A hora de fim tem de ser depois da hora de início.", + "panel.boost_rule_on": "Ligado", + "panel.boost_rule_unnamed": "Boost", + "panel.boost_days": "Dias", + "panel.boost_from": "Das", + "panel.boost_to": "Às", + "panel.boost_everyone_lc": "todos" } diff --git a/custom_components/taskmate/www/taskmate-child-card.js b/custom_components/taskmate/www/taskmate-child-card.js index 308ecf74..a9a4039d 100644 --- a/custom_components/taskmate/www/taskmate-child-card.js +++ b/custom_components/taskmate/www/taskmate-child-card.js @@ -104,6 +104,7 @@ class TaskMateChildCard extends LitElement { this._maybeBirthdayConfetti(attrs); this._maybeInspectionCelebration(attrs); this._maybeRecordConfetti(attrs); + this._maybeBoostTakeover(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); @@ -115,7 +116,9 @@ class TaskMateChildCard extends LitElement { const at = new Date(c.deadline_at).getTime(); return !Number.isNaN(at) && at > now; }) || this._inspectionsOf((attrs.children || []).find(c => c.id === this.config?.child_id)) - .some(i => i.status === "open"); + .some(i => i.status === "open") + // Boost windows: the countdown and the moment a scheduled one begins. + || (window.__taskmate_boosts ? window.__taskmate_boosts(attrs, this.config?.child_id).length > 0 : false); if ((hasRunning || hasDeadline) && !this._timerInterval) { this._timerInterval = setInterval(() => { this._timerTick++; this.requestUpdate(); }, 1000); } else if (!hasRunning && !hasDeadline && this._timerInterval) { @@ -1439,6 +1442,34 @@ class TaskMateChildCard extends LitElement { .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; } + /* Boost windows */ + ha-card { position: relative; } + ha-card:has(.tm-boost-take) { min-height: 460px; } + .tm-care-boost { position: relative; overflow: hidden; background: linear-gradient(120deg, #ffe2bf, #fff1c2); color: #4a2600; border: 1px solid #ffc778; } + .tm-care-boost .tm-boost-body { flex: 1; min-width: 0; } + .tm-boost-bolt { animation: tm-boost-pulse 1.6s ease-in-out infinite; } + .tm-boost-clock { font: 700 1.2rem/1 ui-monospace, Menlo, monospace; color: #b34f00; text-align: end; flex: none; } + .tm-boost-clock small { display: block; font: 600 .62rem sans-serif; color: #8a5a2a; text-transform: uppercase; letter-spacing: .06em; margin-top: 3px; } + .tm-boost-bar { position: absolute; inset-inline-start: 0; bottom: 0; height: 4px; background: #ff7a00; transition: width 1s linear; } + .tm-care-boost-next { background: #f4f6fa; color: #4b5468; } + .tm-boost-old { text-decoration: line-through; opacity: .6; } + .tm-boost-new { color: #b34f00; } + .tm-boost-x, .tm-boost-tag { background: #ff7a00 !important; color: #fff !important; border-radius: 999px; padding: 1px 7px; font-weight: 800; font-size: .75em; } + @keyframes tm-boost-pulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.15); } } + .tm-boost-take { position: absolute; inset: 0; z-index: 30; display: flex; align-items: center; justify-content: center; padding: 20px; cursor: pointer; + background: radial-gradient(circle at 50% 35%, #ffb300 0%, #ff7a00 55%, #d84b00 100%); color: #fff; text-align: center; border-radius: inherit; animation: tm-boost-in .4s ease-out; } + .tm-boost-take-in { cursor: default; display: flex; flex-direction: column; align-items: center; gap: 4px; max-width: 360px; } + .tm-boost-take-bolt { font-size: 4rem; line-height: 1; animation: tm-boost-pulse 1.2s ease-in-out infinite; filter: drop-shadow(0 6px 12px rgba(0,0,0,.25)); } + .tm-boost-take-title { font-size: 2rem; font-weight: 800; letter-spacing: .02em; } + .tm-boost-take-mult { font: 900 3.6rem/1 sans-serif; text-shadow: 0 4px 0 rgba(0,0,0,.15); } + .tm-boost-take-chips { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; margin-top: 4px; } + .tm-boost-take-chips span { background: rgba(255,255,255,.22); border-radius: 999px; padding: 4px 11px; font-weight: 700; font-size: .9rem; } + .tm-boost-take-sub { font-size: 1.05rem; } + .tm-boost-take-clock { font: 800 2rem/1 ui-monospace, Menlo, monospace; background: rgba(0,0,0,.18); padding: 8px 16px; border-radius: 14px; margin: 8px 0 2px; } + .tm-boost-take-name { opacity: .85; font-size: .9rem; } + .tm-boost-take-go { margin-top: 12px; background: #fff; color: #c24a00; border: 0; border-radius: 999px; padding: 12px 28px; font: 800 1.05rem sans-serif; cursor: pointer; box-shadow: 0 6px 16px rgba(0,0,0,.2); } + @keyframes tm-boost-in { from { opacity: 0; transform: scale(.96); } to { opacity: 1; transform: none; } } + @media (prefers-reduced-motion: reduce) { .tm-boost-bolt, .tm-boost-take-bolt, .tm-boost-take { animation: none; } } .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; } @@ -2472,7 +2503,7 @@ class TaskMateChildCard extends LitElement { ${this._renderSwappable(allChores, child, pointsIcon)} ${this._celebrating ? this._renderCelebration() : ""} - ${this._renderInspectionCelebration(pointsIcon)} + ${this._renderInspectionCelebration(pointsIcon)}${this._renderBoostTakeover()} ${this._confetti.length > 0 ? this._renderConfetti() : ""} ${this._renderPhotoCapture()} ${this._renderExtraCapture()} @@ -2822,7 +2853,8 @@ class TaskMateChildCard extends LitElement { chore, child, done, loading, onAct, index: i, dimmed, blocked, recLocked, firstComeLocked, weeklyDone, tone: this._designTone(i), glyph: this._choreGlyph(chore), - points: chore.effective_points ?? chore.points, + points: this._boostPoints(chore, child), + boost: this._boostMult(chore, child), timed: chore.task_type === "timed", mandatory: chore.mandatory === true, photo: chore.require_photo === true, @@ -2920,7 +2952,7 @@ class TaskMateChildCard extends LitElement { ${this._renderSwappable(allChores, child, pointsIcon)} ${this._celebrating ? this._renderCelebration() : ""} - ${this._renderInspectionCelebration(pointsIcon)} + ${this._renderInspectionCelebration(pointsIcon)}${this._renderBoostTakeover()} ${this._confetti.length > 0 ? this._renderConfetti() : ""} ${this._renderPhotoCapture()} ${this._renderExtraCapture()} @@ -2998,9 +3030,10 @@ class TaskMateChildCard extends LitElement { ? this._t("child.weekly_target_progress", { done: r.chore._weeklyProgress, target: r.chore._weeklyTarget }) : ""; return html` - ${r.mandatory || r.auction || r.photo || r.openEnded || depNames.length || recLabel || firstComeLabel || weeklyLabel || (r.team && !r.done) + ${(r.boost && !r.done) || r.mandatory || r.auction || r.photo || r.openEnded || depNames.length || recLabel || firstComeLabel || weeklyLabel || (r.team && !r.done) || (!r.done && (r.chore.estimated_minutes || r.chore.quantity_unit || this._helpEnabled())) ? html`
+ ${r.boost && !r.done ? html`⚡ ×${this._fmtMult(r.boost)}` : ""} ${this._choreExtraTags(r.chore, r.done, "tmd-tag")} ${r.done ? "" : this._renderHelp(r.chore, r.child, "tmd-tag")} ${r.mandatory ? html`⚠ ${this._t("child.mandatory")}` : ""} @@ -4172,7 +4205,7 @@ class TaskMateChildCard extends LitElement { ` : ''}
- +${chore.effective_points ?? chore.points} + ${isCompletedForToday ? html`+${chore.effective_points ?? chore.points}` : this._boostedPoints(chore, child)} ${dailyLimit > 1 ? html`(${completionsToday}/${dailyLimit})` : ''} ${chore._weeklyTarget > 0 ? html`${this._t('child.weekly_target_progress', { done: chore._weeklyProgress, target: chore._weeklyTarget })}` : ''}
@@ -5137,6 +5170,23 @@ class TaskMateChildCard extends LitElement { /** Sick-day banner, make-it-right offers and the weekly goal, above the chores. */ _renderCareBlocks(child, mode) { const parts = []; + // Boost windows: a live boost's countdown banner, else the next one coming. + const boosts = this._boosts(child); + const live = boosts.find(b => b.status === "active"); + if (live) { + const total = Math.max(1, live.endsMs - live.startsMs); + const left = Math.max(0, live.endsMs - Date.now()); + parts.push(html`
+ ⚡ + ${live.name ? this._t("child.boost_title_named", { multiplier: this._fmtMult(live.multiplier), name: live.name }) : this._t("child.boost_title", { multiplier: this._fmtMult(live.multiplier) })}
${this._boostWhat(live)}
+ ${this._clock(left)}${this._t("child.boost_left")} + +
`); + } else if (boosts.length) { + const next = boosts[0]; + parts.push(html`
+ 🕘${this._t("child.boost_next", { time: this._boostStart(next), multiplier: this._fmtMult(next.multiplier), what: this._boostWhat(next) })}
`); + } if (child.sick_today) { parts.push(html`
🤒${this._t("child.sick_title")}
${this._t("child.sick_body")}
`); @@ -5190,6 +5240,99 @@ class TaskMateChildCard extends LitElement { return html`
${parts}
${this._renderGoalSheet()}`; } + /* ── Boost windows ──────────────────────────────────────────────────── */ + + _attrsNow() { + return (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config?.entity)) + || this.hass?.states?.[this.config?.entity]?.attributes || {}; + } + + _boosts(child) { + return window.__taskmate_boosts && child ? window.__taskmate_boosts(this._attrsNow(), child.id) : []; + } + + _boostMult(chore, child) { + if (!chore || chore.task_type === "timed" || (chore.auction && chore.auction.child_id)) return 0; + // Nothing to multiply on an open-ended (0-point) chore. + if (!(Number(chore.effective_points ?? chore.points) > 0)) return 0; + const m = window.__taskmate_boost_for ? window.__taskmate_boost_for(this._boosts(child), chore.id) : 0; + return m > 1 ? m : 0; + } + + _boostPoints(chore, child) { + const base = chore.effective_points ?? chore.points; + const m = this._boostMult(chore, child); + return m && Number(base) > 0 ? Math.round(Number(base) * m) : base; + } + + /** Classic row: "+5" or, while boosted, "~~+5~~ +10 ×2". */ + _boostedPoints(chore, child) { + const base = chore.effective_points ?? chore.points; + const m = this._boostMult(chore, child); + if (!m || !(Number(base) > 0)) return html`+${base}`; + return html`+${base} +${Math.round(Number(base) * m)} ×${this._fmtMult(m)}`; + } + + _fmtMult(m) { + return String(Math.round(Number(m) * 10) / 10); + } + + _clock(ms) { + return window.__taskmate_clock ? window.__taskmate_clock(ms) : `${Math.ceil(ms / 60000)}m`; + } + + _boostWhat(boost) { + const names = boost.chore_names || []; + if (!names.length) return this._t("child.boost_all_chores"); + return names.length > 2 + ? this._t("child.boost_some_chores", { first: names.slice(0, 2).join(", "), count: names.length - 2 }) + : names.join(", "); + } + + _boostStart(boost) { + const d = new Date(boost.startsMs); + const time = d.toLocaleTimeString(this.hass?.locale?.language || undefined, { hour: "2-digit", minute: "2-digit" }); + if (d.toDateString() === new Date().toDateString()) return time; + return `${d.toLocaleDateString(this.hass?.locale?.language || undefined, { weekday: "long" })} ${time}`; + } + + /* The takeover plays once per device per boost, then the banner carries on. */ + _maybeBoostTakeover(attrs) { + if (this.config?.show_boost_alert === false || !window.__taskmate_boosts) return; + const child = (attrs.children || []).find(c => c.id === this.config?.child_id); + if (!child) return; + const live = window.__taskmate_boosts(attrs, child.id).find(b => b.status === "active"); + if (this._boostTake && (!live || live.id !== this._boostTake.id)) { this._boostTake = null; this.requestUpdate(); } + if (!live || this._boostTake || (window.__taskmate_boost_seen && window.__taskmate_boost_seen(live.id))) return; + this._boostTake = live; + this.requestUpdate(); + } + + _dismissBoostTakeover() { + if (this._boostTake && window.__taskmate_boost_mark) window.__taskmate_boost_mark(this._boostTake.id); + this._boostTake = null; + this.requestUpdate(); + } + + _renderBoostTakeover() { + const b = this._boostTake; + if (!b) return ""; + const names = b.chore_names || []; + return html` + `; + } + _pointsNameFor() { const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config?.entity)) || this.hass?.states?.[this.config?.entity]?.attributes || {}; @@ -6003,6 +6146,7 @@ class TaskMateChildCardEditor extends LitElement { { name: 'show_mood', selector: { boolean: {} } }, { name: 'show_weekly_goal', selector: { boolean: {} } }, { name: 'show_personal_bests', selector: { boolean: {} } }, + { name: 'show_boost_alert', selector: { boolean: {} } }, { name: 'show_description', selector: { boolean: {} } }, { name: 'pre_reader', selector: { boolean: {} } }, { name: 'pre_reader_labels', selector: { boolean: {} } }, @@ -6030,6 +6174,7 @@ class TaskMateChildCardEditor extends LitElement { 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_boost_alert: this._t('child.editor.show_boost_alert'), 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'), @@ -6076,6 +6221,7 @@ class TaskMateChildCardEditor extends LitElement { 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_boost_alert: this.config.show_boost_alert !== false, show_description: this.config.show_description === true, pre_reader: this.config.pre_reader === true, pre_reader_labels: this.config.pre_reader_labels === true, @@ -6118,7 +6264,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_personal_bests: true, show_boost_alert: 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-design.js b/custom_components/taskmate/www/taskmate-design.js index 8557490b..262d68e4 100644 --- a/custom_components/taskmate/www/taskmate-design.js +++ b/custom_components/taskmate/www/taskmate-design.js @@ -630,6 +630,62 @@ // hass update or page load. Shared by every card that celebrates, so a child // card and a points card on the same dashboard don't both go off. Keyed on // HA's time zone so the day rolls over with the backend, not the browser. + // ── Boost windows ───────────────────────────────────────────────────────── + // The overview sensor's `boosts` list holds live and upcoming boosts. The + // cards work out live/upcoming/over from the clock themselves, so a boost + // starts and ends on screen at the right second even between updates. + // __taskmate_boosts(attrs, childId?, nowMs?) -> boosts for that child + // (or all), each with status "active" | "upcoming", startsMs, endsMs. + // __taskmate_boost_for(boosts, choreId) -> the biggest live multiplier + // covering that chore, or 0. + // __taskmate_boost_seen(id) / __taskmate_boost_mark(id) -> the "show the + // takeover once per device per boost" gate, shared by every card. + window.__taskmate_boosts = window.__taskmate_boosts || function (attrs, childId, nowMs) { + const now = nowMs == null ? Date.now() : nowMs; + const list = (attrs && Array.isArray(attrs.boosts)) ? attrs.boosts : []; + const out = []; + for (const b of list) { + if (!b || !b.id) continue; + const startsMs = Date.parse(b.starts_at); + const endsMs = Date.parse(b.ends_at); + if (isNaN(startsMs) || isNaN(endsMs) || now >= endsMs) continue; + const kids = Array.isArray(b.child_ids) ? b.child_ids : []; + if (childId && kids.length && kids.indexOf(childId) === -1) continue; + out.push(Object.assign({}, b, { status: now < startsMs ? "upcoming" : "active", startsMs: startsMs, endsMs: endsMs })); + } + out.sort(function (a, b) { return (a.status === "active" ? 0 : 1) - (b.status === "active" ? 0 : 1) || a.startsMs - b.startsMs; }); + return out; + }; + window.__taskmate_boost_for = window.__taskmate_boost_for || function (boosts, choreId) { + let best = 0; + for (const b of boosts || []) { + if (b.status !== "active") continue; + const chores = Array.isArray(b.chore_ids) ? b.chore_ids : []; + if (chores.length && chores.indexOf(choreId) === -1) continue; + best = Math.max(best, Number(b.multiplier) || 0); + } + return best; + }; + (function () { + const KEY = "taskmate_boosts_seen"; + const read = function () { + try { const v = JSON.parse(window.localStorage.getItem(KEY) || "[]"); return Array.isArray(v) ? v : []; } catch (e) { return []; } + }; + window.__taskmate_boost_seen = window.__taskmate_boost_seen || function (id) { return read().indexOf(id) !== -1; }; + window.__taskmate_boost_mark = window.__taskmate_boost_mark || function (id) { + const seen = read(); + if (seen.indexOf(id) === -1) seen.push(id); + try { window.localStorage.setItem(KEY, JSON.stringify(seen.slice(-40))); } catch (e) { /* private mode: shows again next load */ } + }; + })(); + // "45:00" / "1:05:00" + window.__taskmate_clock = window.__taskmate_clock || function (ms) { + const s = Math.max(0, Math.ceil(ms / 1000)); + const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60; + const pad = function (n) { return String(n).padStart(2, "0"); }; + return h ? h + ":" + pad(m) + ":" + pad(sec) : pad(m) + ":" + pad(sec); + }; + window.__taskmate_birthday_once = window.__taskmate_birthday_once || function (hass, childId) { if (!childId) return false; let day; diff --git a/custom_components/taskmate/www/taskmate-kiosk-card.js b/custom_components/taskmate/www/taskmate-kiosk-card.js index 41252c62..b9ac1ca8 100644 --- a/custom_components/taskmate/www/taskmate-kiosk-card.js +++ b/custom_components/taskmate/www/taskmate-kiosk-card.js @@ -479,7 +479,87 @@ class TaskMateKioskCard extends LitElement { return !(now >= start && now < Math.min(1440, end + allowance)); } - _pointsFor(chore) { return chore.effective_points ?? chore.points ?? 0; } + _pointsFor(chore, childId) { + const base = chore.effective_points ?? chore.points ?? 0; + const m = this._boostMult(chore, childId); + return m && Number(base) > 0 ? Math.round(Number(base) * m) : base; + } + + // ── Boost windows ──────────────────────────────────────────────────────── + // A live boost wakes the tablet out of ambient mode and fills the screen + // until someone taps; after that a countdown strip sits under the top bar. + + _boosts(childId) { + return window.__taskmate_boosts ? window.__taskmate_boosts(this._attrs(), childId) : []; + } + + _boostMult(chore, childId) { + if (!chore || !childId || chore.task_type === "timed" || (chore.auction && chore.auction.child_id)) return 0; + const m = window.__taskmate_boost_for ? window.__taskmate_boost_for(this._boosts(childId), chore.id) : 0; + return m > 1 ? m : 0; + } + + _fmtMult(m) { return String(Math.round(Number(m) * 10) / 10); } + + _boostWhat(b) { + const names = b.chore_names || []; + if (!names.length) return this._t("child.boost_all_chores"); + return names.length > 2 + ? this._t("child.boost_some_chores", { first: names.slice(0, 2).join(", "), count: names.length - 2 }) + : names.join(", "); + } + + /** Called every second from the tick: is there a live boost this tablet hasn't announced? */ + _checkBoost() { + const live = this._boosts().find(b => b.status === "active"); + if (this._boostTake && (!live || live.id !== this._boostTake.id)) { this._boostTake = null; return true; } + if (!live || this._boostTake || this.config.show_boost_alert === false) return false; + const key = `kiosk:${live.id}`; + if (window.__taskmate_boost_seen && window.__taskmate_boost_seen(key)) return false; + this._boostTake = live; + // Wake out of the ambient screen (and keep the idle timer from sending a + // child's screen back to the picker underneath). + this._ambient = false; + this._pickerIdle = 0; + this._idle = 0; + return true; + } + + _dismissBoost() { + if (this._boostTake && window.__taskmate_boost_mark) window.__taskmate_boost_mark(`kiosk:${this._boostTake.id}`); + this._boostTake = null; + this._pickerIdle = 0; + this.requestUpdate(); + } + + _renderBoostStrip() { + const live = this._boosts().find(b => b.status === "active"); + if (!live || this._boostTake) return ""; + const clock = window.__taskmate_clock ? window.__taskmate_clock(live.endsMs - Date.now()) : ""; + return html`
+ + ${this._t("kiosk.boost_strip", { multiplier: this._fmtMult(live.multiplier), what: this._boostWhat(live) })} + ${clock} +
`; + } + + _renderBoostTakeover(attrs) { + const b = this._boostTake; + if (!b) return ""; + const kids = (attrs.children || []).filter(c => !(b.child_ids || []).length || b.child_ids.includes(c.id)); + const clock = window.__taskmate_clock ? window.__taskmate_clock(b.endsMs - Date.now()) : ""; + return html` + `; + } /** The cheapest reward this child can't afford yet — what they're saving for. */ _nextReward(child) { @@ -1078,6 +1158,9 @@ class TaskMateKioskCard extends LitElement { const prevMinute = this._now.getMinutes(); this._now = new Date(); let dirty = this._now.getMinutes() !== prevMinute; + if (this._checkBoost()) dirty = true; + // A live boost's countdown ticks every second. + if (!dirty && this._boosts().some(b => b.status === "active")) dirty = true; if (this._screen === "pin" && this._lockedUntil) { if (this._lockSeconds() === 0) this._lockedUntil = 0; dirty = true; @@ -1164,8 +1247,10 @@ class TaskMateKioskCard extends LitElement { ` : ""}
+ ${this._renderBoostStrip()}
${body}
${this._renderRaffleReveal()} + ${this._renderBoostTakeover(attrs)} ${this._showBounties() ? html` this._bountyPhotoPicked(e)} @cancel=${() => this._photoCancelled()}>` : ""} @@ -1403,7 +1488,8 @@ class TaskMateKioskCard extends LitElement {
${chore.name}
- ${this._pointsFor(chore)} + ${this._pointsFor(chore, child.id)} + ${this._boostMult(chore, child.id) ? html`×${this._fmtMult(this._boostMult(chore, child.id))}` : ""} ${needsCheck ? html`${this._t("kiosk.needs_check")}` : ""} ${undoable ? html` +
@@ -5026,6 +5036,347 @@ class TaskMatePanel extends HTMLElement { `; } + // ── Boost windows ───────────────────────────────────────────────────── + // A boost is started, ended and skipped through the start_boost / + // end_boost services (the same door automations use); the recurring rules + // and the test wake are admin websocket commands. + + _boostList() { + const fn = window.__taskmate_boosts; + const list = this._state.boosts || []; + if (fn) return fn({ boosts: list }); + const now = Date.now(); + return list.map(b => ({ ...b, startsMs: Date.parse(b.starts_at), endsMs: Date.parse(b.ends_at) })) + .filter(b => b.endsMs > now).map(b => ({ ...b, status: b.startsMs > now ? "upcoming" : "active" })); + } + + _fmtMult(m) { return String(Math.round(Number(m) * 10) / 10); } + + _boostChoreText(choreIds) { + if (!choreIds || !choreIds.length) return this._t("panel.boost_all_chores"); + const names = Object.fromEntries((this._state.chores || []).map(c => [c.id, c.name])); + const list = choreIds.map(id => names[id]).filter(Boolean); + return list.length > 3 ? this._t("panel.boost_n_chores", { first: list.slice(0, 2).join(", "), count: list.length - 2 }) : list.join(", "); + } + + _boostKidText(childIds) { + if (!childIds || !childIds.length) return this._t("panel.boost_everyone"); + const names = Object.fromEntries((this._state.children || []).map(c => [c.id, c.name])); + return childIds.map(id => names[id]).filter(Boolean).join(", "); + } + + _hm(ms) { + return new Date(ms).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + } + + _renderBoostsCard() { + const boosts = this._boostList(); + if (!boosts.length) return ""; + const live = boosts.filter(b => b.status === "active").length; + return ` +
+

${this._t("panel.boost_card_title")} + ${live ? `${this._t("panel.boost_live", { count: live })}` : ""} + +

+
+ ${boosts.map(b => { + const active = b.status === "active"; + const when = active + ? this._t("panel.boost_ends", { time: this._hm(b.endsMs), minutes: Math.max(1, Math.ceil((b.endsMs - Date.now()) / 60000)) }) + : this._t("panel.boost_starts", { start: this._hm(b.startsMs), end: this._hm(b.endsMs) }); + const btn = active ? "panel.boost_end_now" : (b.rule_id ? "panel.boost_skip" : "panel.boost_call_off"); + return ` +
+ +
+ ×${this._esc(this._fmtMult(b.multiplier))}${b.name ? ` · ${this._esc(b.name)}` : ""} +
${this._esc(this._boostChoreText(b.chore_ids))} · ${this._esc(this._boostKidText(b.child_ids))} · ${this._esc(when)}${b.rule_id ? ` · ${this._t("panel.boost_recurring")}` : ""}
+
+ +
`; + }).join("")} +
+
`; + } + + _boostChips(d, kindAct) { + const chip = (act, id, on, label) => + ``; + const chores = (this._state.chores || []).filter(c => c.enabled !== false && c.task_type !== "timed"); + const kids = (this._state.children || []).filter(c => !c.is_adult); + return ` +
+ ${this._t("panel.boost_pays")} +
${[1.5, 2, 3].map(m => chip("boost-mult", m, Number(d.multiplier) === m, `×${m}`)).join("")}
+
+ ${kindAct === "boost" ? ` +
+ ${this._t("panel.boost_for")} +
${[15, 30, 45, 60, 120].map(n => chip("boost-len", n, Number(d.minutes) === n, + n < 60 ? this._t("panel.boost_minutes", { count: n }) : this._t(n === 60 ? "panel.boost_hour" : "panel.boost_hours", { count: n / 60 }))).join("")}
+
` : ""} +
+ ${this._t("panel.boost_which_chores")} +
+ ${chip("boost-chore", "__all__", !d.chore_ids.length, this._t("panel.boost_all_chores"))} + ${chores.map(c => chip("boost-chore", c.id, d.chore_ids.includes(c.id), this._esc(c.name))).join("")} +
+
+
+ ${this._t("panel.boost_who")} +
+ ${chip("boost-kid", "__all__", !d.child_ids.length, this._t("panel.boost_everyone"))} + ${kids.map(c => chip("boost-kid", c.id, d.child_ids.includes(c.id), this._esc(c.name))).join("")} +
+
+
+ ${this._t("panel.boost_name")} + +
+ ${this._switch(this._t("panel.boost_notify"), "notify", d.notify !== false, this._t("panel.boost_notify_hint"))}`; + } + + _renderBoostDialog() { + const d = this._dialog.data; + const body = ` +
+ ${this._t("panel.boost_when")} +
+ + + ${d.when === "later" ? `` : ""} +
+
+ ${this._boostChips(d, "boost")} +
⚡ ${this._esc(this._boostPreview(d))}
`; + return this._dialogShell(this._t("panel.boost_dialog_title"), body, + ` + `); + } + + _boostPreview(d) { + const what = this._boostChoreText(d.chore_ids); + // Mid-sentence, "everyone" stays lower case. + const who = d.child_ids && d.child_ids.length ? this._boostKidText(d.child_ids) : this._t("panel.boost_everyone_lc"); + const mult = this._fmtMult(d.multiplier); + if (this._dialog.kind === "boost-rule") { + const dayNames = (d.days || []).map(i => this._weekdayShort(i)).join(", ") || "—"; + return this._t("panel.boost_preview_rule", { what, who, multiplier: mult, days: dayNames, start: d.start || "", end: d.end || "" }); + } + const minutes = Number(d.minutes) || 0; + if (d.when === "later") return this._t("panel.boost_preview_later", { what, who, multiplier: mult, time: d.later || "", minutes }); + return this._t("panel.boost_preview_now", { what, who, multiplier: mult, minutes, until: this._hm(Date.now() + minutes * 60000) }); + } + + _weekdayShort(i) { + // 2024-01-01 was a Monday, so day i of that week is weekday i. + return new Date(2024, 0, 1 + Number(i)).toLocaleDateString([], { weekday: "short" }); + } + + _renderBoostRuleDialog() { + const d = this._dialog.data; + const dayChips = [0, 1, 2, 3, 4, 5, 6].map(i => + ``).join(""); + const body = ` +
+ ${this._t("panel.boost_days")} +
${dayChips}
+
+
+
${this._t("panel.boost_from")}
+
${this._t("panel.boost_to")}
+
+ ${this._boostChips(d, "boost-rule")} +
⚡ ${this._esc(this._boostPreview(d))}
`; + return this._dialogShell(this._t(d.rule_id ? "panel.boost_rule_edit" : "panel.boost_rule_add"), body, + `${d.rule_id ? `` : ""} + + `); + } + + _renderBoostSettings(s) { + const cand = this._state.boost_wake_candidates || { screens: [], devices: [] }; + const screens = s.boost_wake_screens || []; + const devices = s.boost_wake_devices || []; + const stacking = s.boost_stacking === "stack" ? "stack" : "highest"; + const rules = this._state.boost_rules || []; + const ck = (attr, id, label, tag, on) => ` + `; + // Picked before but gone from HA since: keep it visible so saving doesn't drop it silently. + const screenRows = [...cand.screens, ...screens.filter(id => !cand.screens.some(c => c.entity_id === id)).map(id => ({ entity_id: id, name: id }))]; + const deviceRows = [...cand.devices, ...devices.filter(id => !cand.devices.some(c => c.service === id)).map(id => ({ service: id, name: id }))]; + const radio = (value, title, hint) => ` + `; + return ` +
+

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

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

+
+
+
${this._t("panel.boost_stacking")}${this._t("panel.boost_stacking_hint")}
+
+ ${radio("highest", this._t("panel.boost_stacking_highest"), this._t("panel.boost_stacking_highest_hint"))} + ${radio("stack", this._t("panel.boost_stacking_stack"), this._t("panel.boost_stacking_stack_hint"))} +
+
+
+
${this._t("panel.boost_wake_screens")}${this._t("panel.boost_wake_screens_hint")}
+
${screenRows.length + ? screenRows.map(c => ck("data-boost-screen", c.entity_id, c.name, "Fully Kiosk", screens.includes(c.entity_id))).join("") + : `${this._t("panel.boost_wake_screens_none")}`}
+
+
+
${this._t("panel.boost_wake_devices")}${this._t("panel.boost_wake_devices_hint")}
+
${deviceRows.length + ? deviceRows.map(c => ck("data-boost-device", c.service, c.name, this._t("panel.boost_companion_tag"), devices.includes(c.service))).join("") + : `${this._t("panel.boost_wake_devices_none")}`} + ${screens.length || devices.length ? `
+ ${this._t("panel.boost_test_wake_hint")}
` : ""} +
+
+
+
${this._t("panel.boost_rules")}${this._t("panel.boost_rules_hint")}
+
+ ${rules.map(r => ` +
+
${[0, 1, 2, 3, 4, 5, 6].map(i => `${this._esc(this._weekdayShort(i).slice(0, 2))}`).join("")}
+
${this._esc(r.name || this._t("panel.boost_rule_unnamed"))} · ×${this._esc(this._fmtMult(r.multiplier))} +
${this._esc(r.start)}–${this._esc(r.end)} · ${this._esc(this._boostChoreText(r.chore_ids))} · ${this._esc(this._boostKidText(r.child_ids))}
+ + +
`).join("")} + +
+
+
+
`; + } + + _boostClick(act, t) { + const d = this._dialog && (this._dialog.kind === "boost" || this._dialog.kind === "boost-rule") ? this._dialog.data : null; + const toggleList = (list, id) => { + if (id === "__all__") return []; + return list.includes(id) ? list.filter(x => x !== id) : [...list, id]; + }; + switch (act) { + case "boost-open": + this._openDialog({ kind: "boost", data: { when: "now", later: "", multiplier: 2, minutes: 45, chore_ids: [], child_ids: [], name: "", notify: true } }); + return true; + case "boost-rule-add": + this._openDialog({ kind: "boost-rule", data: { rule_id: "", days: [5], start: "09:00", end: "11:00", multiplier: 2, chore_ids: [], child_ids: [], name: "", notify: true, enabled: true } }); + return true; + case "boost-rule-edit": { + const r = (this._state.boost_rules || []).find(x => x.id === t.dataset.id); + if (!r) return true; + this._openDialog({ kind: "boost-rule", data: { rule_id: r.id, days: [...(r.days || [])], start: r.start, end: r.end, multiplier: r.multiplier, + chore_ids: [...(r.chore_ids || [])], child_ids: [...(r.child_ids || [])], name: r.name || "", notify: r.notify !== false, enabled: r.enabled !== false } }); + return true; + } + case "boost-rule-toggle": { + const r = (this._state.boost_rules || []).find(x => x.id === t.dataset.id); + if (r) this._saveBoostRule({ ...r, rule_id: r.id, enabled: !(r.enabled !== false) }, false); + return true; + } + case "boost-end": + this._endBoost(t.dataset.id); + return true; + case "boost-test-wake": + this._boostTestWake(); + return true; + case "boost-save": + this._startBoostFromDialog(); + return true; + case "boost-rule-save": + if (d) this._saveBoostRule(d, true); + return true; + case "boost-rule-delete": + if (d) this._deleteBoostRule(d.rule_id); + return true; + } + if (!d) return false; + switch (act) { + case "boost-when": d.when = t.dataset.id; break; + case "boost-mult": d.multiplier = Number(t.dataset.id); break; + case "boost-len": d.minutes = Number(t.dataset.id); break; + case "boost-chore": d.chore_ids = toggleList(d.chore_ids, t.dataset.id); break; + case "boost-kid": d.child_ids = toggleList(d.child_ids, t.dataset.id); break; + case "boost-day": { + const i = Number(t.dataset.id); + d.days = d.days.includes(i) ? d.days.filter(x => x !== i) : [...d.days, i].sort(); + break; + } + default: return false; + } + this._syncDialogInputs && this._syncDialogInputs(); + this._render(); + return true; + } + + async _startBoostFromDialog() { + const d = this._dialog.data; + const data = { multiplier: Number(d.multiplier), minutes: Number(d.minutes), chore_ids: d.chore_ids, child_ids: d.child_ids, + name: String(d.name || "").trim(), notify: d.notify !== false }; + if (d.when === "later") { + const m = /^(\d{2}):(\d{2})$/.exec(d.later || ""); + const at = m ? new Date() : null; + if (at) at.setHours(Number(m[1]), Number(m[2]), 0, 0); + if (!at || at.getTime() <= Date.now()) { this._showToast("err", this._t("panel.boost_later_invalid")); return; } + data.starts_at = at.toISOString(); + } + const { ok, err } = await this._callService("start_boost", data); + if (!ok) { this._showToast("err", err); return; } + this._closeDialog(true); + await this._fetchState(); + this._showToast("ok", this._t(d.when === "later" ? "panel.boost_scheduled" : "panel.boost_started")); + } + + async _endBoost(id) { + const { ok, err } = await this._callService("end_boost", { boost_id: id }); + if (!ok) { this._showToast("err", err); return; } + await this._fetchState(); + this._showToast("ok", this._t("panel.boost_ended")); + } + + async _saveBoostRule(d, fromDialog) { + if (!d.days || !d.days.length) { this._showToast("err", this._t("panel.boost_rule_need_day")); return; } + if (!(d.start < d.end)) { this._showToast("err", this._t("panel.boost_rule_bad_times")); return; } + const payload = { type: "taskmate/boosts/save_rule", days: d.days, start: d.start, end: d.end, multiplier: Number(d.multiplier), + chore_ids: d.chore_ids || [], child_ids: d.child_ids || [], name: String(d.name || "").trim(), notify: d.notify !== false, enabled: d.enabled !== false }; + if (d.rule_id) payload.rule_id = d.rule_id; + try { + await this._callWS(payload); + } catch (err) { + this._showToast("err", (err && err.message) || String(err)); + return; + } + if (fromDialog) this._closeDialog(true); + await this._fetchState(); + this._showToast("ok", this._t("panel.boost_rule_saved")); + } + + async _deleteBoostRule(id) { + if (!id || !confirm(this._t("panel.boost_rule_delete_confirm"))) return; + try { + await this._callWS({ type: "taskmate/boosts/delete_rule", rule_id: id }); + } catch (err) { + this._showToast("err", (err && err.message) || String(err)); + return; + } + this._closeDialog(true); + await this._fetchState(); + this._showToast("ok", this._t("panel.boost_rule_deleted")); + } + + async _boostTestWake() { + try { + const res = await this._callWS({ type: "taskmate/boosts/test_wake" }); + this._showToast("ok", this._t("panel.boost_test_wake_sent", { count: (res && res.sent) || 0 })); + } catch (err) { + this._showToast("err", (err && err.message) || String(err)); + } + } + /** "I'm stuck" requests still open, with an "On my way" button each. */ _renderHelpCard(kids) { const ids = new Set(kids.map(k => k.id)); @@ -9703,6 +10054,7 @@ class TaskMatePanel extends HTMLElement {
+ ${this._renderBoostSettings(s)}

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

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

@@ -10397,6 +10749,8 @@ class TaskMatePanel extends HTMLElement { if (this._dialog.kind === "avatar-catalog") return this._renderAvatarCatalogDialog(); if (this._dialog.kind === "challenge") return this._renderChallengeDialog(); if (this._dialog.kind === "bounty") return this._renderBountyDialog(); + if (this._dialog.kind === "boost") return this._renderBoostDialog(); + if (this._dialog.kind === "boost-rule") return this._renderBoostRuleDialog(); if (this._dialog.kind === "auction") return this._renderAuctionDialog(); if (this._dialog.kind === "apply-penalty") return this._renderApplyDialog("penalty"); if (this._dialog.kind === "apply-bonus") return this._renderApplyDialog("bonus"); @@ -14111,6 +14465,29 @@ class TaskMatePanel extends HTMLElement { .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; } + /* Boost windows */ + .tm-boost-card { border-inline-start: 4px solid #ff7a00; } + .tm-boost-pill { background: #fff1e0; color: #b34f00; } + .tm-boost-btn { background: #ff7a00 !important; border-color: #ff7a00 !important; color: #fff !important; } + .tm-boost-row { justify-content: flex-start; } + .tm-boost-row .tm-help-text { flex: 1; } + .tm-boost-ic { width: 36px; height: 36px; border-radius: 10px; display: grid; place-items: center; font-size: 1.2rem; background: var(--secondary-background-color); flex: none; } + .tm-boost-live .tm-boost-ic { background: #fff1e0; } + .tm-boost-preview { background: #fff8ef; border: 1px dashed #ffc778; border-radius: 10px; padding: 10px 12px; color: #6b3a00; font-size: .92rem; } + .tm-setting-top { align-items: flex-start; } + .tm-boost-radios, .tm-boost-cks, .tm-boost-rules { display: flex; flex-direction: column; gap: 8px; flex: 1; min-width: 0; } + .tm-boost-radio { display: flex; gap: 10px; align-items: flex-start; border: 1px solid var(--divider-color); border-radius: 10px; padding: 10px 12px; cursor: pointer; } + .tm-boost-radio:has(input:checked) { border-color: var(--primary-color); background: color-mix(in srgb, var(--primary-color) 7%, transparent); } + .tm-boost-radio small { display: block; color: var(--secondary-text-color); } + .tm-boost-ck { display: flex; align-items: center; gap: 8px; } + .tm-boost-tag { font-size: .72rem; padding: 2px 7px; border-radius: 999px; background: var(--secondary-background-color); color: var(--secondary-text-color); } + .tm-boost-rule { display: flex; align-items: center; gap: 12px; border: 1px solid var(--divider-color); border-radius: 10px; padding: 10px 12px; } + .tm-boost-rule.off { opacity: .6; } + .tm-boost-rule .tm-help-text { flex: 1; min-width: 0; } + .tm-boost-days { display: flex; gap: 3px; flex: none; } + .tm-boost-days span { width: 24px; height: 24px; border-radius: 50%; display: grid; place-items: center; font-size: .66rem; font-weight: 700; + background: var(--secondary-background-color); color: var(--secondary-text-color); } + .tm-boost-days span.on { background: #ff7a00; color: #fff; } .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/mkdocs.yml b/mkdocs.yml index 5e36212d..71b744ac 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -110,6 +110,7 @@ nav: - "Wishlist": "Wishlist.md" - "Raffles": "Raffles.md" - "Reward Fulfilment": "Reward-Fulfilment.md" + - "Boost Windows": "Boost-Windows.md" - "Gamification": - "Levels & XP": "Levels-and-XP.md" - "Achievement Badges": "Achievement-Badges.md" diff --git a/tests/data/locale_english_baseline.json b/tests/data/locale_english_baseline.json index c0cef055..6a530453 100644 --- a/tests/data/locale_english_baseline.json +++ b/tests/data/locale_english_baseline.json @@ -8,6 +8,9 @@ "bounty.ok", "child.bonus_label", "child.bonus_tooltip", + "child.boost_takeover_title", + "child.boost_title", + "child.boost_title_named", "child.extra_submit", "child.level_label", "child.pause", @@ -16,6 +19,7 @@ "common.design.console", "common.design.playroom", "dashboard.tab_rotation", + "kiosk.boost_strip", "notify.celebration", "notify.month_year", "notify.period_range", @@ -24,6 +28,10 @@ "panel.badge_tab_title", "panel.badge_table_badge", "panel.badge_table_bonus", + "panel.boost_card_title", + "panel.boost_minutes", + "panel.boost_rule_unnamed", + "panel.boost_starts", "panel.bounty_col_status", "panel.bounty_completed_meta", "panel.child_avatar_label", @@ -67,11 +75,14 @@ "bounty.ok", "child.bonus_label", "child.bonus_tooltip", + "child.boost_takeover_title", + "child.boost_title", "child.pause", "common.design.cleanpro", "common.design.console", "common.design.playroom", "dashboard.tab_rotation", + "kiosk.boost_strip", "notify.celebration", "notify.month_11", "notify.month_4", @@ -91,6 +102,10 @@ "overview.default_title", "panel.badge_name_label", "panel.badge_table_bonus", + "panel.boost_card_title", + "panel.boost_name", + "panel.boost_rule_unnamed", + "panel.boost_starts", "panel.bounty_col_status", "panel.bounty_completed_meta", "panel.child_avatar_label", @@ -141,6 +156,8 @@ "bounty.ok", "bounty.photo_tag", "child.bonus_label", + "child.boost_title", + "child.boost_title_named", "child.parent", "child.pause", "child.pts_per_min", @@ -157,6 +174,7 @@ "dashboard.tab_rotation", "graph.total", "inspection.tag_open", + "kiosk.boost_strip", "notify.celebration", "notify.month_year", "notify.period_range", @@ -175,6 +193,10 @@ "panel.badge_table_badge", "panel.badge_table_bonus", "panel.badge_table_source", + "panel.boost_card_title", + "panel.boost_minutes", + "panel.boost_rule_unnamed", + "panel.boost_starts", "panel.bounty_col_points", "panel.bounty_completed_meta", "panel.bounty_points_label", @@ -232,6 +254,8 @@ "bounty.ok", "child.bonus_label", "child.bonus_tooltip", + "child.boost_takeover_title", + "child.boost_title", "child.error_title", "child.extra_submit", "child.pause", @@ -242,12 +266,16 @@ "common.design.cleanpro", "common.design.console", "common.design.playroom", + "kiosk.boost_strip", "notify.celebration", "notify.month_year", "notify.period_range", "notify.pet_evolved", "overview.default_title", "panel.badge_table_bonus", + "panel.boost_minutes", + "panel.boost_rule_unnamed", + "panel.boost_starts", "panel.bounty_col_status", "panel.bounty_completed_meta", "panel.child_avatar_label", @@ -287,11 +315,15 @@ "bounty.open_count", "child.bonus_label", "child.bonus_tooltip", + "child.boost_takeover_title", + "child.boost_title", + "child.boost_title_named", "child.pts_per_min", "child.pts_per_n_min", "child.start", "child.timed_cap_label", "common.design.console", + "kiosk.boost_strip", "kiosk.editor.show_confetti", "kiosk.raffle_winner", "notify.celebration", @@ -303,6 +335,10 @@ "panel.auction_col_max", "panel.auction_min_meta", "panel.badge_table_bonus", + "panel.boost_card_title", + "panel.boost_minutes", + "panel.boost_rule_unnamed", + "panel.boost_starts", "panel.bounty_col_status", "panel.bounty_completed_meta", "panel.bounty_status_open", @@ -353,6 +389,8 @@ "bounty.ok", "child.bonus_label", "child.bonus_tooltip", + "child.boost_takeover_title", + "child.boost_title", "child.error_title", "child.extra_submit", "child.pause", @@ -363,12 +401,16 @@ "common.design.cleanpro", "common.design.console", "common.design.playroom", + "kiosk.boost_strip", "notify.celebration", "notify.month_year", "notify.period_range", "notify.pet_evolved", "overview.default_title", "panel.badge_table_bonus", + "panel.boost_minutes", + "panel.boost_rule_unnamed", + "panel.boost_starts", "panel.bounty_col_status", "panel.bounty_completed_meta", "panel.child_avatar_label", @@ -404,17 +446,24 @@ "pl.json": [ "approvals.timed_duration", "badge.source_auto", + "child.boost_takeover_title", + "child.boost_title", + "child.boost_title_named", "child.pts_per_min", "child.pts_per_n_min", "child.start", "child.timed_cap_label", "common.design.cleanpro", + "kiosk.boost_strip", "notify.celebration", "notify.month_year", "notify.period_range", "notify.pet_evolved", "notify.short_date", "overview.default_title", + "panel.boost_minutes", + "panel.boost_rule_unnamed", + "panel.boost_starts", "panel.bounty_col_status", "panel.bounty_completed_meta", "panel.dash_card_kiosk", @@ -437,6 +486,9 @@ "badge.source_manual", "badge.tier_bronze", "bounty.ok", + "child.boost_takeover_title", + "child.boost_title", + "child.boost_title_named", "child.error_title", "child.pts_per_min", "child.pts_per_n_min", @@ -446,10 +498,15 @@ "common.design.playroom", "common.total", "graph.total", + "kiosk.boost_strip", "notify.celebration", "notify.period_range", "notify.pet_evolved", "overview.default_title", + "panel.boost_card_title", + "panel.boost_minutes", + "panel.boost_rule_unnamed", + "panel.boost_starts", "panel.bounty_col_status", "panel.bounty_completed_meta", "panel.child_avatar_label", @@ -476,6 +533,9 @@ "badge.source_manual", "badge.tier_bronze", "bounty.ok", + "child.boost_takeover_title", + "child.boost_title", + "child.boost_title_named", "child.error_title", "child.pts_per_min", "child.pts_per_n_min", @@ -485,10 +545,15 @@ "common.design.playroom", "common.total", "graph.total", + "kiosk.boost_strip", "notify.celebration", "notify.period_range", "notify.pet_evolved", "overview.default_title", + "panel.boost_card_title", + "panel.boost_minutes", + "panel.boost_rule_unnamed", + "panel.boost_starts", "panel.bounty_completed_meta", "panel.child_avatar_label", "panel.chore_subtask_points_placeholder", diff --git a/tests/frontend/taskmate-boosts.test.cjs b/tests/frontend/taskmate-boosts.test.cjs new file mode 100644 index 00000000..4114584c --- /dev/null +++ b/tests/frontend/taskmate-boosts.test.cjs @@ -0,0 +1,201 @@ +const assert = require("node:assert/strict"); +const { test } = require("node:test"); +const { readFileSync } = require("node:fs"); +const path = require("node:path"); +const vm = require("node:vm"); + +const { loadCard, render, localize, WWW } = require("./harness.cjs"); + +// Boost windows on the child card (every design), the kiosk card and the +// admin panel. The boost helpers come from the real taskmate-design.js. + +const DESIGNS = ["classic", "playroom", "console", "cleanpro", "accessible", "graphite"]; +const ENTITY = "sensor.taskmate_overview"; +const plain = (v) => JSON.parse(JSON.stringify(v)); + +function designHelpers() { + const store = {}; + const window = { matchMedia: () => ({ matches: false }), localStorage: { getItem: (k) => store[k] ?? null, setItem: (k, v) => { store[k] = v; } } }; + const el = () => ({ style: {}, setAttribute() {}, appendChild() {} }); + vm.runInNewContext(readFileSync(path.join(WWW, "taskmate-design.js"), "utf8"), { + window, + document: { documentElement: { getAttribute: () => "" }, getElementById: () => ({}), createElement: el, head: { appendChild() {} }, body: { appendChild() {} }, addEventListener() {} }, + customElements: { get: () => undefined }, + }); + const pick = {}; + for (const k of ["__taskmate_boosts", "__taskmate_boost_for", "__taskmate_boost_seen", "__taskmate_boost_mark", "__taskmate_clock"]) pick[k] = window[k]; + return pick; +} + +const minutes = (n) => new Date(Date.now() + n * 60000).toISOString(); +const liveBoost = (over = {}) => ({ id: "b1", name: "Guests at six", multiplier: 2, chore_ids: ["bins"], chore_names: ["Take the bins out"], + child_ids: [], starts_at: minutes(-5), ends_at: minutes(40), minutes: 45, ...over }); + +// ── child card ──────────────────────────────────────────────────────────── + +function childCard({ design = "classic", boosts = [liveBoost()], config = {} } = {}) { + const ChildCard = loadCard("taskmate-child-card.js", { window: designHelpers() }).get("taskmate-child-card"); + const card = new ChildCard(); + card.setConfig({ entity: ENTITY, child_id: "kid1", card_design: design, ...config }); + card.hass = { + states: { [ENTITY]: { state: "ok", attributes: { + children: [{ id: "kid1", name: "Mia", chore_order: [] }], + chores: [ + { id: "bins", name: "Take the bins out", points: 5, time_category: "anytime", assigned_to: [], due_days: [], schedule_mode: "specific_days", assignment_mode: "everyone" }, + { id: "bed", name: "Make bed", points: 2, 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", boosts, + } } }, + callService: async () => {}, + }; + card._loading = {}; + card._optimisticCompletions = {}; + card._playSound = () => {}; + card._spawnConfetti = () => {}; + return card; +} + +for (const design of DESIGNS) { + test(`child: ${design} shows the live boost banner and the boosted chore`, () => { + const { markup } = render(childCard({ design }).render()); + assert.ok(markup.includes(localize("child.boost_title_named", { multiplier: "2", name: "Guests at six" }))); + assert.match(markup, /tm-boost-clock/); + assert.ok(markup.includes("×2"), "the chore carries the ×2 chip"); + assert.match(markup, /\+10|>10 { + const { markup } = render(childCard({ design, boosts: [liveBoost({ child_ids: ["someone-else"] })] }).render()); + assert.doesNotMatch(markup, /tm-care-boost|tm-boost-x|tm-boost-tag/); + }); +} + +test("child: an upcoming boost is announced as the next one", () => { + const { markup } = render(childCard({ boosts: [liveBoost({ starts_at: minutes(60), ends_at: minutes(120) })] }).render()); + assert.match(markup, /tm-care-boost-next/); + assert.doesNotMatch(markup, /tm-boost-x/); +}); + +test("child: the takeover shows once, and Let's go dismisses it for good", () => { + const card = childCard(); + const attrs = card.hass.states[ENTITY].attributes; + card._maybeBoostTakeover(attrs); + let out = render(card.render()); + assert.ok(out.markup.includes(localize("child.boost_takeover_title"))); + out.control(localize("child.boost_takeover_go")).click(); + card._maybeBoostTakeover(attrs); + out = render(card.render()); + assert.ok(!out.markup.includes(localize("child.boost_takeover_title"))); +}); + +test("child: the takeover can be switched off per card", () => { + const card = childCard({ config: { show_boost_alert: false } }); + card._maybeBoostTakeover(card.hass.states[ENTITY].attributes); + assert.ok(!render(card.render()).markup.includes(localize("child.boost_takeover_title"))); +}); + +// ── kiosk card ──────────────────────────────────────────────────────────── + +function kiosk(boosts = [liveBoost({ chore_ids: [], chore_names: [] })], config = {}) { + const Kiosk = loadCard("taskmate-kiosk-card.js", { window: designHelpers() }).get("taskmate-kiosk-card"); + const card = new Kiosk(); + card.setConfig({ entity: ENTITY, children: [{ child_id: "kid1" }], ambient_mode: "progress", ...config }); + card.hass = { locale: { language: "en" }, states: { [ENTITY]: { state: "ok", attributes: { + children: [{ id: "kid1", name: "Mia", points: 10 }], chores: [], todays_completions: [], points_icon: "mdi:star", boosts, + } } }, callService: async () => {} }; + return card; +} + +test("kiosk: a live boost wakes the ambient screen and fills it until tapped", () => { + const card = kiosk(); + card._ambient = true; + card._screen = "picker"; + card._tick(); + assert.equal(card._ambient, false, "ambient mode is left"); + let out = render(card.render()); + assert.ok(out.markup.includes(localize("kiosk.boost_title"))); + out.control(localize("kiosk.boost_go")).click(); + card._tick(); + out = render(card.render()); + assert.ok(!out.markup.includes(localize("kiosk.boost_title"))); + assert.match(out.markup, /km-boost-strip/, "the countdown strip stays"); +}); + +test("kiosk: boost_alert off keeps the strip but no takeover", () => { + const card = kiosk(undefined, { show_boost_alert: false }); + card._tick(); + const { markup } = render(card.render()); + assert.ok(!markup.includes(localize("kiosk.boost_title"))); + assert.match(markup, /km-boost-strip/); +}); + +// ── panel ───────────────────────────────────────────────────────────────── + +function panel(extra = {}) { + const TaskMatePanel = loadCard("taskmate-panel.js", { window: designHelpers() }).get("taskmate-panel"); + const p = Object.create(TaskMatePanel.prototype); + p._t = (key, params = {}) => `${key}${Object.keys(params).length ? ":" + JSON.stringify(params) : ""}`; + p._state = { + children: [{ id: "kid1", name: "Malia" }, { id: "kid2", name: "Vaiha" }], + chores: [{ id: "bins", name: "Bins" }, { id: "dish", name: "Dishwasher" }], + boosts: [liveBoost({ chore_ids: ["bins"] }), liveBoost({ id: "rule:r1:2026-10-18", rule_id: "r1", starts_at: minutes(90), ends_at: minutes(150), name: "Sat" })], + boost_rules: [{ id: "r1", name: "Sat", days: [5], start: "09:00", end: "11:00", multiplier: 2, chore_ids: [], child_ids: [], enabled: true }], + boost_wake_candidates: { screens: [{ entity_id: "switch.kitchen_screen", name: "Kitchen screen" }], devices: [{ service: "mobile_app_wall", name: "Wall" }] }, + settings: { boost_wake_screens: ["switch.kitchen_screen"], boost_wake_devices: [] }, + ...extra, + }; + return p; +} + +test("panel: the Today boosts card lists live and upcoming with the right buttons", () => { + const html = panel()._renderBoostsCard(); + assert.match(html, /data-act="boost-end" data-id="b1">panel\.boost_end_now/); + assert.match(html, /data-act="boost-end" data-id="rule:r1:2026-10-18">panel\.boost_skip/); + assert.match(html, /data-act="boost-open"/); + assert.equal(panel({ boosts: [] })._renderBoostsCard(), ""); +}); + +test("panel: Start a boost sends start_boost with the chosen chips", async () => { + const p = panel(); + const calls = []; + p._callService = async (service, data) => { calls.push({ service, data }); return { ok: true }; }; + p._render = () => {}; + p._fetchState = async () => {}; + p._showToast = () => {}; + p._closeDialog = () => {}; + p._openDialog = (d) => { p._dialog = d; }; + p._boostClick("boost-open", { dataset: {} }); + for (const [act, id] of [["boost-mult", "3"], ["boost-len", "30"], ["boost-chore", "dish"], ["boost-kid", "kid2"]]) p._boostClick(act, { dataset: { id } }); + p._dialog.data.name = "Tidy up"; + assert.match(p._renderBoostDialog(), /panel\.boost_preview_now/); + await p._startBoostFromDialog(); + assert.deepEqual(plain(calls), [{ service: "start_boost", data: { multiplier: 3, minutes: 30, chore_ids: ["dish"], child_ids: ["kid2"], name: "Tidy up", notify: true } }]); +}); + +test("panel: settings offer stacking, the wake lists and the rules", () => { + const html = panel()._renderBoostSettings({ boost_wake_screens: ["switch.kitchen_screen"], boost_wake_devices: [] }); + assert.match(html, /name="boost_stacking" value="highest" checked/); + assert.match(html, /data-boost-screen="switch\.kitchen_screen" checked/); + assert.match(html, /data-boost-device="mobile_app_wall" >/); + assert.match(html, /data-act="boost-test-wake"/); + assert.match(html, /data-act="boost-rule-edit" data-id="r1"/); + assert.match(html, /data-act="boost-rule-add"/); +}); + +test("panel: a recurring boost is saved over the websocket", async () => { + const p = panel(); + const sent = []; + p._callWS = async (msg) => { sent.push(msg); return {}; }; + p._render = () => {}; + p._fetchState = async () => {}; + p._showToast = () => {}; + p._closeDialog = () => {}; + p._openDialog = (d) => { p._dialog = d; }; + p._boostClick("boost-rule-add", { dataset: {} }); + p._boostClick("boost-day", { dataset: { id: "6" } }); + p._dialog.data.name = "Weekend"; + await p._saveBoostRule(p._dialog.data, true); + assert.equal(sent[0].type, "taskmate/boosts/save_rule"); + assert.deepEqual(plain(sent[0].days), [5, 6]); + assert.equal(sent[0].name, "Weekend"); +}); diff --git a/tests/test_boosts.py b/tests/test_boosts.py new file mode 100644 index 00000000..2f68703d --- /dev/null +++ b/tests/test_boosts.py @@ -0,0 +1,407 @@ +"""Boost windows (#1100): a timed multiplier on chosen chores the kids can see coming.""" + +from __future__ import annotations + +import datetime as dt +from unittest.mock import AsyncMock, patch + +import pytest + +from .test_completion_eligibility import _now, run +from .test_family_care import _at, _family, _fired + +BOOSTS = "custom_components.taskmate.coord_boosts.dt_util" +CHORES = "custom_components.taskmate.coord_chores.dt_util" + + +def _clock(mod, when=None): + import contextlib + + when = when or _now() + stack = contextlib.ExitStack() + for ctx in (_at(mod, when), patch(f"{BOOSTS}.now", return_value=when), patch(f"{CHORES}.now", return_value=when)): + stack.enter_context(ctx) + return stack + + +def _setup(): + coord, storage, mod, kid, chore = _family(requires_approval=False, daily_limit=5) + coord._celebrate = AsyncMock() + coord.badges = None + with _clock(mod): + other = run(coord.async_add_chore("Bins", points=4, requires_approval=False, schedule_mode="specific_days")) + leo = run(coord.async_add_child("Leo")) + other.daily_limit = 5 + storage.update_chore(other) + return coord, storage, mod, kid, chore, other, leo + + +def _points(storage, kid): + return storage.get_child(kid.id).points + + +class TestStart: + def test_boosted_chore_pays_the_multiplier_for_the_window_only(self): + coord, storage, mod, kid, chore, other, _leo = _setup() + with _clock(mod): + boost = run(coord.async_start_boost(multiplier=2, minutes=60, chore_ids=[chore.id])) + assert boost["multiplier"] == 2.0 and boost["status"] == "active" + comp = run(coord.async_complete_chore(chore.id, kid.id)) + assert comp.points_awarded == 10 and comp.submitted_points == 10 + # Not on the list: ordinary pay. + comp2 = run(coord.async_complete_chore(other.id, kid.id)) + assert comp2.points_awarded == 4 + assert _points(storage, kid) == 14 + with _clock(mod, _now() + dt.timedelta(minutes=61)): + comp3 = run(coord.async_complete_chore(chore.id, kid.id)) + assert comp3.points_awarded == 5 + (evt,) = _fired(coord, "taskmate_boost_started") + assert evt["multiplier"] == 2.0 and evt["chore_ids"] == [chore.id] and evt["minutes"] == 60 + + def test_all_chores_and_only_some_children(self): + coord, storage, mod, kid, chore, other, leo = _setup() + with _clock(mod): + run(coord.async_start_boost(multiplier=1.5, minutes=30, child_ids=[leo.id])) + assert run(coord.async_complete_chore(chore.id, kid.id)).points_awarded == 5 + assert run(coord.async_complete_chore(other.id, leo.id)).points_awarded == 6 # 4 × 1.5 + + def test_overlapping_boosts_pay_the_best_one(self): + coord, storage, mod, kid, chore, _other, _leo = _setup() + with _clock(mod): + run(coord.async_start_boost(multiplier=2, minutes=30)) + run(coord.async_start_boost(multiplier=3, minutes=10, chore_ids=[chore.id])) + assert run(coord.async_complete_chore(chore.id, kid.id)).points_awarded == 15 + + def test_notifies_the_children_it_covers(self): + coord, storage, mod, kid, chore, _other, leo = _setup() + coord.notifications.fire.reset_mock() + with _clock(mod): + run( + coord.async_start_boost( + multiplier=2, minutes=45, chore_ids=[chore.id], child_ids=[leo.id], name="Guests at 6" + ) + ) + call = coord.notifications.fire.call_args + assert call.args[0] == "boost_started" + ctx = call.args[1] + assert ( + ctx["multiplier"] == "2" + and ctx["minutes"] == 45 + and ctx["chores_text"] == "Tidy room" + and ctx["name"] == "Guests at 6" + ) + assert call.kwargs["only_recipients"] == {f"child:{leo.id}"} + + def test_no_push_when_asked_not_to(self): + coord, storage, mod, kid, chore, _other, _leo = _setup() + coord.notifications.fire.reset_mock() + with _clock(mod): + run(coord.async_start_boost(multiplier=2, minutes=45, notify=False)) + coord.notifications.fire.assert_not_called() + + def test_validation(self): + coord, storage, mod, kid, chore, _other, _leo = _setup() + with _clock(mod): + with pytest.raises(ValueError, match="multiplier"): + run(coord.async_start_boost(multiplier=1, minutes=30)) + with pytest.raises(ValueError, match="multiplier"): + run(coord.async_start_boost(multiplier=9, minutes=30)) + with pytest.raises(ValueError, match="minutes"): + run(coord.async_start_boost(multiplier=2, minutes=0)) + with pytest.raises(ValueError, match="[Cc]hore"): + run(coord.async_start_boost(multiplier=2, minutes=30, chore_ids=["nope"])) + with pytest.raises(ValueError, match="[Cc]hild"): + run(coord.async_start_boost(multiplier=2, minutes=30, child_ids=["nope"])) + with pytest.raises(ValueError, match="past"): + run(coord.async_start_boost(multiplier=2, minutes=30, starts_at=_now() - dt.timedelta(hours=2))) + + +class TestLifecycle: + def test_state_lists_active_and_upcoming_with_names(self): + coord, storage, mod, kid, chore, _other, _leo = _setup() + with _clock(mod): + run(coord.async_start_boost(multiplier=2, minutes=30, chore_ids=[chore.id])) + run( + coord.async_start_boost( + multiplier=3, minutes=20, starts_at=_now() + dt.timedelta(hours=2), name="Tea time" + ) + ) + state = coord.boosts_state() + assert [b["status"] for b in state] == ["active", "upcoming"] + assert state[0]["chore_ids"] == [chore.id] and state[0]["chore_names"] == ["Tidy room"] + assert state[0]["child_ids"] == [] and state[0]["ends_at"] > state[0]["starts_at"] + assert state[1]["name"] == "Tea time" and state[1]["multiplier"] == 3.0 + with _clock(mod, _now() + dt.timedelta(hours=5)): + assert coord.boosts_state() == [] + + def test_upcoming_boost_is_announced_when_it_begins(self): + coord, storage, mod, kid, chore, _other, _leo = _setup() + with _clock(mod): + run(coord.async_start_boost(multiplier=2, minutes=20, starts_at=_now() + dt.timedelta(hours=1))) + assert _fired(coord, "taskmate_boost_started") == [] + coord.notifications.fire.reset_mock() + with _clock(mod, _now() + dt.timedelta(minutes=30)): + assert run(coord.async_check_boosts()) == 0 + assert run(coord.async_complete_chore(chore.id, kid.id)).points_awarded == 5 + with _clock(mod, _now() + dt.timedelta(hours=1, minutes=1)): + assert run(coord.async_check_boosts()) == 1 + assert run(coord.async_check_boosts()) == 0 + assert run(coord.async_complete_chore(chore.id, kid.id)).points_awarded == 10 + assert len(_fired(coord, "taskmate_boost_started")) == 1 + assert [c.args[0] for c in coord.notifications.fire.call_args_list].count("boost_started") == 1 + + def test_ending_fires_once_on_its_own_and_early_by_hand(self): + coord, storage, mod, kid, chore, _other, _leo = _setup() + with _clock(mod): + b1 = run(coord.async_start_boost(multiplier=2, minutes=10)) + b2 = run(coord.async_start_boost(multiplier=2, minutes=120, name="Long")) + run(coord.async_end_boost(b2["id"])) + with pytest.raises(ValueError): + run(coord.async_end_boost(b2["id"])) + with pytest.raises(ValueError): + run(coord.async_end_boost("nope")) + assert [b["id"] for b in coord.boosts_state()] == [b1["id"]] + ended = _fired(coord, "taskmate_boost_ended") + assert [(e["id"], e["ended_early"]) for e in ended] == [(b2["id"], True)] + with _clock(mod, _now() + dt.timedelta(minutes=11)): + assert run(coord.async_check_boosts()) == 1 + assert run(coord.async_check_boosts()) == 0 + assert coord.boosts_state() == [] + ended = _fired(coord, "taskmate_boost_ended") + assert [(e["id"], e["ended_early"]) for e in ended] == [(b2["id"], True), (b1["id"], False)] + + def test_old_boosts_are_pruned(self): + coord, storage, mod, kid, chore, _other, _leo = _setup() + with _clock(mod): + for i in range(25): + run(coord.async_start_boost(multiplier=2, minutes=1, name=f"b{i}")) + with _clock(mod, _now() + dt.timedelta(hours=1)): + run(coord.async_check_boosts()) + assert len(storage.get_section("boosts", list)) == 20 + + +class TestSpecialDays: + """Saturday 2024-03-23 pays the weekend ×2. ``boost_stacking`` decides.""" + + SAT = dt.datetime(2024, 3, 23, 12, 0, tzinfo=dt.UTC) + + def test_highest_wins_by_default(self): + coord, storage, mod, kid, chore, _other, _leo = _setup() + with _clock(mod, self.SAT): + run(coord.async_start_boost(multiplier=3, minutes=30)) + comp = run(coord.async_complete_chore(chore.id, kid.id)) + # 5 × 3, the weekend ×2 dropped. + assert comp.points_awarded == 15 + assert comp.boost_multiplier == 3.0 and comp.boost_stacked is False + + def test_highest_wins_keeps_the_weekend_when_it_is_bigger(self): + coord, storage, mod, kid, chore, _other, _leo = _setup() + with _clock(mod, self.SAT): + run(coord.async_start_boost(multiplier=1.5, minutes=30)) + comp = run(coord.async_complete_chore(chore.id, kid.id)) + assert comp.points_awarded == 10 + assert comp.boost_multiplier == 0 + + def test_stack_multiplies_both(self): + coord, storage, mod, kid, chore, _other, _leo = _setup() + storage.set_setting("boost_stacking", "stack") + with _clock(mod, self.SAT): + run(coord.async_start_boost(multiplier=2, minutes=30)) + comp = run(coord.async_complete_chore(chore.id, kid.id)) + assert comp.points_awarded == 20 and comp.boost_stacked is True + + def test_approval_later_pays_what_the_window_promised(self): + coord, storage, mod, kid, chore, _other, _leo = _setup() + chore.requires_approval = True + storage.update_chore(chore) + with _clock(mod, self.SAT): + run(coord.async_start_boost(multiplier=3, minutes=30)) + comp = run(coord.async_complete_chore(chore.id, kid.id)) + assert comp.submitted_points == 15 and comp.points_awarded == 0 + with _clock(mod, self.SAT + dt.timedelta(hours=3)): + run(coord.async_approve_chore(comp.id)) + stored = next(c for c in storage.get_completions() if c.id == comp.id) + # No weekend double on top in highest-wins mode, and the window being + # over by approval time changes nothing. + assert stored.points_awarded == 15 + + def test_old_completions_load_without_a_boost(self): + from custom_components.taskmate.models import ChoreCompletion + + c = ChoreCompletion.from_dict({"chore_id": "x", "child_id": "y", "completed_at": _now().isoformat()}) + assert c.boost_multiplier == 0 and c.boost_stacked is False + assert "boost_multiplier" not in c.to_dict() + + +class TestRules: + def _rule(self, coord, **over): + rule = { + "name": "Saturday garden", + "days": [5], + "start": "09:00", + "end": "11:00", + "multiplier": 2, + "enabled": True, + } + rule.update(over) + return run(coord.async_save_boost_rule(rule)) + + def test_a_rule_starts_and_ends_a_boost_on_its_days(self): + coord, storage, mod, kid, chore, _other, _leo = _setup() + sat9 = dt.datetime(2024, 3, 23, 9, 0, 30, tzinfo=dt.UTC) + with _clock(mod, sat9): + rule = self._rule(coord, chore_ids=[chore.id]) + assert run(coord.async_check_boosts()) >= 1 + (b,) = coord.boosts_state() + assert b["rule_id"] == rule["id"] and b["status"] == "active" and b["name"] == "Saturday garden" + assert b["ends_at"].startswith("2024-03-23T11:00") + run(coord.async_check_boosts()) + assert len(coord.boosts_state()) == 1, "one boost per rule per day" + with _clock(mod, dt.datetime(2024, 3, 23, 11, 1, tzinfo=dt.UTC)): + run(coord.async_check_boosts()) + assert coord.boosts_state() == [] + # Not on a Sunday. + with _clock(mod, dt.datetime(2024, 3, 24, 9, 30, tzinfo=dt.UTC)): + run(coord.async_check_boosts()) + assert coord.boosts_state() == [] + + def test_upcoming_rule_shows_ahead_of_time_and_disabled_rules_do_nothing(self): + coord, storage, mod, kid, chore, _other, _leo = _setup() + with _clock(mod, dt.datetime(2024, 3, 23, 7, 0, tzinfo=dt.UTC)): + rule = self._rule(coord) + (b,) = coord.boosts_state() + assert b["status"] == "upcoming" and b["rule_id"] == rule["id"] + run(coord.async_save_boost_rule({**rule, "enabled": False})) + assert coord.boosts_state() == [] + + def test_ending_a_rule_boost_early_holds_for_the_day(self): + coord, storage, mod, kid, chore, _other, _leo = _setup() + with _clock(mod, dt.datetime(2024, 3, 23, 9, 5, tzinfo=dt.UTC)): + self._rule(coord) + run(coord.async_check_boosts()) + (b,) = coord.boosts_state() + run(coord.async_end_boost(b["id"])) + run(coord.async_check_boosts()) + assert coord.boosts_state() == [] + + def test_rule_validation_and_delete(self): + coord, storage, mod, kid, chore, _other, _leo = _setup() + with _clock(mod): + with pytest.raises(ValueError, match="day"): + self._rule(coord, days=[]) + with pytest.raises(ValueError, match="end"): + self._rule(coord, start="11:00", end="09:00") + with pytest.raises(ValueError, match="multiplier"): + self._rule(coord, multiplier=7) + with pytest.raises(ValueError, match="time"): + self._rule(coord, start="9am") + rule = self._rule(coord) + assert [r["id"] for r in coord.boost_rules()] == [rule["id"]] + run(coord.async_delete_boost_rule(rule["id"])) + assert coord.boost_rules() == [] + with pytest.raises(ValueError): + run(coord.async_delete_boost_rule(rule["id"])) + + +class TestWake: + def _registry(self, entries): + from types import SimpleNamespace + + reg = SimpleNamespace(entities={e.entity_id: e for e in entries}) + reg.async_get = lambda eid: reg.entities.get(eid) + return reg + + def _fkb(self, entity_id, unique_id): + from types import SimpleNamespace + + return SimpleNamespace( + entity_id=entity_id, platform="fully_kiosk", unique_id=unique_id, domain=entity_id.split(".")[0] + ) + + def test_fully_kiosk_screen_is_turned_on_screensaver_off_and_browser_brought_forward(self): + coord, storage, mod, kid, chore, _other, _leo = _setup() + coord.hass.services.async_call = AsyncMock() + coord.hass.services.has_service = lambda domain, service: True + reg = self._registry( + [ + self._fkb("switch.kitchen_screen", "abc123-screenOn"), + self._fkb("switch.kitchen_screensaver", "abc123-screensaver"), + self._fkb("button.kitchen_bring_to_foreground", "abc123-toForeground"), + self._fkb("switch.hall_screen", "zzz-screenOn"), + ] + ) + storage.set_setting("boost_wake_screens", ["switch.kitchen_screen"]) + storage.set_setting("boost_wake_devices", ["mobile_app_wall_tablet"]) + with patch("custom_components.taskmate.coord_boosts.er.async_get", return_value=reg), _clock(mod): + run(coord.async_start_boost(multiplier=2, minutes=30)) + calls = [(c.args[0], c.args[1], c.args[2]) for c in coord.hass.services.async_call.call_args_list] + assert ("switch", "turn_on", {"entity_id": "switch.kitchen_screen"}) in calls + assert ("switch", "turn_off", {"entity_id": "switch.kitchen_screensaver"}) in calls + assert ("button", "press", {"entity_id": "button.kitchen_bring_to_foreground"}) in calls + assert ("notify", "mobile_app_wall_tablet", {"message": "command_screen_on"}) in calls + assert not any("hall" in str(c) for c in calls) + + def test_only_fully_kiosk_screens_and_companion_apps_are_ever_called(self): + coord, storage, mod, kid, chore, _other, _leo = _setup() + coord.hass.services.async_call = AsyncMock() + coord.hass.services.has_service = lambda domain, service: True + reg = self._registry([self._fkb("switch.kitchen_screensaver", "abc123-screensaver")]) + storage.set_setting("boost_wake_screens", ["switch.garage_door", "switch.kitchen_screensaver", "lock.front"]) + storage.set_setting("boost_wake_devices", ["persistent_notification", "mobile_app_x; rm"]) + with patch("custom_components.taskmate.coord_boosts.er.async_get", return_value=reg), _clock(mod): + run(coord.async_start_boost(multiplier=2, minutes=30)) + coord.hass.services.async_call.assert_not_called() + + def test_a_scheduled_boost_wakes_when_it_begins_not_when_it_is_made(self): + coord, storage, mod, kid, chore, _other, _leo = _setup() + coord.hass.services.async_call = AsyncMock() + coord.hass.services.has_service = lambda domain, service: True + storage.set_setting("boost_wake_devices", ["mobile_app_wall_tablet"]) + with _clock(mod): + run(coord.async_start_boost(multiplier=2, minutes=30, starts_at=_now() + dt.timedelta(hours=1))) + coord.hass.services.async_call.assert_not_called() + with _clock(mod, _now() + dt.timedelta(hours=1, seconds=5)): + run(coord.async_check_boosts()) + coord.hass.services.async_call.assert_awaited() + + def test_a_wake_failure_never_stops_the_boost(self): + coord, storage, mod, kid, chore, _other, _leo = _setup() + coord.hass.services.async_call = AsyncMock(side_effect=RuntimeError("tablet offline")) + coord.hass.services.has_service = lambda domain, service: True + storage.set_setting("boost_wake_devices", ["mobile_app_wall_tablet"]) + with _clock(mod): + b = run(coord.async_start_boost(multiplier=2, minutes=30)) + assert coord.boosts_state()[0]["id"] == b["id"] + + +def test_skipping_todays_recurring_boost_before_it_starts(): + coord, storage, mod, kid, chore, _other, _leo = _setup() + with _clock(mod, dt.datetime(2024, 3, 23, 7, 0, tzinfo=dt.UTC)): + run( + coord.async_save_boost_rule({"name": "Sat", "days": [5], "start": "09:00", "end": "11:00", "multiplier": 2}) + ) + (row,) = coord.boosts_state() + assert row["id"].startswith("rule:") + run(coord.async_end_boost(row["id"])) + assert coord.boosts_state() == [] + with _clock(mod, dt.datetime(2024, 3, 23, 9, 30, tzinfo=dt.UTC)): + run(coord.async_check_boosts()) + assert coord.boosts_state() == [] + assert run(coord.async_complete_chore(chore.id, kid.id)).points_awarded == 10 # weekend ×2 only + assert _fired(coord, "taskmate_boost_started") == [] + # Next Saturday it runs again. + with _clock(mod, dt.datetime(2024, 3, 30, 9, 30, tzinfo=dt.UTC)): + run(coord.async_check_boosts()) + assert [b["status"] for b in coord.boosts_state()] == ["active"] + + +def test_a_rule_saved_part_way_through_its_window_starts_for_the_rest_of_it(): + coord, storage, mod, kid, chore, _other, _leo = _setup() + with _clock(mod, dt.datetime(2024, 3, 23, 9, 40, tzinfo=dt.UTC)): + run( + coord.async_save_boost_rule({"name": "Sat", "days": [5], "start": "09:00", "end": "11:00", "multiplier": 2}) + ) + assert run(coord.async_check_boosts()) >= 1 + (b,) = coord.boosts_state() + assert not b["id"].startswith("rule:") and b["status"] == "active" + assert len(_fired(coord, "taskmate_boost_started")) == 1 diff --git a/wiki/Automations.md b/wiki/Automations.md index 450ab4f1..e990d7cd 100644 --- a/wiki/Automations.md +++ b/wiki/Automations.md @@ -256,6 +256,8 @@ All auction events carry `auction_id`, `chore_id`, `chore_name`, `occurrence` (t | `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_boost_started` | `id`, `name`, `multiplier`, `chore_ids`, `child_ids`, `starts_at`, `ends_at`, `minutes`, `rule_id` (recurring only), `timestamp` — (v7.1.0) a [boost](Boost-Windows.md) went live | +| `taskmate_boost_ended` | The same plus `ended_early` — (v7.1.0) a boost ran out or was ended | | `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 | diff --git a/wiki/Boost-Windows.md b/wiki/Boost-Windows.md new file mode 100644 index 00000000..3ff24112 --- /dev/null +++ b/wiki/Boost-Windows.md @@ -0,0 +1,149 @@ +# Boost Windows + +A **boost** makes chosen chores pay more for a set time: "the next hour, bins and the dishwasher pay double". Children see it coming. Their cards and the wall tablet fill with an orange **BOOST!** screen when it starts, then show a countdown and a ×2 chip on every boosted chore. + +> **New in v7.1.0** + +--- + +## Three Ways to Start One + +| Way | Where | Good for | +|-----|-------|----------| +| **On the spot** | **TaskMate panel → Today → Start a boost** | "Guests at six, the kitchen needs clearing" | +| **Recurring** | **TaskMate panel → Settings → Boost windows → Recurring boosts** | "Every Saturday, 09:00 to 11:00, garden jobs pay double" | +| **From an automation** | The `taskmate.start_boost` service | Anything Home Assistant can trigger: a calendar event, the doorbell, a button | + +Use whichever suits you. All three produce the same boost, and it looks and pays the same on the cards. + +### Start a boost + +![Start a boost](images/boost-dialog.png) + +Pick **Now** or **Later today**, how much it pays (×1.5, ×2 or ×3), how long it lasts, which chores (or all of them) and which children (or everyone). An optional name such as *Guests at six* shows on the cards. **Tell the children** sends the opt-in *Boost started* push to the children it covers. + +### Recurring boosts + +Each recurring boost has days, a start and end time, a multiplier, chores and children, and an on/off switch. TaskMate starts and ends it by itself. The Today page lists today's recurring boost before it starts, with **Skip this one** to leave it out for today only. + +### From an automation + +```yaml +action: taskmate.start_boost +data: + multiplier: 2 + minutes: 60 + chore_ids: [8a3b2c1d4e5f6789] # leave out for all chores + child_ids: [] # leave out for everyone + name: Guests at six +``` + +`starts_at` schedules one for later, up to a week ahead. `taskmate.end_boost` with a `boost_id` stops one early. Find the IDs in the `boosts` attribute of `sensor.taskmate_overview`. + +--- + +## What the Children See + +![Boost on the child card](images/boost-child-card.png) + +- **The moment it starts** the child card is covered by an orange **BOOST! ×2** screen: the chores, a live countdown and a **Let's go!** button. It shows once per device per boost. +- **Afterwards** a banner at the top counts down, and each boosted chore shows its old points crossed out, the boosted points and a **×2** chip. +- **Before a scheduled boost** a line reads *Next boost: 16:00 · Bins ×2*. + +This works in every [card design](Card-Design-Styles.md). Switch the full-screen alert off per card with `show_boost_alert: false`; the banner and chips stay. + +### On the kiosk tablet + +![Boost on the kiosk](images/boost-kiosk.png) + +The [kiosk card](Kiosk-Mode.md) leaves ambient mode or the photo slideshow and fills the screen with **BOOST TIME!** until someone taps. After that an orange strip under the top bar counts down. The kiosk card has its own `show_boost_alert` option. + +--- + +## Waking the Wall Tablet + +A sleeping screen can't wake itself: once the display is off nothing in the browser runs. Home Assistant has to tell the device, so TaskMate does that when a boost starts. + +**TaskMate panel → Settings → Boost windows** + +| Device | What TaskMate does | +|--------|--------------------| +| **Fully Kiosk Browser** (Android), through the [Fully Kiosk Browser integration](https://www.home-assistant.io/integrations/fully_kiosk/) | Turns the tablet's **Screen** switch on, turns its **Screensaver** off and presses **Bring to foreground** | +| **Home Assistant Companion app** (Android) | Sends the `command_screen_on` notification command | + +Tick the tablets to wake. **Test wake now** sends the wake straight away, using the saved choices. TaskMate only ever calls those two kinds of target, and a tablet that doesn't respond never stops the boost. + +iPads and iPhones can't be woken remotely. iOS doesn't allow it, so they show the boost the next time someone touches the screen. + +--- + +## How Boosted Points Are Paid + +- A chore **completed** inside the window earns the boosted points, even if a parent approves it after the window has closed. The approvals queue shows the boosted figure. +- When two boosts cover the same chore, the bigger one applies. +- Timed tasks and chores won at [auction](Chore-Auctions.md) are never boosted. An auction pays exactly the winning bid. +- A job [logged for an earlier day](Logging-Past-Jobs.md) is not boosted. + +### Weekends and birthdays + +Those days already pay a multiplier (see [Bonus Points](Bonus-Points.md) and [Birthday Mode](Birthday-Mode.md)). **On the weekend or a birthday** decides how a boost combines with it: + +| Setting | Key value | A ×2 boost on a ×2 Saturday pays | +|---------|-----------|----------------------------------| +| **Highest wins** (default) | `highest` | ×2. The bigger multiplier applies, the other is dropped | +| **Stack them** | `stack` | ×4 | + +--- + +## Settings + +| Setting | Key | Default | Description | +|---------|-----|---------|-------------| +| On the weekend or a birthday | `boost_stacking` | `highest` | `highest` or `stack` | +| Wake these tablets | `boost_wake_screens` | none | Fully Kiosk Browser **Screen** switch entities | +| Wake these phones and tablets | `boost_wake_devices` | none | Companion-app notify services (`mobile_app_…`) | + +--- + +## Notification + +| Type | Audience | Default | +|------|----------|---------| +| **Boost started** `boost_started` | The children the boost covers | Off | + +> ⚡ Boost! Take the bins out pay ×2 for the next 45 minutes. + +--- + +## Events + +| Event | Payload | +|-------|---------| +| `taskmate_boost_started` | `id`, `name`, `multiplier`, `chore_ids`, `child_ids`, `starts_at`, `ends_at`, `minutes`, `rule_id` (recurring only), `timestamp` | +| `taskmate_boost_ended` | The same, plus `ended_early` | + +Both show on the family [event entity](Event-Entities.md) as **Boost started** and **Boost ended**. The notification's own bus event is `taskmate_boost_started_notification`. + +--- + +## Services + +| Service | Fields | Description | +|---------|--------|-------------| +| `taskmate.start_boost` | `multiplier` (1.1–5), `minutes` (1–1440), `chore_ids`, `child_ids`, `starts_at`, `name`, `notify` | Open a boost now or schedule one. Parent action | +| `taskmate.end_boost` | `boost_id` | End one early, call off a scheduled one, or skip today's recurring one. Parent action | + +--- + +## Sensor + +`sensor.taskmate_overview` carries `boosts` while any boost is live or coming up today: each has `id`, `name`, `multiplier`, `chore_ids`, `chore_names`, `child_ids`, `starts_at`, `ends_at`, `minutes`, `status` (`active` / `upcoming`) and `rule_id` for a recurring one. + +--- + +## Related + +- [Bonus Points](Bonus-Points.md): weekend multiplier and streak bonuses +- [Kiosk Mode](Kiosk-Mode.md) +- [Bonuses](Bonuses.md) +- [Automations](Automations.md) diff --git a/wiki/Dashboard-Cards.md b/wiki/Dashboard-Cards.md index c19f5103..80cd2d85 100644 --- a/wiki/Dashboard-Cards.md +++ b/wiki/Dashboard-Cards.md @@ -128,6 +128,7 @@ show_minutes_left: true # (v7.0.0) "About 25 min left" from the chores' es show_help_button: true # (v7.0.0) the "I'm stuck" button on each outstanding chore show_mood: true # (v7.0.0) end-of-day mood check-in once the day's chores are done show_weekly_goal: true # (v7.0.0) the child's own weekly goal (needs the global setting) +show_boost_alert: true # (v7.1.0) the full-card BOOST! alert when a boost starts elapsed_time_mode: dim # dim | hide | show — time-of-day chores whose window has passed show_description: false # show chore description below name show_badges: true # show earned badge strip below points @@ -175,6 +176,7 @@ header_color: "#9b59b6" - **Minutes left (v7.0.0)** — chores with [estimated minutes](Estimated-Minutes.md) show a `~10 mins` tag, and the card adds up what's still to do into "About 25 min left" beside the countdown. Hide the total with `show_minutes_left: false` - **I'm stuck (v7.0.0)** — each outstanding chore has an "I'm stuck" button that asks a parent for help without completing or skipping anything; the chore then shows "Help is on its way". Hidden with `show_help_button: false`, and always hidden in pre-reader mode. See [Help Requests](Help-Requests.md) - **Mood check-in (v7.0.0)** — once the day's chores are done, the card asks "How was your day?" with five faces. One answer per day. Hide it with `show_mood: false`. See [Mood Check-In](Mood-Check-In.md) +- **Boosts (v7.1.0)** — a live [boost](Boost-Windows.md) shows a countdown banner and ×2 chips with the boosted points; when it starts the card is covered by a BOOST! alert once per device. Hide the alert with `show_boost_alert: false` - **Weekly goal (v7.0.0)** — while weekly goals are on in Settings, the child sets their own points target for the week and sees a progress bar towards it. Hide it with `show_weekly_goal: false`. See [Weekly Goals](Weekly-Goals.md) - **Sick day (v7.0.0)** — on a [sick day](Sick-Days.md) the card says "Rest up and get better" and shows no chores due; anything already ticked stays listed so it can still be undone - **Make it right (v7.0.0)** — after a penalty with a [make-it-right](Make-It-Right-Penalties.md) chore, the card says which chore to do and by when to earn points back, and that chore appears on the list even if it isn't normally due @@ -773,6 +775,7 @@ elapsed_time_mode: show # (v7.0.0) show | dim | hide — time-of-day c ambient_mode: "off" # (v7.0.0) off | progress | photos — what the picker turns into when nobody's using it ambient_after: 120 # (v7.0.0) seconds untouched before ambient mode starts (30–1800) show_raffle_draws: true # (v7.0.0) spin the names and land on the winner when a raffle is drawn +show_boost_alert: true # (v7.1.0) fill the screen when a boost starts children: # optional — order and PINs; omit to show every child - child_id: a8c8376a require_pin: true diff --git a/wiki/Event-Entities.md b/wiki/Event-Entities.md index f55f744c..b530e795 100644 --- a/wiki/Event-Entities.md +++ b/wiki/Event-Entities.md @@ -57,6 +57,8 @@ An event about more than one child lands on each of their entities: a [gift of p | `bounty_posted` | Bounty posted | | `scheduled_change_applied` | Scheduled change applied | | `raffle_drawn` | Raffle drawn | +| `boost_started` | Boost started | +| `boost_ended` | Boost ended | Events tied to a notification (help requested, weekly goal reached, penalty earned back, pet grew up, raffle drawn) reach the entities whether or not that notification is switched on. diff --git a/wiki/Kiosk-Mode.md b/wiki/Kiosk-Mode.md index 52610eae..9e1cca3b 100644 --- a/wiki/Kiosk-Mode.md +++ b/wiki/Kiosk-Mode.md @@ -66,6 +66,7 @@ The card works best in a **Panel** view, so it fills the screen. The full-screen | `ambient_mode` | string | `off` | *(v7.0.0)* What the picker turns into when nobody's using it: `off`, `progress` (the family's day) or `photos` (a slideshow of chore photos). See [Ambient mode](#ambient-mode) | | `ambient_after` | number | `120` | *(v7.0.0)* Seconds untouched before ambient mode starts (30–1800) | | `show_raffle_draws` | boolean | `true` | *(v7.0.0)* Spin the names and land on the winner when a [raffle](Raffles.md) is drawn. See [Raffle draws](#raffle-draws) | +| `show_boost_alert` | boolean | `true` | *(v7.1.0)* Leave ambient mode and fill the screen when a [boost](Boost-Windows.md) starts, until someone taps. The countdown strip shows either way | | `children` | list | all children | Which children appear on the picker, in order. Each entry is `child_id` plus an optional `require_pin` (default `true`) | Leave `children` out and every child is shown (guest profiles excepted), with a PIN asked for wherever one is set. The visual editor lists each child with a tick box to show or hide them, up/down arrows to set the order, and a **Require PIN** switch. That switch is greyed out ("No PIN set — goes straight in") until you give the child a PIN. diff --git a/wiki/Notifications.md b/wiki/Notifications.md index d7ab8bcd..1c0336ff 100644 --- a/wiki/Notifications.md +++ b/wiki/Notifications.md @@ -68,6 +68,7 @@ TaskMate ships with these built-in notification types: | **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 | +| **Boost started** | `boost_started` | Children | 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. diff --git a/wiki/Sensor-Reference.md b/wiki/Sensor-Reference.md index 8374354e..533d5a1a 100644 --- a/wiki/Sensor-Reference.md +++ b/wiki/Sensor-Reference.md @@ -46,6 +46,7 @@ The main overview sensor. Carries scalar stats and the compact per-child summary | `vacation_name` | string | Name of the active vacation period (empty when none) | | `vacation_end` | string | ISO end date of the active vacation period (empty when none) | | `vacation_periods` | list | All configured vacation periods — each has `id`, `name`, `start`, `end`. See [Vacation Mode](Vacation-Mode.md) | +| `boosts` | list | (v7.1.0) Live and upcoming [boosts](Boost-Windows.md): `id`, `name`, `multiplier`, `chore_ids`, `chore_names`, `child_ids`, `starts_at`, `ends_at`, `minutes`, `status` (`active` / `upcoming`), plus `rule_id` for a recurring one. Present only while there is one | | `family_goal` | object | (v4.4.0) Shared co-op [family goal](Family-Goals.md) state — `enabled` (bool), `name` (string), `target` (int), `reward` (string), `progress` (int — combined current points), `achieved` (bool). See [Family Goals](Family-Goals.md) | | `season_month` | string | (v4.4.0) Current leaderboard season month, `YYYY-MM` | | `season_champions` | list | (v4.4.0) Past monthly leaderboard champions (newest first, up to 12). See [Leaderboard Seasons](Leaderboard-Seasons.md) | diff --git a/wiki/Services.md b/wiki/Services.md index cbc9af6c..9f929c36 100644 --- a/wiki/Services.md +++ b/wiki/Services.md @@ -322,6 +322,26 @@ Approve a pending reward claim. For wallet rewards, deducts cost from the child' Refused for a claim that is not owed, or already marked delivered. +### `taskmate.start_boost` +(v7.1.0) Open a [boost window](Boost-Windows.md): chores done inside it pay the multiplier. Parent action. + +| Field | Required | Description | +| --- | --- | --- | +| `multiplier` | ✅ | 1.1 to 5, e.g. `2` for double | +| `minutes` | ✅ | 1 to 1440 | +| `chore_ids` | ❌ | The chores it covers. Empty or left out = all chores | +| `child_ids` | ❌ | The children it covers. Empty or left out = everyone | +| `starts_at` | ❌ | Start later instead of now, up to a week ahead | +| `name` | ❌ | Label shown on the cards, up to 60 characters | +| `notify` | ❌ | Send the *Boost started* push (default `true`; only when that notification is switched on) | + +### `taskmate.end_boost` +(v7.1.0) End a boost early, call off a scheduled one, or skip today's recurring one. Parent action. + +| Field | Required | Description | +| --- | --- | --- | +| `boost_id` | ✅ | From the `boosts` attribute of `sensor.taskmate_overview` | + ### `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/boost-child-card.png b/wiki/images/boost-child-card.png new file mode 100644 index 00000000..e9c42cc9 Binary files /dev/null and b/wiki/images/boost-child-card.png differ diff --git a/wiki/images/boost-dialog.png b/wiki/images/boost-dialog.png new file mode 100644 index 00000000..5da5aec3 Binary files /dev/null and b/wiki/images/boost-dialog.png differ diff --git a/wiki/images/boost-kiosk.png b/wiki/images/boost-kiosk.png new file mode 100644 index 00000000..5ca9bf0e Binary files /dev/null and b/wiki/images/boost-kiosk.png differ diff --git a/wiki/index.md b/wiki/index.md index 0fd869a2..acf4cc9f 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 | +| [Boost Windows](Boost-Windows.md) | Timed multipliers on chosen chores, started on the spot, on a schedule or from an automation | | [Personal Bests](Personal-Bests.md) | Each child's own records — longest streak, best day, best week, biggest award — celebrated when beaten | ## Notifications & Admin