diff --git a/README.md b/README.md index 39e9d2c7..342cdec8 100755 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ 3. **Parents approve** — chores marked "requires approval" land in a pending queue 4. **Kids claim rewards** — points are only deducted once a parent approves the claim -Everything is managed from the **TaskMate panel** in the Home Assistant sidebar, and surfaced on your dashboards through 27 Lovelace cards. +Everything is managed from the **TaskMate panel** in the Home Assistant sidebar, and surfaced on your dashboards through 28 Lovelace cards. All data stays inside your Home Assistant instance. Nothing is sent to any external service. @@ -89,7 +89,7 @@ Lovelace resources register automatically on startup. Edit your dashboard → ** | For kids | For parents | |----------|-------------| -| Child · Rewards · Reward Progress · Points Display · Routine · Kiosk · Streak · Badges · Bounty Board · Wishlist · Recap · Chore Auction | Approvals · Parent Dashboard · Overview · Chore Board · Activity · Points · Reorder · Penalties · Bonuses · Weekly · Graph · Leaderboard · Calendar · Family Goal · Photo Gallery | +| Child · Sticker Chart · Rewards · Reward Progress · Points Display · Routine · Kiosk · Streak · Badges · Bounty Board · Wishlist · Recap · Chore Auction | Approvals · Parent Dashboard · Overview · Chore Board · Activity · Points · Reorder · Penalties · Bonuses · Weekly · Graph · Leaderboard · Calendar · Family Goal · Photo Gallery | Every card takes a `header_color` and a `card_design` option. See [Dashboard Cards](https://github.com/tempus2016/taskmate/wiki/Dashboard-Cards) for each card's full configuration. @@ -108,7 +108,7 @@ Every card takes a `header_color` and a `card_design` option. See [Dashboard Car | [Getting Started](https://github.com/tempus2016/taskmate/wiki/Getting-Started) | First-time setup walkthrough | | [Admin Panel](https://github.com/tempus2016/taskmate/wiki/Admin-Panel) | The management hub in the HA sidebar | | [Settings](https://github.com/tempus2016/taskmate/wiki/Settings) | Every configurable setting in one place | -| [Dashboard Cards](https://github.com/tempus2016/taskmate/wiki/Dashboard-Cards) | All 27 Lovelace cards and their options | +| [Dashboard Cards](https://github.com/tempus2016/taskmate/wiki/Dashboard-Cards) | All 28 Lovelace cards and their options | | [Services](https://github.com/tempus2016/taskmate/wiki/Services) | Every callable service, with examples | | [Sensor Reference](https://github.com/tempus2016/taskmate/wiki/Sensor-Reference) | All entities and their attributes | | [Automations](https://github.com/tempus2016/taskmate/wiki/Automations) | Bus events, blueprints, and examples | @@ -134,4 +134,4 @@ Bug reports and feature requests are welcome in [Issues](https://github.com/temp MIT — see [LICENSE](LICENSE). -> Originally created by [vinnybad/choremander](https://github.com/vinnybad/choremander). This fork adds 27 Lovelace cards, an admin panel, a bonus points system, streak tracking, reward approval flow, a penalty system, and much more. +> Originally created by [vinnybad/choremander](https://github.com/vinnybad/choremander). This fork adds 28 Lovelace cards, an admin panel, a bonus points system, streak tracking, reward approval flow, a penalty system, and much more. diff --git a/custom_components/taskmate/__init__.py b/custom_components/taskmate/__init__.py index a501f8fd..9f6352b9 100644 --- a/custom_components/taskmate/__init__.py +++ b/custom_components/taskmate/__init__.py @@ -545,6 +545,16 @@ async def handle_complete_chore(call: ServiceCall) -> None: chore_id = call.data[ATTR_CHORE_ID] child_id = call.data[ATTR_CHILD_ID] as_parent = call.data.get(ATTR_AS_PARENT, False) + completed_date = call.data.get("completed_date") + if completed_date is not None: + # Logging a job for an earlier day is a grown-up's correction, never + # a child's: it is always parent-gated and always approved. + await _async_require_parent(hass, call) + try: + await coordinator.async_complete_chore_on_date(chore_id, child_id, completed_date) + except ValueError as err: + raise ServiceValidationError(str(err)) from err + return if as_parent: # Completing on behalf of a child (auto-approve + instant award) is a # parent privilege. Enforce it on the backend, not just by hiding the @@ -1302,6 +1312,7 @@ async def handle_rebuild_badges(call: ServiceCall) -> None: vol.Required(ATTR_CHILD_ID): cv.string, vol.Optional(ATTR_AS_PARENT, default=False): cv.boolean, vol.Optional("photo_url"): cv.string, + vol.Optional("completed_date"): cv.date, vol.Optional("note"): cv.string, vol.Optional("suggested_points"): vol.All( vol.Coerce(int), vol.Range(min=0, max=CHORE_SUGGESTED_POINTS_MAX) diff --git a/custom_components/taskmate/coord_chores.py b/custom_components/taskmate/coord_chores.py index 00390ac8..082f60d5 100644 --- a/custom_components/taskmate/coord_chores.py +++ b/custom_components/taskmate/coord_chores.py @@ -4,7 +4,7 @@ import logging from calendar import monthrange -from datetime import date, datetime, timedelta +from datetime import date, datetime, time, timedelta from typing import TYPE_CHECKING from homeassistant.util import dt as dt_util @@ -20,7 +20,7 @@ ) from .coord_rejections import clean_reject_reason from .coord_teamwork import teamwork_config_error -from .models import Chore, ChoreCompletion, PointsTransaction +from .models import Chore, ChoreCompletion, PointsTransaction, parse_datetime if TYPE_CHECKING: pass @@ -996,12 +996,18 @@ async def _async_record_completion( photo_url: str = "", note: str = "", suggested_points: int = 0, + backdated: bool = False, ) -> ChoreCompletion: """Store one completion and, when auto-approved, pay it. 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). + + ``backdated`` is a parent logging a job for an earlier day: ``now`` is + then that day's timestamp. It is paid for that day (weekend and birthday + bonuses follow the day) but leaves the streak alone, and only moves the + recurrence window forward, never back. """ completion = ChoreCompletion( chore_id=chore.id, @@ -1017,6 +1023,7 @@ async def _async_record_completion( # window (#918); one a parent made on their behalf may not, and # nothing made while the window is 0 (off) ever can. child_undo_allowed=not as_parent and undo_window_seconds(self.storage) > 0, + backdated=backdated, ) # Record the completion before awarding anything. Awarding can suspend @@ -1027,7 +1034,16 @@ async def _async_record_completion( self.storage.add_completion(completion) if auto_approve: - total_awarded = await self._award_points(child, points, chore_id=chore.id) + if backdated: + total_awarded = await self._award_points( + child, + points, + completion_date=dt_util.as_local(now).date(), + skip_streak=True, + chore_id=chore.id, + ) + else: + total_awarded = await self._award_points(child, points, chore_id=chore.id) completion.approved = True completion.approved_at = dt_util.now() completion.points_awarded = total_awarded @@ -1043,12 +1059,17 @@ async def _async_record_completion( "chore_name": chore.name, "points": points, "difficulty": getattr(chore, "difficulty", "medium"), - "timestamp": dt_util.now().isoformat(), + "timestamp": now.isoformat() if backdated else dt_util.now().isoformat(), + **({"backdated": True} if backdated else {}), }, ) - # Update last_completed store (window starts at completion time, midnight-rounded) - self.storage.set_last_completed(chore.id, child.id, now.isoformat()) + # Update last_completed store (window starts at completion time, midnight-rounded). + # A job logged for an earlier day must not pull the window back behind a + # completion the child made since, so it only records when it is the latest. + current = parse_datetime((self.storage.get_last_completed(chore.id, child.id) or {}).get("current")) + if not backdated or current is None or now > current: + self.storage.set_last_completed(chore.id, child.id, now.isoformat()) # One-shot: if auto-approved, disable for this child immediately if getattr(chore, "schedule_mode", "specific_days") == "one_shot" and auto_approve: @@ -1097,6 +1118,175 @@ async def _async_after_completions(self, chore, recorded: list, *, auto_approve: if hasattr(self, "_async_evaluate_challenges"): await self._async_evaluate_challenges(child.id) + # How far back a grown-up may log a job that was done but not ticked. + BACKDATE_MAX_DAYS = 7 + + async def async_complete_chore_on_date(self, chore_id: str, child_id: str, day: date) -> ChoreCompletion: + """Log a job a child did on an earlier day but nobody ticked at the time. + + Parent-only (the service gates it). The completion is stamped midday + on ``day`` so it lands in that day's (and that week's) totals, and it + is approved straight away — the grown-up logging it is the approver. It + is recorded and followed up by the same code a parent ticking a job + for a child uses, so badges, quest steps, challenges, the recurrence + window and one-shot disabling all behave as they do for any approval. + + It must be a day in the last ``BACKDATE_MAX_DAYS`` days, not today + (today is ticked the normal way), for a chore that was assigned to the + child and due that day, with that day's daily limit — and, for a + weekly-target chore, that week's quota — not yet used up. + + Refused, because they can't be reconstructed or priced after the fact: + rotation and teamwork chores (whose turn it was), a day won at auction, + and open-ended chores (they need the child's note and the parent's price). + + Points are the chore's base points (with its difficulty) plus the + weekend multiplier when ``day`` was a Saturday or Sunday; the time-of-day, + speed and roulette adjustments are left out, as there was no live tap. + The streak is left alone: rewriting ``last_completion_date`` to a past + day would break a streak the child is on today. + + Every refusal raises ValueError with a message meant for the parent. + """ + chore = self.get_chore(chore_id) + if not chore: + raise ValueError(f"Chore {chore_id} not found") + child = self.get_child(child_id) + if not child: + raise ValueError(f"Child {child_id} not found") + + today = dt_util.as_local(dt_util.now()).date() + if not (today - timedelta(days=self.BACKDATE_MAX_DAYS) <= day < today): + raise ValueError(f"Pick a day in the last {self.BACKDATE_MAX_DAYS} days, before today.") + + refusal = self._backdate_refusal(chore, child, day) + if refusal: + raise ValueError(refusal) + + points = self.effective_chore_points(chore) + completed_at = datetime.combine(day, time(12, 0), tzinfo=dt_util.DEFAULT_TIME_ZONE) + completion = await self._async_record_completion( + chore, + child, + completed_at, + auto_approve=True, + points=points, + as_parent=True, + backdated=True, + ) + await self._async_after_completions(chore, [(child, completion, points)], auto_approve=True) + return completion + + def completions_on_date(self, day: date) -> list[dict]: + """Every chore completion made on ``day`` (local time), sub-tasks excluded. + + Shaped like the sensors' ``todays_completions`` rows, so the sticker + chart can show a past day with the same code it uses for today. + """ + return [ + { + "completion_id": comp.id, + "chore_id": comp.chore_id, + "child_id": comp.child_id, + "approved": comp.approved, + "points": comp.points_awarded, + } + for comp in self.storage.get_completions() + if not comp.bonus_subtask_id and dt_util.as_local(comp.completed_at).date() == day + ] + + def _backdate_refusal(self, chore, child, day: date, *, quota: bool = True) -> str | None: + """Why ``chore`` can't be logged for ``child`` on ``day``, or None if it can. + + The one place the past-day rules live: logging a job refuses with this + message, and the panel's day lookup offers exactly the chores it lets + through, so the dialog never offers a tap TaskMate would then refuse. + + ``quota=False`` leaves out what that day and week have already used + (the daily limit and a weekly target's quota). The sticker chart shows + those itself, as done tiles that can still be undone. + """ + child_id = child.id + if not getattr(chore, "enabled", True): + return f"'{chore.name}' is switched off." + assigned = chore.assigned_to or [] + if (assigned and child_id not in assigned) or child_id in (getattr(chore, "disabled_for", None) or []): + return f"'{chore.name}' isn't one of {child.name}'s jobs." + if (getattr(chore, "assignment_mode", "everyone") or "everyone") != "everyone" or self.teamwork_size(chore): + return f"'{chore.name}' rotates or needs a team, so it can't be logged for a past day." + if getattr(chore, "open_ended", False): + return f"'{chore.name}' is open-ended, so it needs the child's note and a price. Use the child's card." + if self.is_vacation_day(day): + return f"{day.strftime('%A')} was a holiday, so no jobs were due." + # A birthday with "chores off" is a day off for everything but mandatory + # chores, the same rule today's chore list follows. + if not getattr(chore, "mandatory", False) and self.is_birthday_day_off(child, day): + return f"{day.strftime('%A')} was {child.name}'s birthday, a day off from jobs." + if self.auction_winner(chore, day): + return f"'{chore.name}' was won at auction that day, so it can't be logged afterwards." + + # A weekly-target chore (#883) has no fixed days — the child picks them — + # so it isn't "due" on any one day; its quota is the gate instead. + weekly_target = int(getattr(chore, "weekly_target", 0) or 0) + if weekly_target: + if quota and self.weekly_target_met(chore, child_id, on=day): + return f"'{chore.name}' already reached its {weekly_target} for that week." + elif not self._is_chore_scheduled_for_date(chore, day): + return f"'{chore.name}' wasn't due on {day.strftime('%A')}." + + if not quota: + return None + redo_ids = self.inspection_redo_completion_ids() + done_that_day = sum( + 1 + for comp in self.storage.get_completions() + if comp.id not in redo_ids + and comp.chore_id == chore.id + and comp.child_id == child_id + and not comp.bonus_subtask_id + and dt_util.as_local(comp.completed_at).date() == day + ) + daily_limit = max(1, int(getattr(chore, "daily_limit", 1) or 1)) + if done_that_day >= daily_limit: + return f"'{chore.name}' was already done {done_that_day}/{daily_limit} times on {day.strftime('%A')}." + return None + + def loggable_on(self, day: date) -> dict[str, list[str]]: + """The chores each child could have had logged for ``day``. + + Everything ``_backdate_refusal`` allows apart from the day's and week's + quotas, which the sticker chart shows itself. The panel uses it so its + list and the past-day chart follow every schedule TaskMate knows — + recurrences, calendar moves, holidays — not just fixed weekdays. + """ + loggable: dict[str, list[str]] = {} + for child in self.storage.get_children(): + ids = [ + chore.id + for chore in self.storage.get_chores() + if self._backdate_refusal(chore, child, day, quota=False) is None + ] + if ids: + loggable[child.id] = ids + return loggable + + def week_progress_on(self, day: date) -> dict[str, dict[str, int]]: + """Each child's count for every weekly-target chore, in the week of ``day``. + + The past-day sticker chart caps a weekly-target chore at what is left + of that week's quota, the way today's chart reads ``weekly_chore_progress``, + so it never offers a tick the backend would refuse. + """ + progress: dict[str, dict[str, int]] = {} + for chore in self.storage.get_chores(): + if int(getattr(chore, "weekly_target", 0) or 0) <= 0: + continue + for child in self.storage.get_children(): + count = self.weekly_completion_count(chore.id, child.id, on=day) + if count: + progress.setdefault(child.id, {})[chore.id] = count + return progress + async def async_parent_complete_chore(self, chore_id: str) -> ChoreCompletion: """Mark a chore as completed by the parent — zero points, advances recurrence.""" chore = self.get_chore(chore_id) @@ -1455,8 +1645,9 @@ def _reverse_completion_awards(self, target_completion, completions) -> list: # Only reverse streak for parent completions, and only # when this was the child's sole completion that day — - # streaks are day-based, not per-completion - if not completion.bonus_subtask_id: + # streaks are day-based, not per-completion. A backdated one + # never advanced the streak, so it has nothing to give back. + if not completion.bonus_subtask_id and not completion.backdated: reject_date = dt_util.as_local(completion.completed_at).date() other_same_day = any( c.id != completion.id @@ -1508,8 +1699,10 @@ def _reverse_completion_awards(self, target_completion, completions) -> list: ) ) child.streak_milestones_achieved = sorted(d for d in achieved if d <= child.current_streak) - # The weekly target bonus this completion helped earn (#1044). - self._reverse_weekly_target_bonus(completion, child) + # The weekly target bonus this completion helped earn (#1044). + # Outside the streak block: a backdated job never moved the + # streak, but it can complete a week's target like any other. + self._reverse_weekly_target_bonus(completion, child) bonus_completions: list = [] is_parent = not target_completion.bonus_subtask_id @@ -1532,8 +1725,17 @@ def _reverse_completion_awards(self, target_completion, completions) -> list: child.total_points_earned = max(0, child.total_points_earned - bc.points_awarded) child.total_chores_completed = max(0, child.total_chores_completed - 1) - # Undo last_completed store so recurrence window resets correctly - self.storage.undo_last_completed(target_completion.chore_id, target_completion.child_id) + # Undo last_completed store so recurrence window resets correctly. + # A backdated completion only owns the current entry if it was the + # latest when logged; otherwise a later completion does, and popping + # it would reopen that chore's window. + lc_current = parse_datetime( + (self.storage.get_last_completed(target_completion.chore_id, target_completion.child_id) or {}).get( + "current" + ) + ) + if not target_completion.backdated or lc_current == target_completion.completed_at: + self.storage.undo_last_completed(target_completion.chore_id, target_completion.child_id) # One-shot: re-enable for this child chore = self.get_chore(target_completion.chore_id) @@ -1900,33 +2102,40 @@ def is_chore_available_for_child(self, chore, child_id: str) -> bool: days_since = (today - last_dt).days return days_since >= window_days - def weekly_completion_count(self, chore_id: str, child_id: str) -> int: + def weekly_completion_count(self, chore_id: str, child_id: str, on: date | None = None) -> int: """How many times ``child_id`` has completed ``chore_id`` this week. Monday-anchored, matching the Challenges period convention. Bonus sub-tasks don't count; pending completions do, so a chore can't be submitted a fourth time just because the parent hasn't got round to approving the third yet. + + ``on`` asks about the week containing that day instead of this one, for + a job being logged against a past week. """ - today = dt_util.as_local(dt_util.now()).date() - week_start = today - timedelta(days=today.weekday()) + anchor = on or dt_util.as_local(dt_util.now()).date() + week_start = anchor - timedelta(days=anchor.weekday()) + week_end = week_start + timedelta(days=6) count = 0 for comp in self._cached_completions_for_chore(chore_id): if comp.child_id != child_id or getattr(comp, "bonus_subtask_id", ""): continue try: - if dt_util.as_local(comp.completed_at).date() >= week_start: + if week_start <= dt_util.as_local(comp.completed_at).date() <= week_end: count += 1 except (AttributeError, TypeError, ValueError): continue return count - def weekly_target_met(self, chore, child_id: str) -> bool: - """True when a weekly-target chore has had its quota filled this week.""" + def weekly_target_met(self, chore, child_id: str, on: date | None = None) -> bool: + """True when a weekly-target chore has had its quota filled this week. + + ``on`` checks the week containing that day (see ``weekly_completion_count``). + """ target = int(getattr(chore, "weekly_target", 0) or 0) if target <= 0: return False - return self.weekly_completion_count(chore.id, child_id) >= target + return self.weekly_completion_count(chore.id, child_id, on) >= target @staticmethod def _week_key(when) -> str: diff --git a/custom_components/taskmate/coord_points.py b/custom_components/taskmate/coord_points.py index 626db246..474325c1 100644 --- a/custom_components/taskmate/coord_points.py +++ b/custom_components/taskmate/coord_points.py @@ -20,6 +20,84 @@ class PointsMixin: """Mixin providing points, streaks, penalties, bonuses, and notifications.""" + def week_progress(self, children, chores, completions) -> dict[str, dict[str, int]]: + """Points each child has earned this week, and the most they could have. + + The week runs Monday to Sunday, like the weekly digest. ``earned`` is + the approved chore points since Monday (bonus sub-tasks excluded, as + they are extras on top of the jobs). ``available`` is every scheduled + job's points at its daily limit across all seven days, with the weekend + multiplier on Saturday and Sunday and the family's planned vacation days + left out (a child's live away-sensor is not used: it says nothing about + the rest of the week). Rotation + chores are skipped — who is active on a future day can't be known — so + ``earned`` may run past ``available``; cards cap the display. + + A weekly-target chore (#883) is picked up on whichever days the child + chooses, so it adds its quota (target × points) once rather than a slot + per scheduled day. An open-ended chore has no price of its own, so it + adds nothing to ``available`` (what was paid for it still counts in + ``earned``). A teamwork chore counts each member's share, as paid. + """ + today = dt_util.now().date() + week_start = today - timedelta(days=today.weekday()) + days = [week_start + timedelta(days=i) for i in range(7)] + try: + multiplier = float(self.storage.get_setting("weekend_multiplier", "2.0")) + except (ValueError, TypeError): + multiplier = 2.0 + + earned: dict[str, int] = {} + for comp in completions: + if not comp.approved or comp.bonus_subtask_id: + continue + if dt_util.as_local(comp.completed_at).date() < week_start: + continue + earned[comp.child_id] = earned.get(comp.child_id, 0) + (comp.points_awarded or 0) + + celebration_multiplier = self.birthday_multiplier() + + progress: dict[str, dict[str, int]] = {} + for child in children: + available = 0 + for chore in chores: + if not getattr(chore, "enabled", True): + continue + assigned = chore.assigned_to or [] + if assigned and child.id not in assigned: + continue + if child.id in (getattr(chore, "disabled_for", None) or []): + continue + mode = getattr(chore, "assignment_mode", "everyone") or "everyone" + if mode not in ("everyone", "first_come"): + continue + if getattr(chore, "open_ended", False): + continue + limit = 1 if mode == "first_come" else max(1, int(getattr(chore, "daily_limit", 1) or 1)) + # A teamwork chore (#928) pays each member their share (plus any + # team bonus), so that is what it can add to a child's week. + points = self.team_share_points(chore) + weekly_target = int(getattr(chore, "weekly_target", 0) or 0) + if weekly_target: + available += weekly_target * points + continue + for day in days: + if self.is_vacation_day(day) or not self._is_chore_scheduled_for_date(chore, day): + continue + # A birthday off (#924) only excuses non-mandatory chores. + birthday = self.is_birthday(child, day) + if birthday and not getattr(chore, "mandatory", False) and self.birthday_chores_off(): + continue + per = points + if day.weekday() in (5, 6) and multiplier > 1.0: + per += round(points * (multiplier - 1.0)) + # The birthday multiplier stacks on the weekend one, as paid. + if birthday and celebration_multiplier > 1.0: + per += round(per * (celebration_multiplier - 1.0)) + available += per * limit + progress[child.id] = {"earned": earned.get(child.id, 0), "available": available} + return progress + async def _async_check_perfect_week(self) -> None: """Award perfect week bonus to children who completed at least one chore every day last week.""" perfect_week_enabled = self.storage.get_setting("perfect_week_enabled", "true") == "true" diff --git a/custom_components/taskmate/frontend.py b/custom_components/taskmate/frontend.py index 12dc0f02..69402f8a 100644 --- a/custom_components/taskmate/frontend.py +++ b/custom_components/taskmate/frontend.py @@ -49,6 +49,7 @@ "taskmate-calendar-card.js", "taskmate-photo-gallery-card.js", "taskmate-family-goal-card.js", + "taskmate-sticker-chart-card.js", "taskmate-wishlist-card.js", "taskmate-kiosk-card.js", "taskmate-bounty-card.js", diff --git a/custom_components/taskmate/models.py b/custom_components/taskmate/models.py index 01d01f43..13ae9d26 100644 --- a/custom_components/taskmate/models.py +++ b/custom_components/taskmate/models.py @@ -1120,6 +1120,11 @@ class ChoreCompletion: # Set when this completion is a bounty's (#931). chore_id then carries the # bounty id too, so nothing that groups completions by chore mixes the two. bounty_id: str = "" + # True on a completion a parent logged afterwards for an earlier day. It was + # paid without touching the child's streak (rewriting last_completion_date + # to a past day would break a streak they are on today), so undoing it must + # not take a day off the streak either. + backdated: bool = False @classmethod def from_dict(cls, data: dict[str, Any]) -> ChoreCompletion: @@ -1144,6 +1149,7 @@ def from_dict(cls, data: dict[str, Any]) -> ChoreCompletion: quality_rating=parse_quality_rating(data.get("quality_rating")), child_undo_allowed=data.get("child_undo_allowed") is True, bounty_id=str(data.get("bounty_id", "") or ""), + backdated=data.get("backdated") is True, ) def to_dict(self) -> dict[str, Any]: @@ -1170,6 +1176,8 @@ def to_dict(self) -> dict[str, Any]: data["child_undo_allowed"] = True if self.bounty_id: data["bounty_id"] = self.bounty_id + if self.backdated: + data["backdated"] = True return data diff --git a/custom_components/taskmate/sensor.py b/custom_components/taskmate/sensor.py index d40ae026..9b243e54 100644 --- a/custom_components/taskmate/sensor.py +++ b/custom_components/taskmate/sensor.py @@ -148,6 +148,8 @@ def _compute_common(coordinator: TaskMateCoordinator) -> dict: # Each child's photo (from their chosen picture entity), resolved once # per update: every call site that draws a child's face reads this. "child_avatar_images": {c.id: coordinator.child_avatar_image(c) for c in children}, + # This week's points earned vs. the most on offer, per child. + "child_week_progress": coordinator.week_progress(children, chores, all_completions), # Sent back to redo by an inspection (#981): not "done" on the card. "inspection_redo_ids": _inspection_redo_ids(coordinator), } @@ -225,6 +227,8 @@ def _build_children_summary(coordinator: TaskMateCoordinator, common: dict) -> l "avatar": getattr(c, "avatar", "mdi:account-circle") or "mdi:account-circle", # The child's photo, or "" — cards fall back to `avatar`. "avatar_image": common.get("child_avatar_images", {}).get(c.id, ""), + "week_points_earned": common.get("child_week_progress", {}).get(c.id, {}).get("earned", 0), + "week_points_available": common.get("child_week_progress", {}).get(c.id, {}).get("available", 0), "last_completion_date": getattr(c, "last_completion_date", None), "streak_paused": getattr(c, "streak_paused", False), "on_vacation": coordinator._is_child_on_vacation(c), diff --git a/custom_components/taskmate/services.yaml b/custom_components/taskmate/services.yaml index 74a4a529..578f9a50 100644 --- a/custom_components/taskmate/services.yaml +++ b/custom_components/taskmate/services.yaml @@ -27,6 +27,12 @@ complete_chore: required: false selector: text: + completed_date: + name: Done on + description: Log the job for an earlier day (up to 7 days back, not today) that was done but not ticked. Parents only; approved straight away. The day's daily limit still applies. + required: false + selector: + date: note: name: Note description: What the child did, in their own words. Required when the chore is open-ended, and shown to the parent during approval. diff --git a/custom_components/taskmate/websocket.py b/custom_components/taskmate/websocket.py index 897aa296..63c10fcc 100644 --- a/custom_components/taskmate/websocket.py +++ b/custom_components/taskmate/websocket.py @@ -173,6 +173,7 @@ WS_SET_GLOBAL_CHORE_ORDER: Final = "taskmate/set_global_chore_order" WS_ADD_CHORES_BULK: Final = "taskmate/add_chores_bulk" WS_PARENT_COMPLETE_CHORE: Final = "taskmate/parent_complete_chore" +WS_DAY_COMPLETIONS: Final = "taskmate/day_completions" # Templates WS_TEMPLATES_LIST: Final = "taskmate/templates/list" @@ -2415,6 +2416,26 @@ async def _ws_parent_complete_chore(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"ok": True}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_DAY_COMPLETIONS, + vol.Required("date"): vol.All(str, vol.Coerce(date.fromisoformat)), + } +) +@websocket_api.async_response +@_admin_only +async def _ws_day_completions(hass, connection, msg, coordinator): + """What was done on one day — for logging past jobs from the panel.""" + connection.send_result( + msg["id"], + { + "completions": coordinator.completions_on_date(msg["date"]), + "week_progress": coordinator.week_progress_on(msg["date"]), + "loggable": coordinator.loggable_on(msg["date"]), + }, + ) + + @websocket_api.websocket_command( { vol.Required("type"): WS_SET_CHORE_ORDER, @@ -3188,6 +3209,7 @@ async def _ws_config_import(hass, connection, msg, coordinator): _ws_approve_reward, _ws_reject_reward, _ws_parent_complete_chore, + _ws_day_completions, _ws_set_chore_order, _ws_set_global_chore_order, _ws_add_chores_bulk, diff --git a/custom_components/taskmate/www/locales/da.json b/custom_components/taskmate/www/locales/da.json index dd164c76..760d0b74 100644 --- a/custom_components/taskmate/www/locales/da.json +++ b/custom_components/taskmate/www/locales/da.json @@ -3057,5 +3057,66 @@ "notify.sample_pledger": "Mormor", "notify.sample_bounty": "Vask bilen", "notify.sample_reason": "Ikke færdig", - "notify.sample_note": "Flot klaret!" + "notify.sample_note": "Flot klaret!", + "sticker_chart.error.entity_required": "Angiv en entitet", + "sticker_chart.default_title": "{name}s klistermærkeskema", + "sticker_chart.today": "I dag: {done} / {total} opgaver klaret", + "sticker_chart.all_done": "Alt klaret, {name}!", + "sticker_chart.day_done_message": "Alle opgaver er klaret i dag!", + "sticker_chart.no_stickers": "Ingen klistermærker endnu - lav en opgave for at få dit første!", + "sticker_chart.stickers_alt": "{count} {points} optjent", + "sticker_chart.goal": "{points} / {goal} {unit} til belønningen", + "sticker_chart.goal_reached": "MÅLET ER NÅET! Tid til din belønning!", + "sticker_chart.week_goal": "{points} / {goal} {unit} denne uge", + "sticker_chart.week_perfect": "PERFEKT UGE! Alle stjerner er optjent!", + "sticker_chart.block_key": "Hver blok = {count} {unit}", + "sticker_chart.day_count": "{day}: {done} / {total} opgaver klaret", + "sticker_chart.no_jobs_day": "Ingen opgaver skulle laves {day}.", + "sticker_chart.editor.goal": "Hvad klistermærkerne tæller mod", + "sticker_chart.editor.goal_reward": "Belønningen", + "sticker_chart.editor.goal_week": "Ugens opgaver", + "sticker_chart.editor.goal_helper": "Ugens opgaver: blokkene fyldes med de stjerner, der er optjent siden mandag, ud af alle de stjerner, ugens opgaver kan give. Belønningen og knappen til at kræve den forbliver uændrede.", + "sticker_chart.no_reward": "Ingen belønning at spare op til endnu - vælg en i kortets indstillinger.", + "sticker_chart.no_child": "Ingen børn fundet på denne entitet", + "sticker_chart.editor.title": "Titel", + "sticker_chart.editor.reward": "Belønning at spare op til", + "sticker_chart.editor.reward_helper": "Belønningens pris bliver målet. Lad stå på automatisk for at sigte mod den billigste, de endnu ikke har optjent.", + "sticker_chart.editor.reward_auto": "Automatisk (den næste, der kan optjenes)", + "sticker_chart.editor.show_today": "Vis dagens opgaver", + "sticker_chart.editor.show_today_helper": "Vis linjen \"I dag: x / y opgaver klaret\" over klistermærkerne.", + "sticker_chart.claim": "Kræv {reward}", + "sticker_chart.claim_confirm": "Tryk igen for at kræve {reward}", + "sticker_chart.claim_pending": "Venter på, at en voksen siger ja", + "sticker_chart.claim_short": "{count} mere {unit} igen", + "sticker_chart.claimed": "{reward} krævet for {name}!", + "sticker_chart.complete_failed": "Det virkede ikke: {error}", + "sticker_chart.editor.allow_undo": "Hold et klistermærke nede for at fortryde", + "sticker_chart.editor.allow_undo_helper": "Tryk og hold på et klistermærke for at tage det tilbage. TaskMate lader kun en forælder gøre dette.", + "sticker_chart.editor.child": "Barn", + "sticker_chart.editor.child_all": "Alle børn, side om side", + "sticker_chart.editor.columns": "Klistermærker pr. række", + "sticker_chart.editor.show_claim": "Vis knappen til at kræve belønningen", + "sticker_chart.editor.show_claim_helper": "En knap under målet, der kræver belønningen, når der er optjent nok (tryk to gange for at bekræfte).", + "sticker_chart.editor.sound": "Afspil opgavens lyd", + "sticker_chart.editor.title_helper": "Lad stå tomt for at bruge hvert barns navn.", + "sticker_chart.goal_tile": "Mål", + "sticker_chart.needs_photo": "Denne opgave kræver et foto - åbn den på TaskMates børnekort.", + "sticker_chart.no_jobs_today": "Ingen opgaver i dag - nyd din dag!", + "sticker_chart.state.done": "klaret", + "sticker_chart.state.partial": "påbegyndt", + "sticker_chart.state.pending": "venter på en voksen", + "sticker_chart.state.todo": "skal gøres", + "sticker_chart.not_allowed": "Denne tablet må ikke sætte flueben ved {name}s opgaver. En voksen kan rette det i TaskMate: knyt {name} til den konto, tabletten bruger, eller tilføj kontoen som forælder.", + "sticker_chart.undo_denied": "Kun en voksen kan fortryde et klistermærke.", + "sticker_chart.undone": "Klistermærket er taget tilbage.", + "panel.backdate_button": "Registrer en tidligere opgave", + "panel.backdate_title": "Registrer en tidligere opgave for {name}", + "panel.backdate_hint": "Vælg dagen, og tryk derefter på de opgaver, der blev lavet, ligesom på klistermærkeskemaet. De tæller for den dag og uge og godkendes med det samme. Hold et klistermærke nede for at tage det tilbage.", + "panel.backdate_hint_list": "Til en opgave, der blev lavet en tidligere dag, men ikke blev afkrydset. Den tæller for den dag og uge og godkendes med det samme.", + "panel.backdate_finish": "Færdig", + "panel.backdate_day": "Dagen den blev lavet", + "panel.backdate_chore": "Opgave", + "panel.backdate_none": "Ingen opgaver skulle laves for {name} den dag.", + "panel.backdate_save": "Registrer", + "panel.backdate_done": "{chore} registreret for {name} d. {day}" } diff --git a/custom_components/taskmate/www/locales/de.json b/custom_components/taskmate/www/locales/de.json index 5549e071..067dd29b 100644 --- a/custom_components/taskmate/www/locales/de.json +++ b/custom_components/taskmate/www/locales/de.json @@ -1886,6 +1886,57 @@ "streak.editor.days_shown_helper": "Wie viele Tage der Punkte werden angezeigt (Standard: 14)", "streak.editor.title": "Titel", "streak.error.entity_required": "Bitte definieren Sie eine Entität", + "sticker_chart.error.entity_required": "Bitte definieren Sie eine Entität", + "sticker_chart.default_title": "{name}s Stickertafel", + "sticker_chart.today": "Heute: {done} / {total} Aufgaben erledigt", + "sticker_chart.all_done": "Alles geschafft, {name}!", + "sticker_chart.day_done_message": "Heute ist jede Aufgabe erledigt!", + "sticker_chart.no_stickers": "Noch keine Sticker - erledige eine Aufgabe für den ersten!", + "sticker_chart.stickers_alt": "{count} {points} gesammelt", + "sticker_chart.goal": "{points} / {goal} {unit} bis zur Belohnung", + "sticker_chart.goal_reached": "ZIEL ERREICHT! Zeit für die Belohnung!", + "sticker_chart.week_goal": "{points} / {goal} {unit} diese Woche", + "sticker_chart.week_perfect": "PERFEKTE WOCHE! Jeden Stern verdient!", + "sticker_chart.block_key": "Jedes Kästchen = {count} {unit}", + "sticker_chart.day_count": "{day}: {done} / {total} Aufgaben erledigt", + "sticker_chart.no_jobs_day": "Am {day} war keine Aufgabe fällig.", + "sticker_chart.editor.goal": "Worauf die Sticker zählen", + "sticker_chart.editor.goal_reward": "Die Belohnung", + "sticker_chart.editor.goal_week": "Die Aufgaben dieser Woche", + "sticker_chart.editor.goal_helper": "Aufgaben dieser Woche: Die Kästchen füllen sich mit den seit Montag verdienten Sternen, von allen Sternen, die die Aufgaben der Woche bringen können. Belohnung und Einlöse-Knopf bleiben, wie sie sind.", + "sticker_chart.no_reward": "Noch keine Belohnung zum Sparen - wähle eine in den Karteneinstellungen.", + "sticker_chart.no_child": "Keine Kinder für diese Entität gefunden", + "sticker_chart.editor.title": "Titel", + "sticker_chart.editor.reward": "Belohnung zum Sparen", + "sticker_chart.editor.reward_helper": "Die Kosten der Belohnung werden zum Ziel. Auf automatisch belassen, um die günstigste noch nicht verdiente anzupeilen.", + "sticker_chart.editor.reward_auto": "Automatisch (die nächste)", + "sticker_chart.editor.show_today": "Heutige Aufgaben anzeigen", + "sticker_chart.editor.show_today_helper": "Zeigt die Zeile \"Heute: x / y Aufgaben erledigt\" über den Stickern.", + "sticker_chart.claim": "{reward} einlösen", + "sticker_chart.claim_confirm": "Nochmal tippen, um {reward} einzulösen", + "sticker_chart.claim_pending": "Warte auf das OK von einem Erwachsenen", + "sticker_chart.claim_short": "Noch {count} {unit}", + "sticker_chart.claimed": "{reward} für {name} eingelöst!", + "sticker_chart.complete_failed": "Das hat nicht geklappt: {error}", + "sticker_chart.editor.allow_undo": "Sticker gedrückt halten zum Rückgängigmachen", + "sticker_chart.editor.allow_undo_helper": "Einen Sticker gedrückt halten, um ihn zurückzunehmen. TaskMate erlaubt das nur Eltern.", + "sticker_chart.editor.child": "Kind", + "sticker_chart.editor.child_all": "Alle Kinder nebeneinander", + "sticker_chart.editor.columns": "Sticker pro Reihe", + "sticker_chart.editor.show_claim": "Einlöse-Knopf anzeigen", + "sticker_chart.editor.show_claim_helper": "Ein Knopf unter dem Ziel, der die Belohnung einlöst, sobald genug gesammelt ist (zum Bestätigen zweimal tippen).", + "sticker_chart.editor.sound": "Ton der Aufgabe abspielen", + "sticker_chart.editor.title_helper": "Leer lassen, um den Namen jedes Kindes zu verwenden.", + "sticker_chart.goal_tile": "Ziel", + "sticker_chart.needs_photo": "Diese Aufgabe braucht ein Foto - öffne sie auf der TaskMate-Kinderkarte.", + "sticker_chart.no_jobs_today": "Heute keine Aufgaben - genieß den Tag!", + "sticker_chart.state.done": "erledigt", + "sticker_chart.state.partial": "angefangen", + "sticker_chart.state.pending": "wartet auf einen Erwachsenen", + "sticker_chart.state.todo": "zu erledigen", + "sticker_chart.not_allowed": "Dieses Tablet darf {name}s Aufgaben nicht abhaken. Ein Erwachsener kann das in TaskMate ändern: {name} mit dem Konto dieses Tablets verknüpfen oder dieses Konto als Elternteil eintragen.", + "sticker_chart.undo_denied": "Nur ein Erwachsener kann einen Sticker zurücknehmen.", + "sticker_chart.undone": "Sticker zurückgenommen.", "visibility.description_hidden": "Durch Sichtbarkeitsbedingung ausgeblendet", "visibility.description_shown": "Wird nach Sichtbarkeitsbedingung angezeigt", "visibility.label_entity": "Sichtbarkeitseinheit", @@ -2087,6 +2138,16 @@ "panel.adjust_custom_title": "Punkte von {child} um einen eigenen Betrag anpassen", "panel.adjust_dialog_title": "Punkte anpassen — {name}", "panel.adjust_done": "{sign}{amount} {points} für {child}", + "panel.backdate_button": "Erledigte Aufgabe nachtragen", + "panel.backdate_title": "Aufgabe für {name} nachtragen", + "panel.backdate_hint": "Wähle den Tag und tippe dann die erledigten Aufgaben an, genau wie auf dem Stickerplan. Sie zählen für diesen Tag und diese Woche und sind sofort bestätigt. Einen Sticker gedrückt halten, um ihn zurückzunehmen.", + "panel.backdate_hint_list": "Für eine Aufgabe, die an einem früheren Tag erledigt, aber nicht abgehakt wurde. Sie zählt für diesen Tag und diese Woche und ist sofort bestätigt.", + "panel.backdate_finish": "Fertig", + "panel.backdate_day": "Tag der Erledigung", + "panel.backdate_chore": "Aufgabe", + "panel.backdate_none": "An diesem Tag war für {name} keine Aufgabe fällig.", + "panel.backdate_save": "Nachtragen", + "panel.backdate_done": "{chore} für {name} am {day} nachgetragen", "panel.adjust_err_amount": "Gib einen Betrag zwischen 1 und 10000 ein.", "panel.adjust_reason": "Grund (optional)", "panel.adjust_remove": "Abziehen", diff --git a/custom_components/taskmate/www/locales/en-GB.json b/custom_components/taskmate/www/locales/en-GB.json index 4ea97d9e..c95db8d9 100644 --- a/custom_components/taskmate/www/locales/en-GB.json +++ b/custom_components/taskmate/www/locales/en-GB.json @@ -1953,6 +1953,57 @@ "streak.editor.days_shown_helper": "How many days of dots to show (default: 14)", "streak.editor.title": "Title", "streak.error.entity_required": "Please define an entity", + "sticker_chart.error.entity_required": "Please define an entity", + "sticker_chart.default_title": "{name}'s Sticker Chart", + "sticker_chart.today": "Today: {done} / {total} jobs done", + "sticker_chart.all_done": "All done, {name}!", + "sticker_chart.day_done_message": "Every job finished today!", + "sticker_chart.no_stickers": "No stickers yet - do a chore to earn your first!", + "sticker_chart.stickers_alt": "{count} {points} earned", + "sticker_chart.goal": "{points} / {goal} {unit} to the reward", + "sticker_chart.goal_reached": "GOAL REACHED! Time for your reward!", + "sticker_chart.week_goal": "{points} / {goal} {unit} this week", + "sticker_chart.week_perfect": "PERFECT WEEK! Every star earned!", + "sticker_chart.block_key": "Each block = {count} {unit}", + "sticker_chart.day_count": "{day}: {done} / {total} jobs done", + "sticker_chart.no_jobs_day": "No jobs were due on {day}.", + "sticker_chart.editor.goal": "What the stickers count towards", + "sticker_chart.editor.goal_reward": "The reward", + "sticker_chart.editor.goal_week": "This week's jobs", + "sticker_chart.editor.goal_helper": "This week's jobs: the blocks fill with the stars earned since Monday, out of every star the week's jobs can give. The reward and claim button stay as they are.", + "sticker_chart.no_reward": "No reward to save up for yet - pick one in the card settings.", + "sticker_chart.no_child": "No children found on this entity", + "sticker_chart.editor.title": "Title", + "sticker_chart.editor.reward": "Reward to save up for", + "sticker_chart.editor.reward_helper": "The reward's cost becomes the goal. Leave on automatic to aim at the cheapest one they haven't earned yet.", + "sticker_chart.editor.reward_auto": "Automatic (next one to earn)", + "sticker_chart.editor.show_today": "Show today's jobs", + "sticker_chart.editor.show_today_helper": "Show the \"Today: x / y jobs done\" line above the stickers.", + "sticker_chart.claim": "Claim {reward}", + "sticker_chart.claim_confirm": "Tap again to claim {reward}", + "sticker_chart.claim_pending": "Waiting for a grown-up to say yes", + "sticker_chart.claim_short": "{count} more {unit} to go", + "sticker_chart.claimed": "{reward} claimed for {name}!", + "sticker_chart.complete_failed": "That didn't work: {error}", + "sticker_chart.editor.allow_undo": "Hold a sticker to undo it", + "sticker_chart.editor.allow_undo_helper": "Press and hold a sticker to take it back. TaskMate only lets a parent do this.", + "sticker_chart.editor.child": "Child", + "sticker_chart.editor.child_all": "All children, side by side", + "sticker_chart.editor.columns": "Stickers per row", + "sticker_chart.editor.show_claim": "Show the claim button", + "sticker_chart.editor.show_claim_helper": "A button under the goal that claims the reward once enough is earned (tap twice to confirm).", + "sticker_chart.editor.sound": "Play the chore's sound", + "sticker_chart.editor.title_helper": "Leave empty to use each child's name.", + "sticker_chart.goal_tile": "Goal", + "sticker_chart.needs_photo": "This job needs a photo - open it on the TaskMate child card.", + "sticker_chart.no_jobs_today": "No jobs today - enjoy your day!", + "sticker_chart.state.done": "done", + "sticker_chart.state.partial": "started", + "sticker_chart.state.pending": "waiting for a grown-up", + "sticker_chart.state.todo": "to do", + "sticker_chart.not_allowed": "This tablet isn't allowed to tick {name}'s jobs. A grown-up can fix that in TaskMate: link {name} to the account this tablet uses, or add that account as a parent.", + "sticker_chart.undo_denied": "Only a grown-up can undo a sticker.", + "sticker_chart.undone": "Sticker taken back.", "visibility.description_hidden": "Hidden by visibility condition", "visibility.description_shown": "Shown by visibility condition", "visibility.label_entity": "Visibility Entity", @@ -2087,6 +2138,16 @@ "panel.adjust_custom_title": "Adjust {child}'s points by a custom amount", "panel.adjust_dialog_title": "Adjust points — {name}", "panel.adjust_done": "{sign}{amount} {points} for {child}", + "panel.backdate_button": "Log a past job", + "panel.backdate_title": "Log a past job for {name}", + "panel.backdate_hint": "Pick the day, then tap the jobs that were done, just like on the sticker chart. They count for that day and week and are approved straight away. Hold a sticker to take it back.", + "panel.backdate_hint_list": "For a job done on an earlier day that nobody ticked. It counts for that day and week, and it's approved straight away.", + "panel.backdate_finish": "Done", + "panel.backdate_day": "Day it was done", + "panel.backdate_chore": "Job", + "panel.backdate_none": "No jobs were due for {name} that day.", + "panel.backdate_save": "Log it", + "panel.backdate_done": "{chore} logged for {name} on {day}", "panel.adjust_err_amount": "Enter an amount between 1 and 10000.", "panel.adjust_reason": "Reason (optional)", "panel.adjust_remove": "Remove", diff --git a/custom_components/taskmate/www/locales/en.json b/custom_components/taskmate/www/locales/en.json index ec557728..bb03b191 100644 --- a/custom_components/taskmate/www/locales/en.json +++ b/custom_components/taskmate/www/locales/en.json @@ -1953,6 +1953,57 @@ "streak.editor.days_shown_helper": "How many days of dots to show (default: 14)", "streak.editor.title": "Title", "streak.error.entity_required": "Please define an entity", + "sticker_chart.error.entity_required": "Please define an entity", + "sticker_chart.default_title": "{name}'s Sticker Chart", + "sticker_chart.today": "Today: {done} / {total} jobs done", + "sticker_chart.all_done": "All done, {name}!", + "sticker_chart.day_done_message": "Every job finished today!", + "sticker_chart.no_stickers": "No stickers yet - do a chore to earn your first!", + "sticker_chart.stickers_alt": "{count} {points} earned", + "sticker_chart.goal": "{points} / {goal} {unit} to the reward", + "sticker_chart.goal_reached": "GOAL REACHED! Time for your reward!", + "sticker_chart.week_goal": "{points} / {goal} {unit} this week", + "sticker_chart.week_perfect": "PERFECT WEEK! Every star earned!", + "sticker_chart.block_key": "Each block = {count} {unit}", + "sticker_chart.day_count": "{day}: {done} / {total} jobs done", + "sticker_chart.no_jobs_day": "No jobs were due on {day}.", + "sticker_chart.editor.goal": "What the stickers count towards", + "sticker_chart.editor.goal_reward": "The reward", + "sticker_chart.editor.goal_week": "This week's jobs", + "sticker_chart.editor.goal_helper": "This week's jobs: the blocks fill with the stars earned since Monday, out of every star the week's jobs can give. The reward and claim button stay as they are.", + "sticker_chart.no_reward": "No reward to save up for yet - pick one in the card settings.", + "sticker_chart.no_child": "No children found on this entity", + "sticker_chart.editor.title": "Title", + "sticker_chart.editor.reward": "Reward to save up for", + "sticker_chart.editor.reward_helper": "The reward's cost becomes the goal. Leave on automatic to aim at the cheapest one they haven't earned yet.", + "sticker_chart.editor.reward_auto": "Automatic (next one to earn)", + "sticker_chart.editor.show_today": "Show today's jobs", + "sticker_chart.editor.show_today_helper": "Show the \"Today: x / y jobs done\" line above the stickers.", + "sticker_chart.claim": "Claim {reward}", + "sticker_chart.claim_confirm": "Tap again to claim {reward}", + "sticker_chart.claim_pending": "Waiting for a grown-up to say yes", + "sticker_chart.claim_short": "{count} more {unit} to go", + "sticker_chart.claimed": "{reward} claimed for {name}!", + "sticker_chart.complete_failed": "That didn't work: {error}", + "sticker_chart.editor.allow_undo": "Hold a sticker to undo it", + "sticker_chart.editor.allow_undo_helper": "Press and hold a sticker to take it back. TaskMate only lets a parent do this.", + "sticker_chart.editor.child": "Child", + "sticker_chart.editor.child_all": "All children, side by side", + "sticker_chart.editor.columns": "Stickers per row", + "sticker_chart.editor.show_claim": "Show the claim button", + "sticker_chart.editor.show_claim_helper": "A button under the goal that claims the reward once enough is earned (tap twice to confirm).", + "sticker_chart.editor.sound": "Play the chore's sound", + "sticker_chart.editor.title_helper": "Leave empty to use each child's name.", + "sticker_chart.goal_tile": "Goal", + "sticker_chart.needs_photo": "This job needs a photo - open it on the TaskMate child card.", + "sticker_chart.no_jobs_today": "No jobs today - enjoy your day!", + "sticker_chart.state.done": "done", + "sticker_chart.state.partial": "started", + "sticker_chart.state.pending": "waiting for a grown-up", + "sticker_chart.state.todo": "to do", + "sticker_chart.not_allowed": "This tablet isn't allowed to tick {name}'s jobs. A grown-up can fix that in TaskMate: link {name} to the account this tablet uses, or add that account as a parent.", + "sticker_chart.undo_denied": "Only a grown-up can undo a sticker.", + "sticker_chart.undone": "Sticker taken back.", "visibility.description_hidden": "Hidden by visibility condition", "visibility.description_shown": "Shown by visibility condition", "visibility.label_entity": "Visibility Entity", @@ -2087,6 +2138,16 @@ "panel.adjust_custom_title": "Adjust {child}'s points by a custom amount", "panel.adjust_dialog_title": "Adjust points — {name}", "panel.adjust_done": "{sign}{amount} {points} for {child}", + "panel.backdate_button": "Log a past job", + "panel.backdate_title": "Log a past job for {name}", + "panel.backdate_hint": "Pick the day, then tap the jobs that were done, just like on the sticker chart. They count for that day and week and are approved straight away. Hold a sticker to take it back.", + "panel.backdate_hint_list": "For a job done on an earlier day that nobody ticked. It counts for that day and week, and it's approved straight away.", + "panel.backdate_finish": "Done", + "panel.backdate_day": "Day it was done", + "panel.backdate_chore": "Job", + "panel.backdate_none": "No jobs were due for {name} that day.", + "panel.backdate_save": "Log it", + "panel.backdate_done": "{chore} logged for {name} on {day}", "panel.adjust_err_amount": "Enter an amount between 1 and 10000.", "panel.adjust_reason": "Reason (optional)", "panel.adjust_remove": "Remove", diff --git a/custom_components/taskmate/www/locales/fr.json b/custom_components/taskmate/www/locales/fr.json index 41edcb47..303e0b10 100644 --- a/custom_components/taskmate/www/locales/fr.json +++ b/custom_components/taskmate/www/locales/fr.json @@ -1886,6 +1886,57 @@ "streak.editor.days_shown_helper": "Combien de jours de points afficher (par défaut : 14)", "streak.editor.title": "Titre", "streak.error.entity_required": "Veuillez définir une entité", + "sticker_chart.error.entity_required": "Veuillez définir une entité", + "sticker_chart.default_title": "Tableau d'autocollants de {name}", + "sticker_chart.today": "Aujourd'hui : {done} / {total} tâches faites", + "sticker_chart.all_done": "Tout est fait, {name} !", + "sticker_chart.day_done_message": "Toutes les tâches du jour sont faites !", + "sticker_chart.no_stickers": "Pas encore d'autocollants - fais une tâche pour gagner le premier !", + "sticker_chart.stickers_alt": "{count} {points} gagnées", + "sticker_chart.goal": "{points} / {goal} {unit} avant la récompense", + "sticker_chart.goal_reached": "OBJECTIF ATTEINT ! C'est l'heure de la récompense !", + "sticker_chart.week_goal": "{points} / {goal} {unit} cette semaine", + "sticker_chart.week_perfect": "SEMAINE PARFAITE ! Toutes les étoiles gagnées !", + "sticker_chart.block_key": "Chaque case = {count} {unit}", + "sticker_chart.day_count": "{day} : {done} / {total} tâches faites", + "sticker_chart.no_jobs_day": "Aucune tâche n'était prévue le {day}.", + "sticker_chart.editor.goal": "Ce que comptent les autocollants", + "sticker_chart.editor.goal_reward": "La récompense", + "sticker_chart.editor.goal_week": "Les tâches de la semaine", + "sticker_chart.editor.goal_helper": "Tâches de la semaine : les cases se remplissent avec les étoiles gagnées depuis lundi, sur toutes celles que les tâches de la semaine peuvent rapporter. La récompense et son bouton ne changent pas.", + "sticker_chart.no_reward": "Aucune récompense à viser pour l'instant - choisissez-en une dans les réglages de la carte.", + "sticker_chart.no_child": "Aucun enfant trouvé pour cette entité", + "sticker_chart.editor.title": "Titre", + "sticker_chart.editor.reward": "Récompense à viser", + "sticker_chart.editor.reward_helper": "Le coût de la récompense devient l'objectif. Laissez sur automatique pour viser la moins chère pas encore gagnée.", + "sticker_chart.editor.reward_auto": "Automatique (la prochaine à gagner)", + "sticker_chart.editor.show_today": "Afficher les tâches du jour", + "sticker_chart.editor.show_today_helper": "Affiche la ligne « Aujourd'hui : x / y tâches faites » au-dessus des autocollants.", + "sticker_chart.claim": "Obtenir {reward}", + "sticker_chart.claim_confirm": "Touchez encore pour obtenir {reward}", + "sticker_chart.claim_pending": "En attente de l'accord d'un adulte", + "sticker_chart.claim_short": "Encore {count} {unit}", + "sticker_chart.claimed": "{reward} obtenu pour {name} !", + "sticker_chart.complete_failed": "Ça n'a pas marché : {error}", + "sticker_chart.editor.allow_undo": "Maintenir un autocollant pour l'annuler", + "sticker_chart.editor.allow_undo_helper": "Maintenez un autocollant appuyé pour le reprendre. TaskMate ne l'autorise qu'aux parents.", + "sticker_chart.editor.child": "Enfant", + "sticker_chart.editor.child_all": "Tous les enfants côte à côte", + "sticker_chart.editor.columns": "Autocollants par ligne", + "sticker_chart.editor.show_claim": "Afficher le bouton de récompense", + "sticker_chart.editor.show_claim_helper": "Un bouton sous l'objectif qui obtient la récompense une fois assez gagné (touchez deux fois pour confirmer).", + "sticker_chart.editor.sound": "Jouer le son de la tâche", + "sticker_chart.editor.title_helper": "Laissez vide pour utiliser le prénom de chaque enfant.", + "sticker_chart.goal_tile": "Objectif", + "sticker_chart.needs_photo": "Cette tâche demande une photo - ouvrez-la sur la carte enfant TaskMate.", + "sticker_chart.no_jobs_today": "Pas de tâches aujourd'hui - profite de ta journée !", + "sticker_chart.state.done": "fait", + "sticker_chart.state.partial": "commencé", + "sticker_chart.state.pending": "attend un adulte", + "sticker_chart.state.todo": "à faire", + "sticker_chart.not_allowed": "Cette tablette n'a pas le droit de cocher les tâches de {name}. Un adulte peut le corriger dans TaskMate : associer {name} au compte de cette tablette, ou ajouter ce compte comme parent.", + "sticker_chart.undo_denied": "Seul un adulte peut reprendre un autocollant.", + "sticker_chart.undone": "Autocollant repris.", "visibility.description_hidden": "Masqué par la condition de visibilité", "visibility.description_shown": "Affiché par la condition de visibilité", "visibility.label_entity": "Entité de visibilité", @@ -2087,6 +2138,16 @@ "panel.adjust_custom_title": "Ajuster les points de {child} d'un montant personnalisé", "panel.adjust_dialog_title": "Ajuster les points — {name}", "panel.adjust_done": "{sign}{amount} {points} pour {child}", + "panel.backdate_button": "Ajouter une tâche passée", + "panel.backdate_title": "Ajouter une tâche passée pour {name}", + "panel.backdate_hint": "Choisissez le jour, puis touchez les tâches faites, comme sur le tableau d'autocollants. Elles comptent pour ce jour et cette semaine, et sont validées tout de suite. Maintenez un autocollant pour l'annuler.", + "panel.backdate_hint_list": "Pour une tâche faite un jour précédent mais jamais cochée. Elle compte pour ce jour et cette semaine, et elle est validée tout de suite.", + "panel.backdate_finish": "Terminé", + "panel.backdate_day": "Jour où elle a été faite", + "panel.backdate_chore": "Tâche", + "panel.backdate_none": "Aucune tâche n'était prévue pour {name} ce jour-là.", + "panel.backdate_save": "Ajouter", + "panel.backdate_done": "{chore} ajoutée pour {name} le {day}", "panel.adjust_err_amount": "Saisissez un montant entre 1 et 10000.", "panel.adjust_reason": "Motif (facultatif)", "panel.adjust_remove": "Retirer", diff --git a/custom_components/taskmate/www/locales/nb.json b/custom_components/taskmate/www/locales/nb.json index 2adf998c..369cf35d 100644 --- a/custom_components/taskmate/www/locales/nb.json +++ b/custom_components/taskmate/www/locales/nb.json @@ -1886,6 +1886,57 @@ "streak.editor.days_shown_helper": "Hvor mange dager med prikker som skal vises (standard: 14)", "streak.editor.title": "Tittel", "streak.error.entity_required": "Vennligst definer en entitet", + "sticker_chart.error.entity_required": "Vennligst definer en entitet", + "sticker_chart.default_title": "{name} sin klistremerketavle", + "sticker_chart.today": "I dag: {done} / {total} oppgaver gjort", + "sticker_chart.all_done": "Alt er gjort, {name}!", + "sticker_chart.day_done_message": "Alle dagens oppgaver er gjort!", + "sticker_chart.no_stickers": "Ingen klistremerker ennå - gjør en oppgave for å få det første!", + "sticker_chart.stickers_alt": "{count} {points} opptjent", + "sticker_chart.goal": "{points} / {goal} {unit} til belønningen", + "sticker_chart.goal_reached": "MÅLET ER NÅDD! På tide med belønning!", + "sticker_chart.week_goal": "{points} / {goal} {unit} denne uken", + "sticker_chart.week_perfect": "PERFEKT UKE! Hver eneste stjerne tjent!", + "sticker_chart.block_key": "Hver rute = {count} {unit}", + "sticker_chart.day_count": "{day}: {done} / {total} oppgaver gjort", + "sticker_chart.no_jobs_day": "Ingen oppgaver den {day}.", + "sticker_chart.editor.goal": "Hva klistremerkene teller mot", + "sticker_chart.editor.goal_reward": "Belønningen", + "sticker_chart.editor.goal_week": "Ukens oppgaver", + "sticker_chart.editor.goal_helper": "Ukens oppgaver: rutene fylles med stjernene tjent siden mandag, av alle stjernene ukens oppgaver kan gi. Belønningen og knappen for å hente den er som før.", + "sticker_chart.no_reward": "Ingen belønning å spare til ennå - velg en i kortinnstillingene.", + "sticker_chart.no_child": "Fant ingen barn på denne entiteten", + "sticker_chart.editor.title": "Tittel", + "sticker_chart.editor.reward": "Belønning å spare til", + "sticker_chart.editor.reward_helper": "Prisen på belønningen blir målet. La stå på automatisk for å sikte mot den billigste som ikke er opptjent ennå.", + "sticker_chart.editor.reward_auto": "Automatisk (den neste å tjene)", + "sticker_chart.editor.show_today": "Vis dagens oppgaver", + "sticker_chart.editor.show_today_helper": "Viser linjen «I dag: x / y oppgaver gjort» over klistremerkene.", + "sticker_chart.claim": "Hent {reward}", + "sticker_chart.claim_confirm": "Trykk igjen for å hente {reward}", + "sticker_chart.claim_pending": "Venter på at en voksen sier ja", + "sticker_chart.claim_short": "{count} {unit} igjen", + "sticker_chart.claimed": "{reward} hentet for {name}!", + "sticker_chart.complete_failed": "Det gikk ikke: {error}", + "sticker_chart.editor.allow_undo": "Hold inne et klistremerke for å angre", + "sticker_chart.editor.allow_undo_helper": "Hold inne et klistremerke for å ta det tilbake. TaskMate lar bare foreldre gjøre dette.", + "sticker_chart.editor.child": "Barn", + "sticker_chart.editor.child_all": "Alle barn side om side", + "sticker_chart.editor.columns": "Klistremerker per rad", + "sticker_chart.editor.show_claim": "Vis hent-knappen", + "sticker_chart.editor.show_claim_helper": "En knapp under målet som henter belønningen når nok er opptjent (trykk to ganger for å bekrefte).", + "sticker_chart.editor.sound": "Spill oppgavens lyd", + "sticker_chart.editor.title_helper": "La stå tom for å bruke navnet til hvert barn.", + "sticker_chart.goal_tile": "Mål", + "sticker_chart.needs_photo": "Denne oppgaven trenger et bilde - åpne den på TaskMate-barnekortet.", + "sticker_chart.no_jobs_today": "Ingen oppgaver i dag - kos deg!", + "sticker_chart.state.done": "gjort", + "sticker_chart.state.partial": "påbegynt", + "sticker_chart.state.pending": "venter på en voksen", + "sticker_chart.state.todo": "ikke gjort", + "sticker_chart.not_allowed": "Dette nettbrettet får ikke krysse av oppgavene til {name}. En voksen kan fikse det i TaskMate: koble {name} til kontoen nettbrettet bruker, eller legge til den kontoen som forelder.", + "sticker_chart.undo_denied": "Bare en voksen kan ta tilbake et klistremerke.", + "sticker_chart.undone": "Klistremerket er tatt tilbake.", "visibility.description_hidden": "Skjult av synlighetsbetingelse", "visibility.description_shown": "Vist av synlighetsbetingelse", "visibility.label_entity": "Synlighetsentitet", @@ -2087,6 +2138,16 @@ "panel.adjust_custom_title": "Juster poengene til {child} med et eget beløp", "panel.adjust_dialog_title": "Juster poeng — {name}", "panel.adjust_done": "{sign}{amount} {points} til {child}", + "panel.backdate_button": "Før opp en tidligere oppgave", + "panel.backdate_title": "Før opp en tidligere oppgave for {name}", + "panel.backdate_hint": "Velg dagen, og trykk så på oppgavene som ble gjort, akkurat som på klistremerketavla. De teller for den dagen og uken, og godkjennes med en gang. Hold inne et klistremerke for å ta det tilbake.", + "panel.backdate_hint_list": "For en oppgave som ble gjort en tidligere dag, men ikke krysset av. Den teller for den dagen og uken, og godkjennes med en gang.", + "panel.backdate_finish": "Ferdig", + "panel.backdate_day": "Dagen den ble gjort", + "panel.backdate_chore": "Oppgave", + "panel.backdate_none": "{name} hadde ingen oppgaver den dagen.", + "panel.backdate_save": "Før opp", + "panel.backdate_done": "{chore} ført opp for {name} {day}", "panel.adjust_err_amount": "Skriv inn et beløp mellom 1 og 10000.", "panel.adjust_reason": "Årsak (valgfritt)", "panel.adjust_remove": "Trekk fra", diff --git a/custom_components/taskmate/www/locales/nn.json b/custom_components/taskmate/www/locales/nn.json index 2a900994..0b1ef0e4 100644 --- a/custom_components/taskmate/www/locales/nn.json +++ b/custom_components/taskmate/www/locales/nn.json @@ -1886,6 +1886,57 @@ "streak.editor.days_shown_helper": "Kor mange dagar med prikkar som skal visast (standard: 14)", "streak.editor.title": "Tittel", "streak.error.entity_required": "Ver venleg og definer ein entitet", + "sticker_chart.error.entity_required": "Ver venleg og definer ein entitet", + "sticker_chart.default_title": "{name} si klistremerketavle", + "sticker_chart.today": "I dag: {done} / {total} oppgåver gjorde", + "sticker_chart.all_done": "Alt er gjort, {name}!", + "sticker_chart.day_done_message": "Alle oppgåvene i dag er gjorde!", + "sticker_chart.no_stickers": "Ingen klistremerke enno - gjer ei oppgåve for å få det første!", + "sticker_chart.stickers_alt": "{count} {points} opptente", + "sticker_chart.goal": "{points} / {goal} {unit} til løninga", + "sticker_chart.goal_reached": "MÅLET ER NÅDD! På tide med løn!", + "sticker_chart.week_goal": "{points} / {goal} {unit} denne veka", + "sticker_chart.week_perfect": "PERFEKT VEKE! Kvar einaste stjerne tent!", + "sticker_chart.block_key": "Kvar rute = {count} {unit}", + "sticker_chart.day_count": "{day}: {done} / {total} oppgåver gjorde", + "sticker_chart.no_jobs_day": "Ingen oppgåver den {day}.", + "sticker_chart.editor.goal": "Kva klistremerka tel mot", + "sticker_chart.editor.goal_reward": "Løna", + "sticker_chart.editor.goal_week": "Oppgåvene denne veka", + "sticker_chart.editor.goal_helper": "Oppgåvene denne veka: rutene blir fylte med stjernene tente sidan måndag, av alle stjernene oppgåvene i veka kan gi. Løna og knappen for å hente henne er som før.", + "sticker_chart.no_reward": "Inga løn å spare til enno - vel ei i kortinnstillingane.", + "sticker_chart.no_child": "Fann ingen born på denne entiteten", + "sticker_chart.editor.title": "Tittel", + "sticker_chart.editor.reward": "Løn å spare til", + "sticker_chart.editor.reward_helper": "Prisen på løna blir målet. Lat stå på automatisk for å sikte mot den billegaste som ikkje er opptent enno.", + "sticker_chart.editor.reward_auto": "Automatisk (den neste å tene)", + "sticker_chart.editor.show_today": "Vis oppgåvene i dag", + "sticker_chart.editor.show_today_helper": "Viser linja «I dag: x / y oppgåver gjorde» over klistremerka.", + "sticker_chart.claim": "Hent {reward}", + "sticker_chart.claim_confirm": "Trykk igjen for å hente {reward}", + "sticker_chart.claim_pending": "Ventar på at ein vaksen seier ja", + "sticker_chart.claim_short": "{count} {unit} att", + "sticker_chart.claimed": "{reward} henta for {name}!", + "sticker_chart.complete_failed": "Det gjekk ikkje: {error}", + "sticker_chart.editor.allow_undo": "Hald inne eit klistremerke for å angre", + "sticker_chart.editor.allow_undo_helper": "Hald inne eit klistremerke for å ta det tilbake. TaskMate lèt berre foreldre gjere dette.", + "sticker_chart.editor.child": "Barn", + "sticker_chart.editor.child_all": "Alle born side om side", + "sticker_chart.editor.columns": "Klistremerke per rad", + "sticker_chart.editor.show_claim": "Vis hent-knappen", + "sticker_chart.editor.show_claim_helper": "Ein knapp under målet som hentar løna når nok er opptent (trykk to gonger for å stadfeste).", + "sticker_chart.editor.sound": "Spel lyden til oppgåva", + "sticker_chart.editor.title_helper": "La stå tom for å bruke namnet til kvart barn.", + "sticker_chart.goal_tile": "Mål", + "sticker_chart.needs_photo": "Denne oppgåva treng eit bilete - opne ho på TaskMate-barnekortet.", + "sticker_chart.no_jobs_today": "Ingen oppgåver i dag - kos deg!", + "sticker_chart.state.done": "gjort", + "sticker_chart.state.partial": "byrja", + "sticker_chart.state.pending": "ventar på ein vaksen", + "sticker_chart.state.todo": "ikkje gjort", + "sticker_chart.not_allowed": "Dette nettbrettet får ikkje krysse av oppgåvene til {name}. Ein vaksen kan fikse det i TaskMate: kople {name} til kontoen nettbrettet brukar, eller leggje til den kontoen som forelder.", + "sticker_chart.undo_denied": "Berre ein vaksen kan ta tilbake eit klistremerke.", + "sticker_chart.undone": "Klistremerket er teke tilbake.", "visibility.description_hidden": "Skjult av synlegheitskondisjon", "visibility.description_shown": "Vist av synlegheitskondisjon", "visibility.label_entity": "Synlegheitsentitet", @@ -2087,6 +2138,16 @@ "panel.adjust_custom_title": "Juster poenga til {child} med eit eige beløp", "panel.adjust_dialog_title": "Juster poeng — {name}", "panel.adjust_done": "{sign}{amount} {points} til {child}", + "panel.backdate_button": "Før opp ei tidlegare oppgåve", + "panel.backdate_title": "Før opp ei tidlegare oppgåve for {name}", + "panel.backdate_hint": "Vel dagen, og trykk så på oppgåvene som vart gjorde, akkurat som på klistremerketavla. Dei tel for den dagen og veka, og blir godkjende med ein gong. Hald inne eit klistremerke for å ta det tilbake.", + "panel.backdate_hint_list": "For ei oppgåve som vart gjord ein tidlegare dag, men ikkje kryssa av. Ho tel for den dagen og veka, og blir godkjend med ein gong.", + "panel.backdate_finish": "Ferdig", + "panel.backdate_day": "Dagen ho vart gjord", + "panel.backdate_chore": "Oppgåve", + "panel.backdate_none": "{name} hadde ingen oppgåver den dagen.", + "panel.backdate_save": "Før opp", + "panel.backdate_done": "{chore} ført opp for {name} {day}", "panel.adjust_err_amount": "Skriv inn eit beløp mellom 1 og 10000.", "panel.adjust_reason": "Årsak (valfritt)", "panel.adjust_remove": "Trekk frå", diff --git a/custom_components/taskmate/www/locales/pl.json b/custom_components/taskmate/www/locales/pl.json index f8eca63e..ddbbe43d 100644 --- a/custom_components/taskmate/www/locales/pl.json +++ b/custom_components/taskmate/www/locales/pl.json @@ -3057,5 +3057,66 @@ "notify.sample_pledger": "Babcia", "notify.sample_bounty": "Umyj samochód", "notify.sample_reason": "Niedokończone", - "notify.sample_note": "Świetna robota!" + "notify.sample_note": "Świetna robota!", + "sticker_chart.error.entity_required": "Zdefiniuj encję", + "sticker_chart.default_title": "Tabela naklejek: {name}", + "sticker_chart.today": "Dziś: {done} / {total} zadań zrobionych", + "sticker_chart.all_done": "Wszystko zrobione, {name}!", + "sticker_chart.day_done_message": "Wszystkie zadania na dziś gotowe!", + "sticker_chart.no_stickers": "Jeszcze nie ma naklejek – zrób obowiązek, aby zdobyć pierwszą!", + "sticker_chart.stickers_alt": "Zdobyte: {count} {points}", + "sticker_chart.goal": "{points} / {goal} {unit} do nagrody", + "sticker_chart.goal_reached": "CEL OSIĄGNIĘTY! Czas na nagrodę!", + "sticker_chart.week_goal": "{points} / {goal} {unit} w tym tygodniu", + "sticker_chart.week_perfect": "IDEALNY TYDZIEŃ! Wszystkie gwiazdki zdobyte!", + "sticker_chart.block_key": "Każdy blok = {count} {unit}", + "sticker_chart.day_count": "{day}: {done} / {total} zadań zrobionych", + "sticker_chart.no_jobs_day": "W dniu {day} nie było żadnych zadań.", + "sticker_chart.editor.goal": "Do czego liczą się naklejki", + "sticker_chart.editor.goal_reward": "Nagroda", + "sticker_chart.editor.goal_week": "Zadania z tego tygodnia", + "sticker_chart.editor.goal_helper": "Zadania z tego tygodnia: bloki wypełniają się gwiazdkami zdobytymi od poniedziałku, z wszystkich gwiazdek, jakie mogą dać zadania tygodnia. Nagroda i przycisk odbioru pozostają bez zmian.", + "sticker_chart.no_reward": "Nie ma jeszcze nagrody do zbierania – wybierz ją w ustawieniach karty.", + "sticker_chart.no_child": "Nie znaleziono dzieci dla tej encji", + "sticker_chart.editor.title": "Tytuł", + "sticker_chart.editor.reward": "Nagroda, na którą się zbiera", + "sticker_chart.editor.reward_helper": "Koszt nagrody staje się celem. Zostaw automatycznie, aby celować w najtańszą, jeszcze niezdobytą nagrodę.", + "sticker_chart.editor.reward_auto": "Automatycznie (następna do zdobycia)", + "sticker_chart.editor.show_today": "Pokaż dzisiejsze zadania", + "sticker_chart.editor.show_today_helper": "Pokaż wiersz „Dziś: x / y zadań zrobionych” nad naklejkami.", + "sticker_chart.claim": "Odbierz: {reward}", + "sticker_chart.claim_confirm": "Dotknij ponownie, aby odebrać: {reward}", + "sticker_chart.claim_pending": "Czeka na zgodę dorosłego", + "sticker_chart.claim_short": "Jeszcze {count} {unit}", + "sticker_chart.claimed": "Odebrano {reward} dla {name}!", + "sticker_chart.complete_failed": "Nie udało się: {error}", + "sticker_chart.editor.allow_undo": "Przytrzymaj naklejkę, aby cofnąć", + "sticker_chart.editor.allow_undo_helper": "Naciśnij i przytrzymaj naklejkę, aby ją zabrać. TaskMate pozwala na to tylko rodzicowi.", + "sticker_chart.editor.child": "Dziecko", + "sticker_chart.editor.child_all": "Wszystkie dzieci obok siebie", + "sticker_chart.editor.columns": "Naklejek w rzędzie", + "sticker_chart.editor.show_claim": "Pokaż przycisk odbioru", + "sticker_chart.editor.show_claim_helper": "Przycisk pod celem, który odbiera nagrodę po zdobyciu wystarczającej liczby (dotknij dwa razy, aby potwierdzić).", + "sticker_chart.editor.sound": "Odtwarzaj dźwięk obowiązku", + "sticker_chart.editor.title_helper": "Zostaw puste, aby użyć imienia każdego dziecka.", + "sticker_chart.goal_tile": "Cel", + "sticker_chart.needs_photo": "To zadanie wymaga zdjęcia – otwórz je na karcie dziecka TaskMate.", + "sticker_chart.no_jobs_today": "Dziś brak zadań – miłego dnia!", + "sticker_chart.state.done": "zrobione", + "sticker_chart.state.partial": "rozpoczęte", + "sticker_chart.state.pending": "czeka na dorosłego", + "sticker_chart.state.todo": "do zrobienia", + "sticker_chart.not_allowed": "Ten tablet nie może odznaczać zadań dziecka {name}. Dorosły może to zmienić w TaskMate: połącz {name} z kontem używanym na tym tablecie lub dodaj to konto jako rodzica.", + "sticker_chart.undo_denied": "Tylko dorosły może cofnąć naklejkę.", + "sticker_chart.undone": "Naklejka zabrana.", + "panel.backdate_button": "Zapisz minione zadanie", + "panel.backdate_title": "Zapisz minione zadanie dla: {name}", + "panel.backdate_hint": "Wybierz dzień, a potem dotknij zadań, które zostały zrobione – tak jak w tabeli naklejek. Liczą się do tego dnia i tygodnia i są zatwierdzane od razu. Przytrzymaj naklejkę, aby ją cofnąć.", + "panel.backdate_hint_list": "Dla zadania zrobionego wcześniej, którego nikt nie odznaczył. Liczy się do tamtego dnia i tygodnia i jest zatwierdzane od razu.", + "panel.backdate_finish": "Gotowe", + "panel.backdate_day": "Dzień wykonania", + "panel.backdate_chore": "Zadanie", + "panel.backdate_none": "W tym dniu nie było zadań dla: {name}.", + "panel.backdate_save": "Zapisz", + "panel.backdate_done": "Zapisano {chore} dla: {name} w dniu {day}" } diff --git a/custom_components/taskmate/www/locales/pt-BR.json b/custom_components/taskmate/www/locales/pt-BR.json index 93fa3269..71f098c1 100644 --- a/custom_components/taskmate/www/locales/pt-BR.json +++ b/custom_components/taskmate/www/locales/pt-BR.json @@ -1886,6 +1886,57 @@ "streak.editor.days_shown_helper": "Quantos dias de pontos mostrar (padrão: 14)", "streak.editor.title": "Título", "streak.error.entity_required": "Por favor define uma entidade", + "sticker_chart.error.entity_required": "Por favor define uma entidade", + "sticker_chart.default_title": "Quadro de adesivos da {name}", + "sticker_chart.today": "Hoje: {done} / {total} tarefas feitas", + "sticker_chart.all_done": "Tudo pronto, {name}!", + "sticker_chart.day_done_message": "Todas as tarefas de hoje foram feitas!", + "sticker_chart.no_stickers": "Ainda sem adesivos - faça uma tarefa para ganhar o primeiro!", + "sticker_chart.stickers_alt": "{count} {points} ganhas", + "sticker_chart.goal": "{points} / {goal} {unit} até a recompensa", + "sticker_chart.goal_reached": "OBJETIVO ALCANÇADO! Hora da recompensa!", + "sticker_chart.week_goal": "{points} / {goal} {unit} nesta semana", + "sticker_chart.week_perfect": "SEMANA PERFEITA! Todas as estrelas conquistadas!", + "sticker_chart.block_key": "Cada bloco = {count} {unit}", + "sticker_chart.day_count": "{day}: {done} / {total} tarefas feitas", + "sticker_chart.no_jobs_day": "Não havia tarefas em {day}.", + "sticker_chart.editor.goal": "Para onde contam os adesivos", + "sticker_chart.editor.goal_reward": "A recompensa", + "sticker_chart.editor.goal_week": "As tarefas desta semana", + "sticker_chart.editor.goal_helper": "Tarefas desta semana: os blocos se enchem com as estrelas ganhas desde segunda-feira, de todas as que as tarefas da semana podem dar. A recompensa e o botão de resgate continuam iguais.", + "sticker_chart.no_reward": "Ainda não há recompensa para juntar - escolha uma nas configurações do cartão.", + "sticker_chart.no_child": "Nenhuma criança encontrada nesta entidade", + "sticker_chart.editor.title": "Título", + "sticker_chart.editor.reward": "Recompensa a ser conquistada", + "sticker_chart.editor.reward_helper": "O custo da recompensa vira o objetivo. Deixe em automático para mirar a mais barata ainda não conquistada.", + "sticker_chart.editor.reward_auto": "Automático (a próxima a conquistar)", + "sticker_chart.editor.show_today": "Mostrar tarefas de hoje", + "sticker_chart.editor.show_today_helper": "Mostra a linha \"Hoje: x / y tarefas feitas\" acima dos adesivos.", + "sticker_chart.claim": "Resgatar {reward}", + "sticker_chart.claim_confirm": "Toque de novo para resgatar {reward}", + "sticker_chart.claim_pending": "Esperando um adulto dizer sim", + "sticker_chart.claim_short": "Faltam {count} {unit}", + "sticker_chart.claimed": "{reward} resgatado por {name}!", + "sticker_chart.complete_failed": "Não deu certo: {error}", + "sticker_chart.editor.allow_undo": "Segurar um adesivo para desfazer", + "sticker_chart.editor.allow_undo_helper": "Segure um adesivo para retirá-lo. O TaskMate só deixa os pais fazerem isso.", + "sticker_chart.editor.child": "Criança", + "sticker_chart.editor.child_all": "Todas as crianças lado a lado", + "sticker_chart.editor.columns": "Adesivos por linha", + "sticker_chart.editor.show_claim": "Mostrar o botão de resgate", + "sticker_chart.editor.show_claim_helper": "Um botão abaixo do objetivo que resgata a recompensa quando já ganhou o suficiente (toque duas vezes para confirmar).", + "sticker_chart.editor.sound": "Tocar o som da tarefa", + "sticker_chart.editor.title_helper": "Deixe vazio para usar o nome de cada criança.", + "sticker_chart.goal_tile": "Objetivo", + "sticker_chart.needs_photo": "Esta tarefa precisa de uma foto - abra no cartão da criança do TaskMate.", + "sticker_chart.no_jobs_today": "Nenhuma tarefa hoje - aproveite o dia!", + "sticker_chart.state.done": "feito", + "sticker_chart.state.partial": "começado", + "sticker_chart.state.pending": "esperando um adulto", + "sticker_chart.state.todo": "a fazer", + "sticker_chart.not_allowed": "Este tablet não pode marcar as tarefas de {name}. Um adulto pode resolver no TaskMate: vincular {name} à conta que este tablet usa, ou adicionar essa conta como pai/mãe.", + "sticker_chart.undo_denied": "Só um adulto pode retirar um adesivo.", + "sticker_chart.undone": "Adesivo retirado.", "visibility.description_hidden": "Ocultado por condição de visibilidade", "visibility.description_shown": "Mostrado por condição de visibilidade", "visibility.label_entity": "Entidade de Visibilidade", @@ -2087,6 +2138,16 @@ "panel.adjust_custom_title": "Ajustar os pontos de {child} com um valor personalizado", "panel.adjust_dialog_title": "Ajustar pontos — {name}", "panel.adjust_done": "{sign}{amount} {points} para {child}", + "panel.backdate_button": "Registrar uma tarefa passada", + "panel.backdate_title": "Registrar uma tarefa passada para {name}", + "panel.backdate_hint": "Escolha o dia e toque nas tarefas que foram feitas, igual ao quadro de adesivos. Elas contam para esse dia e essa semana, e já ficam aprovadas. Segure um adesivo para desfazê-lo.", + "panel.backdate_hint_list": "Para uma tarefa feita em um dia anterior que ninguém marcou. Conta para esse dia e essa semana, e já fica aprovada.", + "panel.backdate_finish": "Concluir", + "panel.backdate_day": "Dia em que foi feita", + "panel.backdate_chore": "Tarefa", + "panel.backdate_none": "Não havia tarefas para {name} nesse dia.", + "panel.backdate_save": "Registrar", + "panel.backdate_done": "{chore} registrada para {name} em {day}", "panel.adjust_err_amount": "Digite um valor entre 1 e 10000.", "panel.adjust_reason": "Motivo (opcional)", "panel.adjust_remove": "Remover", diff --git a/custom_components/taskmate/www/locales/pt.json b/custom_components/taskmate/www/locales/pt.json index b9326ed8..72aa5a43 100644 --- a/custom_components/taskmate/www/locales/pt.json +++ b/custom_components/taskmate/www/locales/pt.json @@ -1891,6 +1891,57 @@ "streak.editor.days_shown_helper": "Quantos dias de pontos mostrar (padrão: 14)", "streak.editor.title": "Título", "streak.error.entity_required": "Por favor define uma entidade", + "sticker_chart.error.entity_required": "Por favor define uma entidade", + "sticker_chart.default_title": "Quadro de autocolantes da {name}", + "sticker_chart.today": "Hoje: {done} / {total} tarefas feitas", + "sticker_chart.all_done": "Está tudo feito, {name}!", + "sticker_chart.day_done_message": "Todas as tarefas de hoje estão feitas!", + "sticker_chart.no_stickers": "Ainda sem autocolantes - faz uma tarefa para ganhar o primeiro!", + "sticker_chart.stickers_alt": "{count} {points} ganhas", + "sticker_chart.goal": "{points} / {goal} {unit} até à recompensa", + "sticker_chart.goal_reached": "OBJETIVO ALCANÇADO! Hora da recompensa!", + "sticker_chart.week_goal": "{points} / {goal} {unit} esta semana", + "sticker_chart.week_perfect": "SEMANA PERFEITA! Todas as estrelas ganhas!", + "sticker_chart.block_key": "Cada bloco = {count} {unit}", + "sticker_chart.day_count": "{day}: {done} / {total} tarefas feitas", + "sticker_chart.no_jobs_day": "Não havia tarefas em {day}.", + "sticker_chart.editor.goal": "Para onde contam os autocolantes", + "sticker_chart.editor.goal_reward": "A recompensa", + "sticker_chart.editor.goal_week": "As tarefas desta semana", + "sticker_chart.editor.goal_helper": "Tarefas desta semana: os blocos enchem-se com as estrelas ganhas desde segunda-feira, de todas as que as tarefas da semana podem dar. A recompensa e o botão para a levantar ficam como estão.", + "sticker_chart.no_reward": "Ainda não há recompensa para juntar - escolhe uma nas definições do cartão.", + "sticker_chart.no_child": "Não foram encontradas crianças nesta entidade", + "sticker_chart.editor.title": "Título", + "sticker_chart.editor.reward": "Recompensa a que se destina", + "sticker_chart.editor.reward_helper": "O custo da recompensa passa a ser o objetivo. Deixa em automático para apontar à mais barata ainda não ganha.", + "sticker_chart.editor.reward_auto": "Automático (a próxima a ganhar)", + "sticker_chart.editor.show_today": "Mostrar tarefas de hoje", + "sticker_chart.editor.show_today_helper": "Mostra a linha \"Hoje: x / y tarefas feitas\" por cima dos autocolantes.", + "sticker_chart.claim": "Levantar {reward}", + "sticker_chart.claim_confirm": "Toca outra vez para levantar {reward}", + "sticker_chart.claim_pending": "À espera que um adulto diga que sim", + "sticker_chart.claim_short": "Faltam {count} {unit}", + "sticker_chart.claimed": "{reward} levantado por {name}!", + "sticker_chart.complete_failed": "Não resultou: {error}", + "sticker_chart.editor.allow_undo": "Manter premido um autocolante para anular", + "sticker_chart.editor.allow_undo_helper": "Mantém premido um autocolante para o retirar. O TaskMate só permite isto aos pais.", + "sticker_chart.editor.child": "Criança", + "sticker_chart.editor.child_all": "Todas as crianças lado a lado", + "sticker_chart.editor.columns": "Autocolantes por linha", + "sticker_chart.editor.show_claim": "Mostrar o botão de levantar", + "sticker_chart.editor.show_claim_helper": "Um botão por baixo do objetivo que levanta a recompensa quando já se ganhou o suficiente (toca duas vezes para confirmar).", + "sticker_chart.editor.sound": "Tocar o som da tarefa", + "sticker_chart.editor.title_helper": "Deixa vazio para usar o nome de cada criança.", + "sticker_chart.goal_tile": "Objetivo", + "sticker_chart.needs_photo": "Esta tarefa precisa de uma fotografia - abre-a no cartão da criança do TaskMate.", + "sticker_chart.no_jobs_today": "Hoje não há tarefas - aproveita o dia!", + "sticker_chart.state.done": "feito", + "sticker_chart.state.partial": "começado", + "sticker_chart.state.pending": "à espera de um adulto", + "sticker_chart.state.todo": "por fazer", + "sticker_chart.not_allowed": "Este tablet não pode marcar as tarefas de {name}. Um adulto pode resolver no TaskMate: associar {name} à conta que este tablet usa, ou adicionar essa conta como pai/mãe.", + "sticker_chart.undo_denied": "Só um adulto pode retirar um autocolante.", + "sticker_chart.undone": "Autocolante retirado.", "visibility.description_hidden": "Ocultado por condição de visibilidade", "visibility.description_shown": "Mostrado por condição de visibilidade", "visibility.label_entity": "Entidade de Visibilidade", @@ -2087,6 +2138,16 @@ "panel.adjust_custom_title": "Ajustar os pontos de {child} com um valor personalizado", "panel.adjust_dialog_title": "Ajustar pontos — {name}", "panel.adjust_done": "{sign}{amount} {points} para {child}", + "panel.backdate_button": "Registar uma tarefa passada", + "panel.backdate_title": "Registar uma tarefa passada para {name}", + "panel.backdate_hint": "Escolhe o dia e toca nas tarefas que foram feitas, tal como no quadro de autocolantes. Contam para esse dia e essa semana, e ficam logo aprovadas. Mantém um autocolante premido para o retirar.", + "panel.backdate_hint_list": "Para uma tarefa feita num dia anterior que ninguém marcou. Conta para esse dia e essa semana, e fica logo aprovada.", + "panel.backdate_finish": "Concluído", + "panel.backdate_day": "Dia em que foi feita", + "panel.backdate_chore": "Tarefa", + "panel.backdate_none": "Não havia tarefas para {name} nesse dia.", + "panel.backdate_save": "Registar", + "panel.backdate_done": "{chore} registada para {name} em {day}", "panel.adjust_err_amount": "Introduza um valor entre 1 e 10000.", "panel.adjust_reason": "Motivo (opcional)", "panel.adjust_remove": "Retirar", diff --git a/custom_components/taskmate/www/taskmate-panel.js b/custom_components/taskmate/www/taskmate-panel.js index 1767b184..6df380e2 100644 --- a/custom_components/taskmate/www/taskmate-panel.js +++ b/custom_components/taskmate/www/taskmate-panel.js @@ -292,6 +292,7 @@ class TaskMatePanel extends HTMLElement { // to lean on) and our shell was stripped out in the meantime. if (!this._rendered || !this.querySelector(".tm-shell")) this._render(); this._bindHaPickers(false); + if (this._backdateCard) this._backdateCard.hass = value; } get hass() { return this._hass; } set narrow(_v) {} @@ -646,6 +647,9 @@ class TaskMatePanel extends HTMLElement { if (act === "save-gift") { this._doGiftPoints(); return; } if (act === "adjust-points") { this._doAdjustPoints(t.dataset.id, Number(t.dataset.delta)); return; } if (act === "adjust-points-custom") { this._openAdjustDialog(t.dataset.id); return; } + if (act === "backdate-open") { this._openBackdateDialog(t.dataset.id); return; } + if (act === "save-backdate") { this._saveBackdateDialog(); return; } + if (act === "close-backdate") { this._closeDialog(true); this._fetchState(); return; } if (act === "sound-preview-field") { const sel = t.closest(".tm-sound-row")?.querySelector('select[data-field="completion_sound"]'); if (sel) this._previewSound(sel.value); @@ -3575,6 +3579,7 @@ class TaskMatePanel extends HTMLElement { this._zoneCache = {}; this._applyDesign(); this._bindHaPickers(); + this._mountBackdateCard(); if (this._palette) this._focusPalette(); return; } @@ -3635,6 +3640,7 @@ class TaskMatePanel extends HTMLElement { } this._applyDesign(); + this._mountBackdateCard(); } // A dialog plays its opening animation only when it first appears (#974). @@ -4510,6 +4516,7 @@ class TaskMatePanel extends HTMLElement {
${this._esc(this._t("panel.backdate_hint"))}
`, + this._select(this._t("panel.backdate_day"), "day", day.v, days, "", true), + this._backdateCardReady ? `` : `…
`, + ].join(""), + `` + ); + } + // The plain list needs the day described too; fetched once per day. + if ((!this._backdateInfo || this._backdateInfo.day !== day.v) && this._backdateInfoPending !== day.v) { + setTimeout(() => this._refreshBackdateDay(), 0); + } + const chores = this._backdateChores(d.child_id, day.dow, day.v); + if (!chores.some(c => c.id === d.chore_id)) d.chore_id = chores[0] ? chores[0].id : ""; + return this._dialogShell( + this._t("panel.backdate_title", { name }), + [ + `${this._esc(this._t("panel.backdate_hint_list"))}
`, + this._select(this._t("panel.backdate_day"), "day", day.v, days, "", true), + chores.length + ? this._select(this._t("panel.backdate_chore"), "chore_id", d.chore_id, + chores.map(c => ({ v: c.id, l: `${c.name} (${c.points})` }))) + : `${this._esc(this._t("panel.backdate_none", { name }))}
`, + ].join(""), + ` + ` + ); + } + + async _saveBackdateDialog() { + const d = this._dialog.data; + if (!d.child_id || !d.chore_id || !d.day || this._backdateBusy) return; + this._backdateBusy = true; + try { + const { ok, err } = await this._callService("complete_chore", { + chore_id: d.chore_id, child_id: d.child_id, as_parent: true, completed_date: d.day, + }); + if (!ok) { this._showToast("err", this._t("panel.toast_save_failed", { error: err })); return; } + const child = (this._state.children || []).find(c => c.id === d.child_id) || {}; + const chore = (this._state.chores || []).find(c => c.id === d.chore_id) || {}; + const day = this._backdateDays().find(x => x.v === d.day); + this._closeDialog(true); + await this._fetchState(); + this._showToast("ok", this._t("panel.backdate_done", { + chore: chore.name || "", name: child.name || "", day: day ? day.l : d.day, + })); + } finally { + this._backdateBusy = false; + } + } + _openSwapDialog(choreId) { const c = (this._state.chores || []).find(x => x.id === choreId); if (!c) return; @@ -9736,6 +9979,7 @@ class TaskMatePanel extends HTMLElement { if (this._dialog.kind === "swap") return this._renderSwapDialog(); if (this._dialog.kind === "gift") return this._renderGiftDialog(); if (this._dialog.kind === "adjust") return this._renderAdjustDialog(); + if (this._dialog.kind === "backdate") return this._renderBackdateDialog(); if (this._dialog.kind === "child") return this._renderChildDialog(); if (this._dialog.kind === "chore") return this._renderChoreDialog(); if (this._dialog.kind === "reward") return this._renderRewardDialog(); diff --git a/custom_components/taskmate/www/taskmate-sticker-chart-card.js b/custom_components/taskmate/www/taskmate-sticker-chart-card.js new file mode 100644 index 00000000..4769cad8 --- /dev/null +++ b/custom_components/taskmate/www/taskmate-sticker-chart-card.js @@ -0,0 +1,1267 @@ +/** + * TaskMate Sticker Chart Card + * + * The fridge-door sticker chart as a whole dashboard view, driven entirely by + * TaskMate. Parents keep managing chores, points and rewards in the TaskMate + * panel; this card is only the child's side of it: + * + * summary "Today: 3 / 7 jobs done", a sticker per point earned, the + * child's photo, and blocks filling towards the chosen reward + * tiles one sticker per chore due today — tap to complete (through the + * same button entity / service TaskMate's child card uses), hold + * to undo (TaskMate only lets a parent do that) + * goal the reward being saved for, and a claim button once it's earned; + * with `goal: week` the stickers and blocks count this week's + * jobs instead (stars since Monday out of all the week offers) + * + * One column per child, side by side, or a single child with `child_id`. + * + * Data comes from the TaskMate sensors through window.__taskmate_attrs, so + * nothing here needs keeping in step by hand: + * points / photo → overview sensor children[] (photo = the child's picture entity) + * chores → chores sensor, filtered to today for each child + * done / pending → today's completions (approved vs awaiting approval) + * goal → a reward's cost (per-child cost when one is set) + */ + +// HA's Lit base class, found through an element built on it: the class that +// owns the html tag helper is LitElement itself. Dashboards always define hui-masonry-view; +// the TaskMate admin panel also shows this card (to log past days) and has no +// dashboard views, so fall back to elements every HA page defines. +const LIT_HOST_TAGS = ["hui-masonry-view", "hui-view", "home-assistant-main", "ha-card"]; + +const findLitBase = () => { + for (const tag of LIT_HOST_TAGS) { + let C = customElements.get(tag); + while (C && C.prototype && !Object.prototype.hasOwnProperty.call(C.prototype, "html")) { + C = Object.getPrototypeOf(C); + } + if (C && C.prototype) return C; + } + return null; +}; + +// A slow tablet can evaluate this module before HA has registered any element +// of its own. Failing here would abort the module, so the card would never be +// defined for that page load ("Custom element doesn't exist") — a race a fast +// desktop wins and a Fire tablet can lose. Wait for one to appear instead. +let litBase = findLitBase(); +if (!litBase) { + await Promise.race([ + ...LIT_HOST_TAGS.map(tag => customElements.whenDefined(tag)), + new Promise(resolve => setTimeout(resolve, 15000)), + ]); + litBase = findLitBase(); +} +if (!litBase) throw new Error("TaskMate Sticker Chart: Home Assistant's Lit base class was not found"); +const LitElement = litBase; + +const html = LitElement.prototype.html; +const css = LitElement.prototype.css; + +// Keeps number runs like "3 / 10" reading left to right in RTL text (#995); +// identity until the design layer has loaded. +const _ltrNums = (s) => (window.__taskmate_design && window.__taskmate_design.ltrNums ? window.__taskmate_design.ltrNums(s) : s); + +// The sticker alphabet, cycled as points accumulate so a long row stays +// interesting instead of being forty identical stars. +const STICKERS = [ + "⭐", "🌟", "🦄", "🌈", "🐶", "🍭", "🚀", "🦖", + "🐱", "🍓", "🎈", "🦋", "🐬", "🍦", "🎨", "🐸", +]; + +// Tile colours, assigned by position so a chart keeps its look day to day. +const GRADIENTS = [ + "linear-gradient(135deg,#4facfe,#00f2fe)", + "linear-gradient(135deg,#a18cd1,#fbc2eb)", + "linear-gradient(135deg,#f6d365,#fda085)", + "linear-gradient(135deg,#84fab0,#8fd3f4)", + "linear-gradient(135deg,#a8e063,#56ab2f)", + "linear-gradient(135deg,#ff9a9e,#fad0c4)", + "linear-gradient(135deg,#e0c3fc,#8ec5fc)", + "linear-gradient(135deg,#fccb90,#d57eeb)", + "linear-gradient(135deg,#96e6a1,#d4fc79)", + "linear-gradient(135deg,#fbc2eb,#a6c1ee)", +]; + +// Keyword → sticker for chores whose name carries no emoji of its own. +// Starting a chore's name with an emoji in TaskMate always wins over this. +const CHORE_EMOJI = [ + [/(teeth|tooth|brush)/, "🦷"], [/(toilet|potty|wee|poo)/, "🚽"], + [/(pants|knickers)/, "🩲"], [/(pyjama|pajama|jammies)/, "🌙"], + [/(shoe|trainer)/, "👟"], [/(coat|jacket)/, "🧥"], + [/(dress|clothes|uniform)/, "👚"], [/(bed)/, "🛏️"], + [/(breakfast|lunch|dinner|eat)/, "🥣"], [/(bag)/, "🎒"], + [/(story)/, "📖"], [/(read|book|homework|study|spelling)/, "📚"], + [/(hand)/, "🧼"], [/(shower|bath)/, "🛁"], + [/(kind|shar|nice|love)/, "💖"], [/(please|thank|manner)/, "🙏"], + [/(hoover|vacuum)/, "🧹"], [/(tidy|clean|sweep)/, "🧸"], + [/(laundry|washing|fold)/, "🧺"], [/(bin|rubbish|trash|recycl)/, "🗑️"], + [/(dish|plate|table)/, "🍽️"], [/(dog|cat|pet|feed)/, "🐶"], + [/(plant|water|garden)/, "🪴"], [/(music|piano|practi)/, "🎵"], + [/(run|exercise|sport|swim)/, "🏃"], +]; + +// Emoji at the very start of a name: "🦷 Brush teeth" → ["🦷", "Brush teeth"]. +const LEADING_EMOJI = /^(\p{Extended_Pictographic}(?:\u{FE0F}|\u{200D}\p{Extended_Pictographic}|[\u{1F3FB}-\u{1F3FF}])*)\s*/u; + +const PERIOD_ORDER = { morning: 0, afternoon: 1, evening: 2, anytime: 3 }; + +// A 500-point reward would otherwise paint 500 emoji into the page; past +// these the row is summarised and each block stands for several stars. 40 stickers +// is about three rows, which keeps the jobs on screen on a tablet. +const MAX_STICKERS = 40; +const MAX_BLOCKS = 60; + +const HOLD_MS = 600; // press-and-hold that means "undo" +const OPTIMISTIC_MS = 20000; +const CONFIRM_MS = 4000; // second tap on the claim button must land in this window + +class TaskMateStickerChartCard extends LitElement { + static get properties() { + return { + hass: { type: Object }, + config: { type: Object }, + // A past day's completions, when `config.date` is set (the admin + // panel's "Log a past job"); today's come from the sensors instead. + dayCompletions: { type: Array }, + // That day's week, per child and weekly-target chore (count so far). + dayWeekProgress: { type: Object }, + // The chores TaskMate would let be logged that day, per child: the + // backend's own rules, so every schedule (recurrences, calendar moves, + // holidays) is followed without re-implementing it here. + dayLoggable: { type: Object }, + _optimistic: { state: true }, + _confirming: { state: true }, + _celebrating: { state: true }, + _confetti: { state: true }, + }; + } + + constructor() { + super(); + this._optimistic = {}; // "child:chore" -> { childId, choreId, base, count, at } + this._confirming = ""; // child id whose claim button is armed + this._celebrating = null; // { day: bool, name, points } while the pop-up shows + this._confetti = []; + this._busy = new Set(); + this._holdTimer = null; + this._held = false; + } + + shouldUpdate(changedProps) { + if (changedProps.has("hass")) { + return window.__taskmate_hasChanged + ? window.__taskmate_hasChanged(changedProps.get("hass"), this.hass, this.config?.entity) + : true; + } + return true; + } + + /** + * Drop an optimistic tick once Home Assistant has caught up with it. + * + * It must go the moment the real completion lands, not merely be ignored: + * a stale entry would resurrect the "done" look as soon as the count dropped + * again — after an undo, or a parent rejecting it in the panel. + */ + willUpdate(changedProps) { + if (!(changedProps.has("hass") || changedProps.has("dayCompletions"))) return; + if (!Object.keys(this._optimistic).length) return; + const attrs = this._attrs(); + if (!attrs) return; + const completions = this._completions(attrs); + const chores = new Map((attrs.chores || []).map(c => [c.id, c])); + const next = {}; + for (const [key, opt] of Object.entries(this._optimistic)) { + const chore = chores.get(opt.choreId); + const landed = chore ? this._mine(opt.childId, chore, completions).length : Infinity; + if (landed < opt.base + opt.count && Date.now() - opt.at < OPTIMISTIC_MS) next[key] = opt; + } + this._optimistic = next; + } + + /** + * This child's completions of a chore today. "__parent__" is a parent + * dismissing it for the whole pool, so it counts for whoever is looking — + * except first-come, which is a race the child didn't win. + */ + _mine(childId, chore, completions) { + const firstCome = (chore.assignment_mode || "everyone") === "first_come"; + return completions.filter(c => c.chore_id === chore.id + && (String(c.child_id) === String(childId) || (c.child_id === "__parent__" && !firstCome))); + } + + _t(key, params) { + const fn = window.__taskmate_localize; + return fn ? fn(this.hass, key, params) : key; + } + + setConfig(config) { + if (!config.entity) throw new Error(this._t("sticker_chart.error.entity_required")); + this.config = { ...config }; + } + + static getStubConfig() { + return { entity: "sensor.taskmate_overview" }; + } + + static getConfigElement() { return document.createElement("taskmate-sticker-chart-card-editor"); } + + getCardSize() { return 12; } + + static get styles() { + const base = css` + :host { display: block; } + ha-card { + background: none; box-shadow: none; border: none; overflow: visible; + --panel-bg: var(--ha-card-background, var(--card-background-color, rgba(0,0,0,0.35))); + --panel-radius: var(--ha-card-border-radius, 22px); + } + + .cols { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 16px; + align-items: start; + } + .col { display: flex; flex-direction: column; gap: 10px; min-width: 0; } + + .panel { + background: var(--panel-bg); + border-radius: var(--panel-radius); + box-shadow: var(--ha-card-box-shadow, none); + border: var(--ha-card-border-width, 0) solid var(--ha-card-border-color, transparent); + backdrop-filter: var(--ha-card-backdrop-filter, none); + -webkit-backdrop-filter: var(--ha-card-backdrop-filter, none); + } + + /* -- Heading -- */ + .heading { + display: flex; align-items: center; gap: 8px; + padding: 2px 4px 0; + color: var(--primary-text-color); + font-size: 1.05rem; font-weight: 500; + } + .heading ha-icon { --mdc-icon-size: 20px; } + .heading .title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .score { + display: inline-flex; align-items: center; gap: 4px; + font-size: 0.9rem; font-weight: 600; color: var(--secondary-text-color); + } + .score ha-icon { --mdc-icon-size: 16px; color: var(--tone, #3498db); } + + /* -- Summary -- */ + .summary { padding: 16px; } + .top-row { display: flex; align-items: flex-start; gap: 14px; } + .top-main { flex: 1; min-width: 0; } + .today { + font-size: 1rem; font-weight: 700; + color: var(--primary-text-color); margin-bottom: 10px; + } + .face { + width: 112px; height: 112px; min-width: 112px; + border-radius: 50%; overflow: hidden; + display: flex; align-items: center; justify-content: center; + background: rgba(127,127,127,0.18); + flex: none; + } + .face ha-icon { --mdc-icon-size: 60px; color: var(--primary-color); } + .stickers { + font-size: 26px; line-height: 1.3; + word-break: break-all; + } + .stickers-none, .muted { + font-size: 0.92rem; font-weight: 600; color: var(--secondary-text-color); + } + .overflow { font-size: 0.85rem; font-weight: 800; color: var(--secondary-text-color); margin-inline-start: 4px; } + .goal-line { + margin-top: 14px; + font-size: 0.92rem; font-weight: 700; color: var(--primary-text-color); + } + .blocks { display: flex; flex-wrap: wrap; gap: 3px; margin-top: 6px; } + .blocks i { + width: 16px; height: 16px; border-radius: 3px; + background: #d9d9d9; box-shadow: inset 0 -2px 0 rgba(0,0,0,0.12); + } + .blocks i.on { background: #36b43a; box-shadow: inset 0 -2px 0 rgba(0,0,0,0.22); } + .blocks-key { margin-top: 4px; font-size: 0.75rem; font-weight: 600; color: var(--secondary-text-color); } + .reached { + margin-top: 12px; font-size: 1.1rem; font-weight: 800; + color: var(--primary-text-color); + } + + /* -- Tiles -- */ + .tiles { + display: grid; + grid-template-columns: repeat(var(--cols, 4), minmax(0, 1fr)); + gap: 8px; + } + .tile { + position: relative; + aspect-ratio: 1 / 1.05; + border-radius: 22px; + border: 3px dashed rgba(255,255,255,0.28); + background: rgba(255,255,255,0.07); + display: flex; flex-direction: column; align-items: center; justify-content: center; + gap: 6px; padding: 8px 4px; + color: var(--secondary-text-color); + font: inherit; cursor: pointer; + transition: transform 0.25s ease, box-shadow 0.25s ease, background 0.25s ease; + -webkit-tap-highlight-color: transparent; + user-select: none; -webkit-user-select: none; + touch-action: manipulation; + min-width: 0; + } + /* Older tablet browsers (some Fire OS builds) have no aspect-ratio, which + would leave a tile only as tall as its label. Keep it tappable. */ + @supports not (aspect-ratio: 1 / 1) { + .tile { min-height: 96px; } + } + .tile:focus-visible { outline: 3px solid var(--primary-color); outline-offset: 2px; } + .tile:active { transform: scale(0.96); } + .glyph { + font-size: 36px; line-height: 1; + filter: grayscale(1) opacity(0.5); + } + .glyph img { + width: 58px; height: 58px; min-width: 0; min-height: 0; + object-fit: cover; border-radius: 12px; display: block; + } + .label { + font-size: 12px; font-weight: 700; line-height: 1.2; + text-align: center; padding: 0 2px; + display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; + overflow: hidden; overflow-wrap: anywhere; + } + .tile.done { + background: var(--grad); + border: 3px solid rgba(255,255,255,0.95); + transform: rotate(-2deg) scale(1.03); + box-shadow: 0 8px 20px rgba(0,0,0,0.35); + color: #fff; + } + .tile.done:active { transform: rotate(-2deg) scale(0.99); } + .tile.done .glyph { filter: none; animation: pop 0.45s cubic-bezier(.2,1.6,.4,1); } + .tile.done .label { text-shadow: 0 1px 2px rgba(0,0,0,0.45); } + .tile.partial { + border-color: rgba(255,193,7,0.85); + background: linear-gradient(90deg, rgba(255,193,7,0.32) var(--pct, 0%), rgba(255,255,255,0.07) var(--pct, 0%)); + } + .tile.partial .glyph { filter: grayscale(0.3) opacity(0.9); } + .tile.pending { border-style: solid; border-color: rgba(255,193,7,0.85); background: rgba(255,193,7,0.14); } + .tile.pending .glyph { filter: grayscale(0.2) opacity(0.85); } + .count, .flag { + position: absolute; top: 6px; inset-inline-end: 8px; + font-size: 11px; font-weight: 800; padding: 2px 7px; border-radius: 999px; + background: rgba(0,0,0,0.35); color: #fff; + } + .flag { inset-inline-end: auto; inset-inline-start: 8px; } + .tile.done .count { background: rgba(255,255,255,0.9); color: #111; } + .none-today { padding: 22px 16px; text-align: center; } + + /* -- Goal + claim -- */ + .goal-tile { + display: flex; align-items: center; gap: 14px; + padding: 16px 18px; width: 50%; box-sizing: border-box; + } + .goal-tile ha-icon { --mdc-icon-size: 26px; color: #f1c40f; } + .goal-tile .gt-title { font-weight: 600; color: var(--primary-text-color); } + .goal-tile .gt-sub { font-size: 0.8rem; color: var(--secondary-text-color); } + .claim { + display: flex; flex-direction: column; align-items: center; justify-content: center; + gap: 8px; padding: 18px 16px; width: 100%; box-sizing: border-box; + font: inherit; font-weight: 600; color: var(--primary-text-color); + cursor: pointer; border: none; + } + .claim ha-icon { --mdc-icon-size: 44px; } + .claim[disabled] { cursor: default; opacity: 0.6; } + .claim.armed { outline: 3px solid #f1c40f; outline-offset: -3px; } + + .error { + display: flex; flex-direction: column; align-items: center; gap: 8px; + padding: 28px 16px; text-align: center; color: var(--error-color, #f44336); + } + .error ha-icon { --mdc-icon-size: 40px; opacity: 0.6; } + + /* -- Celebration: the TaskMate child card's own pop-up and confetti -- */ + .celebration-overlay { + position: fixed; inset: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; align-items: center; justify-content: center; + z-index: 9999; + animation: fade-in 0.3s ease; + } + .celebration-content { + background: var(--card-background-color, #fff); + border-radius: 30px; + padding: 40px 50px; + text-align: center; + animation: pop-in 0.5s cubic-bezier(0.68, -0.55, 0.265, 1.55); + position: relative; overflow: hidden; + max-width: calc(100vw - 40px); box-sizing: border-box; + } + .celebration-content.day { padding: 48px 56px; } + .celebration-stars { + font-size: 4rem; margin-bottom: 16px; + animation: star-bounce 0.6s ease infinite; + } + .celebration-title { font-size: 2.5rem; font-weight: bold; color: #9b59b6; margin-bottom: 8px; } + .celebration-content.day .celebration-title { font-size: 2.8rem; } + .celebration-message { font-size: 1.3rem; color: var(--secondary-text-color); margin-bottom: 16px; } + .celebration-points { + font-size: 1.8rem; font-weight: bold; color: #e67e22; + display: flex; align-items: center; justify-content: center; gap: 8px; + } + .celebration-points ha-icon { --mdc-icon-size: 28px; color: #f1c40f; } + .confetti-container { + position: fixed; inset: 0; + pointer-events: none; z-index: 10000; overflow: hidden; + } + .confetti { position: absolute; top: 0; width: 10px; height: 10px; animation: confetti-fall 3s linear forwards; } + @keyframes fade-in { from { opacity: 0; } to { opacity: 1; } } + @keyframes pop-in { from { transform: scale(0); opacity: 0; } to { transform: scale(1); opacity: 1; } } + @keyframes star-bounce { + 0%, 100% { transform: scale(1) rotate(0deg); } + 50% { transform: scale(1.2) rotate(10deg); } + } + @keyframes confetti-fall { + 0% { transform: translateY(-100px) rotate(0deg); opacity: 1; } + 100% { transform: translateY(100vh) rotate(720deg); opacity: 0; } + } + @media (max-width: 420px) { + .celebration-content { padding: 28px 24px; } + .celebration-title { font-size: 1.9rem; } + } + + @keyframes pop { + 0% { transform: scale(.4) rotate(-20deg); } + 70% { transform: scale(1.35) rotate(6deg); } + 100% { transform: scale(1) rotate(0); } + } + @media (prefers-reduced-motion: reduce) { + .tile, .tile.done .glyph { animation: none; transition: none; } + /* The pop-up still says well done; it just doesn't bounce or rain. */ + .celebration-overlay, .celebration-content, .celebration-stars { animation: none; } + .confetti-container { display: none; } + } + @media (max-width: 420px) { + .face { width: 76px; height: 76px; min-width: 76px; } + .face ha-icon { --mdc-icon-size: 42px; } + .stickers { font-size: 21px; } + .glyph { font-size: 30px; } + .goal-tile { width: 100%; } + } + `; + const tokens = window.__taskmate_design && window.__taskmate_design.styles + ? window.__taskmate_design.styles() : null; + return tokens ? [tokens, base] : base; + } + + // -- Data -------------------------------------------------------------------- + + _attrs() { + const entity = this.hass && this.hass.states[this.config.entity]; + if (!entity) return null; + return (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) + || entity.attributes || {}; + } + + _children(attrs) { + const all = attrs.children || []; + const wanted = this.config.child_id; + if (!wanted) return all; + return all.filter(c => c.id === wanted || c.name === wanted); + } + + /** Per-child cost when the reward has one, else its list price. */ + _cost(reward, child) { + const perChild = reward.calculated_costs || {}; + const value = perChild[child.id] !== undefined ? perChild[child.id] : reward.cost; + return Math.max(0, Number(value) || 0); + } + + /** + * The reward being saved up for. + * + * A configured reward wins. Otherwise the cheapest reward the child cannot + * afford yet, so there is always something to aim at; if they can afford + * everything, the dearest, so the chart reads full. + */ + _reward(rewards, child) { + const list = (rewards || []).filter(r => { + const assigned = r.assigned_to || []; + return assigned.length === 0 || assigned.includes(child.id); + }); + if (this.config.reward_id) return list.find(r => r.id === this.config.reward_id) || null; + const claimable = list.filter(r => r.is_available !== false); + if (!claimable.length) return null; + const points = Math.max(0, Number(child.points) || 0); + const ahead = claimable.filter(r => this._cost(r, child) > points); + return ahead.length + ? ahead.reduce((a, b) => (this._cost(b, child) < this._cost(a, child) ? b : a)) + : claimable.reduce((a, b) => (this._cost(b, child) > this._cost(a, child) ? b : a)); + } + + /** + * Today's chores for one child, each with its completion state. + * + * Mirrors the TaskMate child card with every period shown at once — the + * sticker chart is the whole day on one screen: + * - enabled, not disabled for this child, assigned to them (or everyone) + * - rotation chores only for today's active child; first-come for the pool + * - specific-days chores only on their days (the backend leaves that to cards) + * - the backend's availability matrix for everything else (vacation, + * recurrence windows, visibility and weather gates) + * A chore already done today stays on the chart even when the matrix has + * since switched it off, so a finished sticker never vanishes. + */ + // -- Past-day mode (`config.date`, used by the admin panel) -------------------- + + /** The past day being shown, as a local Date at midday, or null for today. */ + _pastDay() { + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(this.config?.date || ""); + return m ? new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), 12) : null; + } + + /** The completions the tiles are drawn from: the chosen day's, or today's. */ + _completions(attrs) { + const list = this._pastDay() ? (this.dayCompletions || []) : (attrs.todays_completions || []); + return list.filter(c => !c.bonus_subtask_id); + } + + _todaysChores(child, attrs) { + const childId = String(child.id); + const past = this._pastDay(); + const todayDow = past + ? past.toLocaleDateString("en-US", { weekday: "long" }).toLowerCase() + : attrs.today_day_of_week || new Date().toLocaleDateString("en-US", { weekday: "long" }).toLowerCase(); + // The availability matrix describes today only, so a past day skips it. + const availability = past ? {} : (attrs.chore_availability || {}); + const completions = this._completions(attrs); + const order = (child.chore_order || []).map(String); + + // A past day the backend has described: only what it would accept, plus + // anything already ticked that day so it can still be undone. + const loggable = past && this.dayLoggable ? (this.dayLoggable[childId] || []).map(String) : null; + + const rows = []; + for (const chore of attrs.chores || []) { + const mine = this._mine(childId, chore, completions); + if (loggable && !loggable.includes(String(chore.id)) && mine.length === 0) continue; + if (chore.enabled === false) continue; + // A one-off job is switched off for the child once it's done; its tile + // stays while it has a tick on this day, so it can still be undone. + if ((chore.disabled_for || []).map(String).includes(childId) && mine.length === 0) continue; + const assigned = (Array.isArray(chore.assigned_to) ? chore.assigned_to : []).map(String); + if (assigned.length && !assigned.includes(childId)) continue; + + // An open-ended chore is a placeholder for unlisted work: the child's note + // is the job, and a tile has nowhere to take it, so it stays on the child + // card. (A completion made there still counts in the totals.) + if (chore.open_ended) continue; + + const mode = chore.assignment_mode || "everyone"; + // Whose turn a rotation chore was, or who made up a team, can't be + // reconstructed for a past day, and TaskMate refuses to log one, so + // it isn't offered. + if (past && (mode !== "everyone" || chore.team)) continue; + if (mode !== "everyone" && mode !== "first_come") { + const active = chore.assignment_current_child_id ? String(chore.assignment_current_child_id) : ""; + if (active !== childId) continue; + } + + if (!loggable && chore.schedule_mode === "specific_days") { + const days = Array.isArray(chore.due_days) ? chore.due_days : []; + if (days.length && !days.includes(todayDow) && mine.length === 0) continue; + } + + const perChild = availability[chore.id]; + if (perChild && perChild[childId] === false && mine.length === 0) continue; + + let limit = mode === "first_come" ? 1 : Math.max(1, Number(chore.daily_limit) || 1); + // Weekly target (#883): the quota caps it a level above the day. Once the + // other days have filled it there is nothing left to tick, so the row + // goes; a tick made on this day that filled it stays, so it can still be + // undone. A past day reads the progress of that day's week instead. + const weeklyTarget = Number(chore.weekly_target) || 0; + if (weeklyTarget > 0) { + const weekProgress = past ? (this.dayWeekProgress || {})[childId] : child.weekly_chore_progress; + const left = Math.max(0, weeklyTarget - Number((weekProgress || {})[chore.id] || 0)); + if (mine.length === 0 && left === 0) continue; + limit = Math.min(limit, mine.length + left); + } + const approved = mine.filter(c => c.approved).length; + + const key = `${childId}:${chore.id}`; + const opt = this._optimistic[key]; + const extra = opt && mine.length < opt.base + opt.count && Date.now() - opt.at < OPTIMISTIC_MS + ? opt.base + opt.count - mine.length : 0; + const total = Math.min(limit, mine.length + extra); + + let state = "todo"; + if (approved >= limit) state = "done"; + else if (total >= limit) { + // Optimistic ticks only count as done where no grown-up has to approve; + // TaskMate omits requires_approval from the payload when it is true. + state = extra > 0 && approved + extra >= limit && (past || chore.requires_approval === false) ? "done" : "pending"; + } + else if (total > 0) state = "partial"; + + rows.push({ chore, key, limit, total, approved, state, mine }); + } + + const pos = (r) => { + const i = order.indexOf(String(r.chore.id)); + return i === -1 ? Number.MAX_SAFE_INTEGER : i; + }; + rows.sort((a, b) => + pos(a) - pos(b) + || (PERIOD_ORDER[a.chore.time_category] ?? 9) - (PERIOD_ORDER[b.chore.time_category] ?? 9) + || String(a.chore.name || "").localeCompare(String(b.chore.name || ""))); + return rows; + } + + _glyph(chore) { + const name = String(chore.name || "").trim(); + const lead = name.match(LEADING_EMOJI); + const label = lead ? name.slice(lead[0].length) || name : name; + if (chore.image_url) { + return { label, glyph: html`