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_assignments.py b/custom_components/taskmate/coord_assignments.py index fe304e78..893289db 100644 --- a/custom_components/taskmate/coord_assignments.py +++ b/custom_components/taskmate/coord_assignments.py @@ -53,6 +53,14 @@ def _refresh_tracked_availability_entities(self) -> None: visibility.add(chore.weather_entity) self._tracked_visibility_entities = visibility + # A child's photo is read from their picture entity, so a photo changed + # there has to reach the sensors without waiting for a TaskMate edit. + # Trimmed the same way child_avatar_image reads it, so a stray space in + # a stored id can't leave the photo untracked. + self._tracked_picture_entities = { + entity for c in self.storage.get_children() if (entity := (getattr(c, "picture_entity", "") or "").strip()) + } + @callback def _availability_state_changed(self, event: Any) -> None: """Cheap bus-filter: react only when a tracked entity changes. @@ -66,6 +74,9 @@ def _availability_state_changed(self, event: Any) -> None: if not entity_id: return + if entity_id in getattr(self, "_tracked_picture_entities", set()): + self._picture_entity_changed(data) + is_availability = entity_id in getattr(self, "_tracked_availability_entities", set()) is_visibility = entity_id in getattr(self, "_tracked_visibility_entities", set()) if not (is_availability or is_visibility): @@ -79,6 +90,22 @@ def _availability_state_changed(self, event: Any) -> None: if is_availability: self.hass.async_create_task(self._async_reevaluate_availability()) + def _picture_entity_changed(self, data: dict) -> None: + """Push a child's new photo out as soon as the entity's picture changes. + + A person's state flips between home and away all day without touching + its picture, so only a real change of ``entity_picture`` (or the entity + appearing or vanishing) is worth invalidating the sensors for. + """ + + def picture(state: Any) -> str: + return str((getattr(state, "attributes", None) or {}).get("entity_picture") or "") if state else "" + + if picture(data.get("old_state")) == picture(data.get("new_state")): + return + self.external_state_version += 1 + self.async_update_listeners() + async def _async_reevaluate_availability(self) -> None: """Re-run assignment for require_availability chores when availability flips. diff --git a/custom_components/taskmate/coord_avatars.py b/custom_components/taskmate/coord_avatars.py index ff0be77b..589d09e7 100644 --- a/custom_components/taskmate/coord_avatars.py +++ b/custom_components/taskmate/coord_avatars.py @@ -10,6 +10,10 @@ import logging +from homeassistant.helpers import entity_registry as er + +from .const import DOMAIN + _LOGGER = logging.getLogger(__name__) # Shipped defaults so the feature is useful out of the box. Parents can replace @@ -36,6 +40,31 @@ def avatar_catalog(self) -> list[dict]: return [a for a in raw if isinstance(a, dict) and a.get("icon")] return list(DEFAULT_AVATAR_CATALOG) + def child_avatar_image(self, child) -> str: + """The picture of the entity chosen for a child's face, or ``""``. + + Read from that entity on every update rather than stored, so a photo + changed in HA appears here with nothing to re-save, and a deleted + entity simply falls back to the MDI avatar instead of a broken image. + + Empty when no entity is chosen, it no longer exists, or it has no + picture — every call site then shows the MDI avatar, so a child is + never left with a blank face. + + TaskMate's own child sensors are refused: they show this very photo, + so choosing one would make the face its own source and leave it stale. + """ + entity_id = (getattr(child, "picture_entity", "") or "").strip() + if not entity_id: + return "" + entry = er.async_get(self.hass).async_get(entity_id) + if entry is not None and entry.platform == DOMAIN: + return "" + state = self.hass.states.get(entity_id) + if state is None: + return "" + return str(state.attributes.get("entity_picture") or "") + def _avatar_unlocked(self, entry: dict, child) -> bool: kind = entry.get("unlock_type", "free") try: 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/coordinator.py b/custom_components/taskmate/coordinator.py index b9deff90..616b4049 100644 --- a/custom_components/taskmate/coordinator.py +++ b/custom_components/taskmate/coordinator.py @@ -986,6 +986,7 @@ async def async_add_child( unavailability_entity: str = "", pause_streak_when_unavailable: bool = False, linked_user_id: str = "", + picture_entity: str = "", birthday: str = "", presence_entity: str = "", age_group: str = "", @@ -1004,6 +1005,7 @@ async def async_add_child( unavailability_entity=unavailability_entity, pause_streak_when_unavailable=pause_streak_when_unavailable, linked_user_id=linked_user_id, + picture_entity=picture_entity, birthday=birthday, presence_entity=presence_entity, age_group=age_group, 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 b620ccb9..13ae9d26 100644 --- a/custom_components/taskmate/models.py +++ b/custom_components/taskmate/models.py @@ -235,6 +235,12 @@ class Child: total_penalties_received: int = 0 notify_service: str | None = None linked_user_id: str = "" # HA user id; when set, only that user (or an admin) may self-serve as this child + # Entity whose picture replaces the MDI avatar — normally the child's + # person.* entity. Deliberately independent of linked_user_id: a young + # child often has a person (with a photo) but no HA login of their own. + # Only the entity id is stored; the picture is read from it on every + # update, so a new photo set in HA shows up with nothing to re-save here. + picture_entity: str = "" quiet_hours_start: str = "" # "HH:MM" — start of do-not-disturb window; empty = no quiet hours quiet_hours_end: str = "" # "HH:MM" — end of do-not-disturb window; start>end means overnight presence_entity: str = "" # person./device_tracker.; while not home, reminders wait for arrival (#926) @@ -282,6 +288,7 @@ def from_dict(cls, data: dict[str, Any]) -> Child: total_penalties_received=data.get("total_penalties_received", 0), notify_service=data.get("notify_service"), linked_user_id=data.get("linked_user_id", ""), + picture_entity=str(data.get("picture_entity", "") or ""), quiet_hours_start=data.get("quiet_hours_start", ""), quiet_hours_end=data.get("quiet_hours_end", ""), presence_entity=str(data.get("presence_entity", "") or ""), @@ -320,6 +327,7 @@ def to_dict(self) -> dict[str, Any]: "total_penalties_received": self.total_penalties_received, "notify_service": self.notify_service, "linked_user_id": self.linked_user_id, + "picture_entity": self.picture_entity, "quiet_hours_start": self.quiet_hours_start, "quiet_hours_end": self.quiet_hours_end, "presence_entity": self.presence_entity, @@ -1112,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: @@ -1136,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]: @@ -1162,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 40d51760..9b243e54 100644 --- a/custom_components/taskmate/sensor.py +++ b/custom_components/taskmate/sensor.py @@ -68,8 +68,11 @@ def _compute_common(coordinator: TaskMateCoordinator) -> dict: """ data = coordinator.data data_id = id(data) + # External entities TaskMate reads (a child's photo among them) are not part + # of ``data``, so their version is part of the key. + external = getattr(coordinator, "external_state_version", 0) cached = getattr(coordinator, _COMMON_CACHE_ATTR, None) - if cached and cached.get("data_id") == data_id: + if cached and cached.get("data_id") == data_id and cached.get("external") == external: return cached["common"] children = data.get("children", []) @@ -142,10 +145,15 @@ def _compute_common(coordinator: TaskMateCoordinator) -> dict: "pool_by_child_reward": pool_by_child_reward, "pool_total_by_reward": pool_total_by_reward, "total_allocated_by_child": total_allocated_by_child, + # 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), } - setattr(coordinator, _COMMON_CACHE_ATTR, {"data_id": data_id, "common": common}) + setattr(coordinator, _COMMON_CACHE_ATTR, {"data_id": data_id, "external": external, "common": common}) return common @@ -217,6 +225,10 @@ def _build_children_summary(coordinator: TaskMateCoordinator, common: dict) -> l "total_points_earned": getattr(c, "total_points_earned", 0) or 0, "total_chores_completed": getattr(c, "total_chores_completed", 0) or 0, "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), @@ -698,6 +710,9 @@ def _build_pending_reward_claims(common: dict) -> list[dict]: "reward_id": rc.reward_id, "child_id": rc.child_id, "child_name": child.name, + # No avatar_image here on purpose: the cards resolve a claim's + # face from the overview sensor's children[], and a copy per + # claim pushed this sensor past the 16KB recorder limit. "child_avatar": getattr(child, "avatar", "mdi:account-circle") or "mdi:account-circle", "reward_name": reward.name, "reward_icon": reward.icon or "mdi:gift", @@ -1575,6 +1590,7 @@ def extra_state_attributes(self) -> dict: "child_id": child.id, "child_name": child.name, "avatar": child.avatar, + "avatar_image": self.coordinator.child_avatar_image(child), "total_points_earned": child.total_points_earned, "total_chores_completed": child.total_chores_completed, "current_streak": child.current_streak, @@ -1615,6 +1631,18 @@ def icon(self) -> str: child = self.coordinator.get_child(self.child_id) return child.avatar if child else "mdi:account-circle" + @property + def entity_picture(self) -> str | None: + """The child's photo, so HA's own UI shows their face too. + + HA prefers a picture over ``icon`` when both are set, so returning + None (the default) leaves the MDI avatar above in charge. + """ + child = self.coordinator.get_child(self.child_id) + if not child: + return None + return self.coordinator.child_avatar_image(child) or None + @property def extra_state_attributes(self) -> dict: """Return additional attributes.""" @@ -1650,6 +1678,7 @@ def _included(c): "child_id": child.id, "child_name": child.name, "avatar": child.avatar, + "avatar_image": self.coordinator.child_avatar_image(child), "points": child.points, "total_points_earned": child.total_points_earned, "total_chores_completed": child.total_chores_completed, @@ -1869,6 +1898,10 @@ def extra_state_attributes(self) -> dict: earned.sort(key=lambda e: e.get("earned_at") or "", reverse=True) return { + # Who the badges belong to, so the card can put a face to them. + "child_name": child.name, + "child_avatar": getattr(child, "avatar", "mdi:account-circle") or "mdi:account-circle", + "avatar_image": self.coordinator.child_avatar_image(child), "earned": earned, "available": available, "total_badges": len(applicable), 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 c591a7b7..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" @@ -490,6 +491,7 @@ async def _ws_get_state(hass, connection, msg, coordinator): vol.Optional("unavailability_entity", default=""): str, vol.Optional("pause_streak_when_unavailable", default=False): bool, vol.Optional("linked_user_id", default=""): str, + vol.Optional("picture_entity", default=""): str, vol.Optional("birthday", default=""): vol.All(str, vol.Length(max=10)), vol.Optional("presence_entity", default=""): _validate_presence_entity, vol.Optional("age_group", default=""): vol.In(("", *AGE_GROUPS)), @@ -511,6 +513,7 @@ async def _ws_add_child(hass, connection, msg, coordinator): unavailability_entity=_opt_str(msg.get("unavailability_entity")), pause_streak_when_unavailable=bool(msg.get("pause_streak_when_unavailable", False)), linked_user_id=_opt_str(msg.get("linked_user_id")), + picture_entity=_opt_str(msg.get("picture_entity")), birthday=birthday, presence_entity=msg.get("presence_entity", ""), age_group=msg.get("age_group", ""), @@ -529,6 +532,7 @@ async def _ws_add_child(hass, connection, msg, coordinator): vol.Optional("unavailability_entity"): str, vol.Optional("pause_streak_when_unavailable"): bool, vol.Optional("linked_user_id"): str, + vol.Optional("picture_entity"): str, vol.Optional("presence_entity"): _validate_presence_entity, vol.Optional("is_guest"): bool, vol.Optional("guest_expires_on"): str, @@ -557,6 +561,8 @@ async def _ws_update_child(hass, connection, msg, coordinator): existing.pause_streak_when_unavailable = bool(msg["pause_streak_when_unavailable"]) if "linked_user_id" in msg: existing.linked_user_id = _opt_str(msg["linked_user_id"]) + if "picture_entity" in msg: + existing.picture_entity = _opt_str(msg["picture_entity"]) if "birthday" in msg: try: existing.birthday = normalize_birthday(msg["birthday"]) @@ -2410,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, @@ -3183,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 5f54fb73..760d0b74 100644 --- a/custom_components/taskmate/www/locales/da.json +++ b/custom_components/taskmate/www/locales/da.json @@ -778,6 +778,8 @@ "panel.child_inverted": "(omvendt)", "panel.child_link_user_label": "Knyt til Home Assistant-bruger (valgfrit)", "panel.child_link_user_hint": "Når det er sat, kan kun denne Home Assistant-bruger (eller en admin) fuldføre pligter, indløse belønninger eller køre timere som dette barn via service-kald. Vælg \"Alle\" til en fælles familie-tablet.", + "panel.child_picture_entity_label": "Foto fra", + "panel.child_picture_entity_hint": "Vælg barnets person for at vise deres foto i stedet for ikonet ovenfor. Fotoet indstilles under Indstillinger › Personer; uden foto vises ikonet.", "panel.child_kiosk_pin_label": "Kiosk-PIN-kode", "panel.child_kiosk_pin_hint": "Valgfri. 4 cifre, som barnet taster på en delt kiosk-tablet. Gemmes som hash og kan ikke læses igen — lad feltet stå tomt for at beholde den nuværende kode.", "panel.child_kiosk_pin_set": "Der er sat en PIN-kode.", @@ -3055,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 9445097b..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", @@ -1980,6 +2031,8 @@ "panel.btn_clone_chore": "Duplizieren", "panel.child_link_user_label": "Mit Home-Assistant-Benutzer verknüpfen (optional)", "panel.child_link_user_hint": "Wenn gesetzt, kann nur dieser Home-Assistant-Benutzer (oder ein Admin) per Dienstaufruf Aufgaben erledigen, Belohnungen einlösen oder Timer für dieses Kind starten. Für ein gemeinsames Familien-Tablet auf \"Beliebig\" lassen.", + "panel.child_picture_entity_label": "Foto von", + "panel.child_picture_entity_hint": "Wähle die Person des Kindes, um ihr Foto anstelle des Symbols oben zu zeigen. Das Foto wird unter Einstellungen › Personen gesetzt; ohne Foto wird das Symbol angezeigt.", "panel.child_kiosk_pin_label": "Kiosk-PIN", "panel.child_kiosk_pin_hint": "Optional. 4 Ziffern, die das Kind auf einem gemeinsamen Kiosk-Tablet eintippt. Wird als Hash gespeichert und kann nicht ausgelesen werden — leer lassen, um die aktuelle PIN zu behalten.", "panel.child_kiosk_pin_set": "Eine PIN ist gesetzt.", @@ -2085,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 4fd7dc37..c95db8d9 100644 --- a/custom_components/taskmate/www/locales/en-GB.json +++ b/custom_components/taskmate/www/locales/en-GB.json @@ -778,6 +778,8 @@ "panel.child_inverted": "(inverted)", "panel.child_link_user_label": "Link to Home Assistant user (optional)", "panel.child_link_user_hint": "When set, only this Home Assistant user (or an admin) can complete chores, claim rewards, or run timers as this child via service calls. Leave as \"Anyone\" for a shared family tablet.", + "panel.child_picture_entity_label": "Photo from", + "panel.child_picture_entity_hint": "Pick the child's person to show their photo instead of the icon above. Set the photo under Settings › People; with none set, the icon is shown.", "panel.child_kiosk_pin_label": "Kiosk PIN", "panel.child_kiosk_pin_hint": "Optional. 4 digits this child types on a shared kiosk tablet. Stored hashed, so it can't be read back — leave blank to keep the current PIN.", "panel.child_kiosk_pin_set": "A PIN is set.", @@ -1951,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", @@ -2085,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 4fd7dc37..bb03b191 100644 --- a/custom_components/taskmate/www/locales/en.json +++ b/custom_components/taskmate/www/locales/en.json @@ -778,6 +778,8 @@ "panel.child_inverted": "(inverted)", "panel.child_link_user_label": "Link to Home Assistant user (optional)", "panel.child_link_user_hint": "When set, only this Home Assistant user (or an admin) can complete chores, claim rewards, or run timers as this child via service calls. Leave as \"Anyone\" for a shared family tablet.", + "panel.child_picture_entity_label": "Photo from", + "panel.child_picture_entity_hint": "Pick any entity with a picture, usually the child's person, to show it instead of the icon above. A person's photo is set under Settings › People; with no picture, the icon is shown.", "panel.child_kiosk_pin_label": "Kiosk PIN", "panel.child_kiosk_pin_hint": "Optional. 4 digits this child types on a shared kiosk tablet. Stored hashed, so it can't be read back — leave blank to keep the current PIN.", "panel.child_kiosk_pin_set": "A PIN is set.", @@ -1951,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", @@ -2085,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 8d8af266..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é", @@ -1980,6 +2031,8 @@ "panel.btn_clone_chore": "Dupliquer", "panel.child_link_user_label": "Lier à un utilisateur Home Assistant (facultatif)", "panel.child_link_user_hint": "Si défini, seul cet utilisateur Home Assistant (ou un admin) peut terminer des tâches, échanger des récompenses ou lancer des minuteurs pour cet enfant via les services. Laissez sur « N'importe qui » pour une tablette familiale partagée.", + "panel.child_picture_entity_label": "Photo de", + "panel.child_picture_entity_hint": "Choisissez la personne de l'enfant pour afficher sa photo à la place de l'icône ci-dessus. La photo se définit dans Paramètres › Personnes ; sans photo, l'icône est affichée.", "panel.child_kiosk_pin_label": "Code PIN du kiosque", "panel.child_kiosk_pin_hint": "Facultatif. 4 chiffres que l'enfant tape sur une tablette kiosque partagée. Stocké haché, il ne peut pas être relu — laissez vide pour garder le code actuel.", "panel.child_kiosk_pin_set": "Un code PIN est défini.", @@ -2085,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 e739d6f8..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", @@ -1980,6 +2031,8 @@ "panel.btn_clone_chore": "Dupliser", "panel.child_link_user_label": "Koble til Home Assistant-bruker (valgfritt)", "panel.child_link_user_hint": "Når satt, kan bare denne Home Assistant-brukeren (eller en admin) fullføre gjøremål, løse inn belønninger eller starte tidtakere for dette barnet via tjenestekall. La stå på «Hvem som helst» for et delt familiebrett.", + "panel.child_picture_entity_label": "Bilde fra", + "panel.child_picture_entity_hint": "Velg barnets person for å vise bildet i stedet for ikonet over. Bildet settes under Innstillinger › Personer; uten bilde vises ikonet.", "panel.child_kiosk_pin_label": "Kiosk-PIN", "panel.child_kiosk_pin_hint": "Valgfritt. 4 sifre barnet taster på et delt kiosk-nettbrett. Lagres som hash og kan ikke leses tilbake — la feltet stå tomt for å beholde gjeldende kode.", "panel.child_kiosk_pin_set": "En PIN-kode er satt.", @@ -2085,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 56a1b693..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", @@ -1980,6 +2031,8 @@ "panel.btn_clone_chore": "Dupliser", "panel.child_link_user_label": "Kople til Home Assistant-brukar (valfritt)", "panel.child_link_user_hint": "Når sett, kan berre denne Home Assistant-brukaren (eller ein admin) fullføre gjeremål, løyse inn løningar eller starte tidtakarar for dette barnet via tenestekall. Lat stå på «Kven som helst» for eit delt familiebrett.", + "panel.child_picture_entity_label": "Bilete frå", + "panel.child_picture_entity_hint": "Vel personen til barnet for å vise biletet i staden for ikonet over. Biletet vert sett under Innstillingar › Personar; utan bilete vert ikonet vist.", "panel.child_kiosk_pin_label": "Kiosk-PIN", "panel.child_kiosk_pin_hint": "Valfritt. 4 siffer barnet tastar på eit delt kiosk-nettbrett. Vert lagra som hash og kan ikkje lesast tilbake — la feltet stå tomt for å halde på gjeldande kode.", "panel.child_kiosk_pin_set": "Ein PIN-kode er sett.", @@ -2085,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 8ef97537..ddbbe43d 100644 --- a/custom_components/taskmate/www/locales/pl.json +++ b/custom_components/taskmate/www/locales/pl.json @@ -778,6 +778,8 @@ "panel.child_inverted": "(odwrócone)", "panel.child_link_user_label": "Powiąż z użytkownikiem Home Assistant (opcjonalnie)", "panel.child_link_user_hint": "Po ustawieniu tylko ten użytkownik Home Assistant (lub administrator) może wykonywać obowiązki, odbierać nagrody i uruchamiać stopery jako to dziecko przez wywołania usług. Pozostaw „Każdy” dla wspólnego tabletu rodzinnego.", + "panel.child_picture_entity_label": "Zdjęcie z", + "panel.child_picture_entity_hint": "Wybierz osobę dziecka, aby pokazać jego zdjęcie zamiast ikony powyżej. Zdjęcie ustawisz w Ustawienia › Osoby; bez zdjęcia pokazywana jest ikona.", "panel.child_kiosk_pin_label": "PIN kiosku", "panel.child_kiosk_pin_hint": "Opcjonalny. 4 cyfry, które dziecko wpisuje na wspólnym tablecie w trybie kiosku. Przechowywany jako hash, więc nie da się go odczytać — pozostaw puste, aby zachować obecny PIN.", "panel.child_kiosk_pin_set": "PIN jest ustawiony.", @@ -3055,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 8053106b..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", @@ -1980,6 +2031,8 @@ "panel.btn_clone_chore": "Duplicar", "panel.child_link_user_label": "Vincular a um usuário do Home Assistant (opcional)", "panel.child_link_user_hint": "Quando definido, apenas este usuário do Home Assistant (ou um admin) pode concluir tarefas, resgatar recompensas ou iniciar timers por esta criança via serviços. Deixe em \"Qualquer pessoa\" para um tablet familiar compartilhado.", + "panel.child_picture_entity_label": "Foto de", + "panel.child_picture_entity_hint": "Escolha a pessoa da criança para mostrar a foto em vez do ícone acima. A foto é definida em Configurações › Pessoas; sem foto, o ícone é exibido.", "panel.child_kiosk_pin_label": "PIN do quiosque", "panel.child_kiosk_pin_hint": "Opcional. 4 dígitos que a criança digita em um tablet de quiosque compartilhado. Salvo com hash, não pode ser lido de volta — deixe em branco para manter o PIN atual.", "panel.child_kiosk_pin_set": "Há um PIN definido.", @@ -2085,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 8be54547..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", @@ -1980,6 +2031,8 @@ "panel.btn_clone_chore": "Duplicar", "panel.child_link_user_label": "Associar a um utilizador do Home Assistant (opcional)", "panel.child_link_user_hint": "Quando definido, apenas este utilizador do Home Assistant (ou um admin) pode concluir tarefas, resgatar recompensas ou iniciar temporizadores por esta criança via serviços. Deixe em \"Qualquer pessoa\" para um tablet familiar partilhado.", + "panel.child_picture_entity_label": "Fotografia de", + "panel.child_picture_entity_hint": "Escolhe a pessoa da criança para mostrar a fotografia em vez do ícone acima. A fotografia define-se em Definições › Pessoas; sem fotografia, é mostrado o ícone.", "panel.child_kiosk_pin_label": "PIN do quiosque", "panel.child_kiosk_pin_hint": "Opcional. 4 dígitos que a criança escreve num tablet de quiosque partilhado. Guardado com hash, não pode ser lido de volta — deixe em branco para manter o PIN atual.", "panel.child_kiosk_pin_set": "Há um PIN definido.", @@ -2085,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-approvals-card.js b/custom_components/taskmate/www/taskmate-approvals-card.js index c451ab6a..849a9f51 100644 --- a/custom_components/taskmate/www/taskmate-approvals-card.js +++ b/custom_components/taskmate/www/taskmate-approvals-card.js @@ -789,23 +789,26 @@ class TaskMateApprovalsCard extends LitElement { _designTone(i) { return `var(--tmd-c${(i % 6) + 1})`; } - _av(name, avatar, tone, size) { - const a = avatar || ""; - const inner = a.startsWith("mdi:") - ? html`` - : a - ? html`${name || ''}` + // `source` is whatever _childAvatar returned: a child object, or a bare + // avatar string from an older payload. The shared helper accepts either. + _av(name, source, tone, size) { + const face = window.__taskmate_child_visual(source); + const inner = face.kind === "icon" + ? html`` + : face.kind === "image" + ? html`${name || ''}` : (name || "?").split(" ").map(w => w[0]).slice(0, 2).join("").toUpperCase(); return html`
${inner}
`; } - // Resolve a child's avatar from the overview sensor's children list (the - // completion/claim payloads don't carry an avatar field). + // Resolve a child's face from the overview sensor's children list (the + // completion/claim payloads don't carry one). Returns the whole child so the + // child's photo travels with the avatar, not just the icon. _childAvatar(childId) { const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) || this.hass?.states?.[this.config.entity]?.attributes || {}; const child = (attrs.children || []).find(c => c.id === childId); - return child ? child.avatar : null; + return child || null; } _pointsAttrs() { diff --git a/custom_components/taskmate/www/taskmate-auction-card.js b/custom_components/taskmate/www/taskmate-auction-card.js index d8fe4aa3..75e1c529 100644 --- a/custom_components/taskmate/www/taskmate-auction-card.js +++ b/custom_components/taskmate/www/taskmate-auction-card.js @@ -183,7 +183,11 @@ class TaskMateAuctionCard extends LitElement { const idx = Math.max(0, children.findIndex(c => String(c.id) === String(childId))); const name = this._childName(childId); const colour = `var(--tmd-c${(idx % 6) + 1}, ${CHILD_COLOURS[idx % CHILD_COLOURS.length]})`; - return html`${name.charAt(0).toUpperCase()}`; + // The child's photo when they have one (the shared face helper), else their initial. + const child = children.find(c => String(c.id) === String(childId)); + const face = child && window.__taskmate_child_visual ? window.__taskmate_child_visual(child) : null; + const inner = face && face.kind === "image" ? html`` : name.charAt(0).toUpperCase(); + return html`${inner}`; } /** @@ -623,8 +627,9 @@ class TaskMateAuctionCard extends LitElement { background: var(--ac-ac, #9b59b6); color: #fff; display: inline-grid; place-items: center; font-weight: 700; font-size: calc(var(--ac-s, 30px) * .42); line-height: 1; - box-sizing: border-box; + box-sizing: border-box; overflow: hidden; } + .ac-av img { width: 100%; height: 100%; object-fit: cover; display: block; } .ac-btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; diff --git a/custom_components/taskmate/www/taskmate-badges-card.js b/custom_components/taskmate/www/taskmate-badges-card.js index 55b8184c..613804d4 100644 --- a/custom_components/taskmate/www/taskmate-badges-card.js +++ b/custom_components/taskmate/www/taskmate-badges-card.js @@ -145,6 +145,8 @@ class TaskMateBadgesCard extends LitElement { --mdc-icon-size: 26px; color: white; } + .child-avatar { overflow: hidden; } + .child-avatar img { width: 100%; height: 100%; object-fit: cover; } .badges-title { font-size: 1.1rem; @@ -479,6 +481,17 @@ class TaskMateBadgesCard extends LitElement { } } + // The child's photo when they have one (the shared face helper), else the + // avatar icon the sensor sends. + _face(attrs, avatar) { + const face = window.__taskmate_child_visual + ? window.__taskmate_child_visual({ avatar, avatar_image: attrs.avatar_image }) + : { kind: "icon", icon: avatar }; + return face.kind === "image" + ? html`` + : html``; + } + render() { if (!this.hass || !this.config) return html``; @@ -528,9 +541,7 @@ class TaskMateBadgesCard extends LitElement {
-
- -
+
${this._face(attrs, childAvatar)}
${title}
${countLabel}
diff --git a/custom_components/taskmate/www/taskmate-bounty-card.js b/custom_components/taskmate/www/taskmate-bounty-card.js index 8edd4517..12caeaaa 100644 --- a/custom_components/taskmate/www/taskmate-bounty-card.js +++ b/custom_components/taskmate/www/taskmate-bounty-card.js @@ -175,7 +175,11 @@ class TaskMateBountyCard extends LitElement { const idx = Math.max(0, children.findIndex(c => String(c.id) === String(childId))); const name = this._childName(childId); const colour = `var(--tmd-c${(idx % 6) + 1}, ${CHILD_COLOURS[idx % CHILD_COLOURS.length]})`; - return html`${name.charAt(0).toUpperCase()}`; + // The child's photo when they have one (the shared face helper), else their initial. + const child = children.find(c => String(c.id) === String(childId)); + const face = child && window.__taskmate_child_visual ? window.__taskmate_child_visual(child) : null; + const inner = face && face.kind === "image" ? html`` : name.charAt(0).toUpperCase(); + return html`${inner}`; } // ── actions ───────────────────────────────────────────────────────────── @@ -578,8 +582,9 @@ class TaskMateBountyCard extends LitElement { display: inline-grid; place-items: center; font-weight: 700; font-size: calc(var(--bb-s, 22px) * .45); line-height: 1; border: 2px solid var(--tmd-surface, var(--ha-card-background, var(--card-background-color, #fff))); - box-sizing: border-box; + box-sizing: border-box; overflow: hidden; } + .bb-av img { width: 100%; height: 100%; object-fit: cover; display: block; } .bb-act { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-top: 10px; } .bb-hint { font-size: 12px; color: var(--tmd-dim, var(--secondary-text-color)); } .bb-claim-line { diff --git a/custom_components/taskmate/www/taskmate-calendar-card.js b/custom_components/taskmate/www/taskmate-calendar-card.js index 994e874a..372514fd 100644 --- a/custom_components/taskmate/www/taskmate-calendar-card.js +++ b/custom_components/taskmate/www/taskmate-calendar-card.js @@ -606,7 +606,12 @@ class TaskMateCalendarCard extends LitElement {
- + ${(() => { + const face = window.__taskmate_child_visual(child); + return face.kind === "image" + ? html`` + : html``; + })()}
${child.name} ${summary} @@ -626,11 +631,11 @@ class TaskMateCalendarCard extends LitElement { _designTone(i) { return `var(--tmd-c${(i % 6) + 1})`; } _av(child, tone, size) { - const a = child.avatar || ""; - const inner = a.startsWith("mdi:") - ? html`` - : a - ? html`${child.name}` + const face = window.__taskmate_child_visual(child); + const inner = face.kind === "icon" + ? html`` + : face.kind === "image" + ? html`${child.name}` : (child.name || "?").split(" ").map((w) => w[0]).slice(0, 2).join("").toUpperCase(); return html`
${inner}
`; } diff --git a/custom_components/taskmate/www/taskmate-child-card.js b/custom_components/taskmate/www/taskmate-child-card.js index 793cebb9..00ab57c0 100644 --- a/custom_components/taskmate/www/taskmate-child-card.js +++ b/custom_components/taskmate/www/taskmate-child-card.js @@ -192,6 +192,20 @@ class TaskMateChildCard extends LitElement { this.requestUpdate(); } + /** + * Classic-header avatar: child's photo → MDI avatar → default. + * + * The picker below is unaffected: it still compares catalogue icons against + * `child.avatar`, so a child shown with their photo keeps whichever MDI + * avatar they unlocked, and gets it back by switching the photo off. + */ + _avatarGlyph(child) { + const face = window.__taskmate_child_visual(child); + return face.kind === "image" + ? html`` + : html``; + } + _renderAvatarPicker(child, options) { return html`
@@ -2250,9 +2264,6 @@ class TaskMateChildCard extends LitElement { const pointsIcon = attrs.points_icon || "mdi:star"; const pointsName = attrs.points_name || this._t('common.stars'); - // Avatar now in children array directly - const avatar = child.avatar || "mdi:account-circle"; - // Resolve badges sensor entity for this child const badgesEntity = this._resolveBadgesEntity(child); const earnedBadges = (badgesEntity?.attributes?.earned) || []; @@ -2281,7 +2292,7 @@ class TaskMateChildCard extends LitElement {
this._toggleAvatarPicker() : null} title="${canChange ? this._t('child.avatar_change') : ''}"> - + ${this._avatarGlyph(child)} ${canChange ? html`` : ''}
${canChange && this._avatarPickerOpen ? this._renderAvatarPicker(child, opts) : ''} @@ -2521,11 +2532,11 @@ class TaskMateChildCard extends LitElement { _designTone(i) { return `var(--tmd-c${(i % 6) + 1})`; } _av(child, tone, size) { - const a = child.avatar || ""; - const inner = a.startsWith("mdi:") - ? html`` - : a - ? html`${child.name}` + const face = window.__taskmate_child_visual(child); + const inner = face.kind === "icon" + ? html`` + : face.kind === "image" + ? html`${child.name}` : (child.name || "?").split(" ").map(w => w[0]).slice(0, 2).join("").toUpperCase(); return html`
${inner}
`; } @@ -2554,11 +2565,12 @@ class TaskMateChildCard extends LitElement { ${team.joined.map((id) => { const i = kids.findIndex((k) => String(k.id) === id); const kid = kids[i] || { name: "?" }; - const a = kid.avatar || ""; + // The shared face helper: the child's photo, else their MDI avatar. + const face = window.__taskmate_child_visual ? window.__taskmate_child_visual(kid) : { kind: "none" }; const tone = `var(--tmd-c${((i < 0 ? 0 : i) % 6) + 1}, ${fallback[(i < 0 ? 0 : i) % 6]})`; - return html`${a.startsWith("mdi:") - ? html`` - : a ? html`` : (kid.name || "?").slice(0, 1).toUpperCase()}`; + return html`${face.kind === "icon" + ? html`` + : face.kind === "image" ? html`` : (kid.name || "?").slice(0, 1).toUpperCase()}`; })} 👥 ${label}${team.mine ? html` · ${this._t("child.team_you_joined")}` : ""} `; diff --git a/custom_components/taskmate/www/taskmate-chore-board-card.js b/custom_components/taskmate/www/taskmate-chore-board-card.js index b645fab2..b78a964d 100644 --- a/custom_components/taskmate/www/taskmate-chore-board-card.js +++ b/custom_components/taskmate/www/taskmate-chore-board-card.js @@ -317,10 +317,11 @@ class TaskMateChoreBoardCard extends LitElement { } _avatar(attrs, kid) { - const a = kid.avatar || ""; - const inner = a.startsWith("mdi:") - ? html`` - : a ? html`` : (kid.name || "?").charAt(0).toUpperCase(); + // The shared face helper: the child's photo, else their MDI avatar. + const face = window.__taskmate_child_visual ? window.__taskmate_child_visual(kid) : { kind: "none" }; + const inner = face.kind === "icon" + ? html`` + : face.kind === "image" ? html`` : (kid.name || "?").charAt(0).toUpperCase(); return html`${inner}`; } diff --git a/custom_components/taskmate/www/taskmate-design.js b/custom_components/taskmate/www/taskmate-design.js index 98773857..8557490b 100644 --- a/custom_components/taskmate/www/taskmate-design.js +++ b/custom_components/taskmate/www/taskmate-design.js @@ -147,7 +147,7 @@ const KIT = ` .tmd{overflow:hidden;font-family:var(--tmd-font-body);color:var(--tmd-text);background:var(--tmd-surface);border-radius:var(--tmd-radius);box-shadow:var(--tmd-shadow)} .tmd-hd{display:flex;align-items:center;gap:11px;padding:13px 15px;background:var(--hd,var(--tmd-accent));color:#fff} -:host([data-tm-design="playroom"]) .tmd-hd{background:linear-gradient(135deg,var(--hd,var(--tmd-accent)),color-mix(in srgb,var(--hd,var(--tmd-accent)) 72%,#fff))} +:host([data-tm-design="playroom"]) .tmd-hd{background:var(--hd,var(--tmd-accent));background:linear-gradient(135deg,var(--hd,var(--tmd-accent)),color-mix(in srgb,var(--hd,var(--tmd-accent)) 72%,#fff))} .tmd-hd .ic{width:32px;height:32px;border-radius:9px;display:grid;place-items:center;background:rgba(255,255,255,.22);font-size:17px;flex:none} .tmd-hd .tt{font-family:var(--tmd-font-display);font-weight:800;font-size:16px;flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .tmd-hd .tt small{display:block;font-family:var(--tmd-font-body);font-weight:600;font-size:11px;opacity:.85;margin-top:1px} @@ -159,6 +159,11 @@ :host([data-tm-design="console"]) .av{box-shadow:0 0 0 1px color-mix(in srgb,var(--ac,var(--tmd-accent)) 70%,transparent),0 0 14px color-mix(in srgb,var(--ac,var(--tmd-accent)) 35%,transparent)} .av ha-icon{--mdc-icon-size:calc(var(--av,42px)*.55);color:#fff} .av img{width:100%;height:100%;object-fit:cover;border-radius:50%} +/* A child's photo, for the classic (non-designed) avatar wells. + Every one of them is a sized, centred circle, so filling it is right; the + chip variant is for the few places the icon sits inline beside text. */ +.tm-face-img{width:100%;height:100%;border-radius:50%;object-fit:cover;display:block} +.tm-face-chip{width:18px;height:18px;border-radius:50%;object-fit:cover;display:block;flex:none} .chip{display:inline-flex;align-items:center;gap:5px;font-size:12px;font-weight:700;padding:4px 9px;border-radius:999px;background:var(--tmd-surface-2);color:var(--tmd-text);border:1px solid var(--tmd-border)} .chip.soft{background:color-mix(in srgb,var(--tmd-accent) 14%,transparent);border-color:transparent;color:var(--tmd-accent)} .num{font-family:var(--tmd-font-mono);font-weight:800;letter-spacing:-.01em} @@ -172,7 +177,7 @@ .lead{color:var(--tmd-accent)} .lead-dot{color:var(--tmd-gold)} .btn{font:inherit;font-family:var(--tmd-font-display);font-weight:800;font-size:13.5px;line-height:1;border:0;cursor:pointer;padding:9px 14px;border-radius:var(--tmd-radius-sm);background:var(--tmd-accent);color:#fff;display:inline-flex;align-items:center;justify-content:center;gap:6px} -:host([data-tm-design="console"]) .btn{color:#06101c;background:linear-gradient(135deg,var(--tmd-accent),color-mix(in srgb,var(--tmd-accent) 60%,var(--tmd-accent2)));box-shadow:0 0 14px color-mix(in srgb,var(--tmd-accent) 45%,transparent)} +:host([data-tm-design="console"]) .btn{color:#06101c;background:linear-gradient(135deg,var(--tmd-accent),var(--tmd-accent2));background:linear-gradient(135deg,var(--tmd-accent),color-mix(in srgb,var(--tmd-accent) 60%,var(--tmd-accent2)));box-shadow:0 0 14px color-mix(in srgb,var(--tmd-accent) 45%,transparent)} .btn.ghost{background:var(--tmd-surface-2);color:var(--tmd-text);border:1px solid var(--tmd-border)} :host([data-tm-design="console"]) .btn.ghost{box-shadow:none} .btn.good{background:var(--tmd-good);color:#06301f} @@ -563,6 +568,28 @@ return { kind: "none" }; }; + /** + * The same idea for a child's face. + * + * Precedence: child's photo → MDI avatar → the call site's fallback. + * + * `avatar_image` is resolved server-side from the picture entity chosen for + * the child in TaskMate (empty when none is), so a card needs no knowledge + * of person entities. A non-"mdi:" `avatar` is still treated as a picture URL, + * which is what the points-display and approvals cards already did. + * + * Accepts a child object or a bare avatar string, because a few payloads + * (reward claims, badges) carry only the string. + */ + window.__taskmate_child_visual = window.__taskmate_child_visual || function (child) { + const c = typeof child === "string" ? { avatar: child } : child || {}; + if (c.avatar_image) return { kind: "image", url: c.avatar_image }; + const av = c.avatar || ""; + if (av.startsWith("mdi:")) return { kind: "icon", icon: av }; + if (av) return { kind: "image", url: av }; + return { kind: "none" }; + }; + // ── Card-picker entity suggestions (HA 2026.6+) ──────────────────────────── // Custom cards may add getEntitySuggestion(hass, entityId) to their // window.customCards entry; when a user picks an entity in the card picker, diff --git a/custom_components/taskmate/www/taskmate-incentive-card.js b/custom_components/taskmate/www/taskmate-incentive-card.js index d592c274..c47d9eeb 100644 --- a/custom_components/taskmate/www/taskmate-incentive-card.js +++ b/custom_components/taskmate/www/taskmate-incentive-card.js @@ -43,6 +43,14 @@ export function createIncentiveCard(P) { return fn ? fn(this.hass, key, params) : key; } + /** Avatar: child's photo → MDI avatar → default icon. */ + _avatarGlyph(source, fallback = "mdi:account-circle", cls = "tm-face-img") { + const face = window.__taskmate_child_visual(source); + return face.kind === "image" + ? html`` + : html``; + } + _tp(key, params) { return this._t(P.i18n + "." + key, params); } @@ -617,7 +625,7 @@ export function createIncentiveCard(P) { ${children.map(c => html`
this._selectChild(c.id)}> - + ${this._avatarGlyph(c, "mdi:account-circle", "tm-face-chip")} ${c.name}
`)} @@ -821,11 +829,11 @@ export function createIncentiveCard(P) { _designTone(i) { return `var(--tmd-c${(i % 6) + 1})`; } _av(child, tone, size) { - const a = child.avatar || ""; - const inner = a.startsWith("mdi:") - ? html`` - : a - ? html`${child.name}` + const face = window.__taskmate_child_visual(child); + const inner = face.kind === "icon" + ? html`` + : face.kind === "image" + ? html`${child.name}` : (child.name || "?").split(" ").map(w => w[0]).slice(0, 2).join("").toUpperCase(); return html`
${inner}
`; } diff --git a/custom_components/taskmate/www/taskmate-kiosk-card.js b/custom_components/taskmate/www/taskmate-kiosk-card.js index 960bf2d0..e967b943 100644 --- a/custom_components/taskmate/www/taskmate-kiosk-card.js +++ b/custom_components/taskmate/www/taskmate-kiosk-card.js @@ -798,7 +798,12 @@ class TaskMateKioskCard extends LitElement { // ── Render ─────────────────────────────────────────────────────────────── _face(child, tone, size = "") { - const avatar = child.avatar || ""; + // The child's photo first (the shared face helper), then their avatar. + const face = window.__taskmate_child_visual ? window.__taskmate_child_visual(child) : { kind: "none" }; + if (face.kind === "image") { + return html`
`; + } + const avatar = face.kind === "icon" ? face.icon : ""; // The default avatar is a generic silhouette, so a child who never picked // one gets their initial instead — easier to tell apart on a picker. const icon = avatar.startsWith("mdi:") && avatar !== "mdi:account-circle"; @@ -1256,6 +1261,7 @@ class TaskMateKioskCard extends LitElement { box-shadow: 0 0 0 6px color-mix(in srgb, var(--kc) 25%, transparent); } .km-face ha-icon { --mdc-icon-size: 72px; } + .km-face img { width: 100%; height: 100%; object-fit: cover; border-radius: inherit; } .km-face.big { width: 160px; height: 160px; font-size: 76px; } .km-face.big ha-icon { --mdc-icon-size: 90px; } .km-face.head { diff --git a/custom_components/taskmate/www/taskmate-leaderboard-card.js b/custom_components/taskmate/www/taskmate-leaderboard-card.js index 8ac8ef8f..e715c458 100644 --- a/custom_components/taskmate/www/taskmate-leaderboard-card.js +++ b/custom_components/taskmate/www/taskmate-leaderboard-card.js @@ -36,6 +36,14 @@ class TaskMateLeaderboardCard extends LitElement { return fn ? fn(this.hass, key, params) : key; } + /** Avatar: child's photo → MDI avatar → default icon. */ + _avatarGlyph(source, fallback = "mdi:account-circle", cls = "tm-face-img") { + const face = window.__taskmate_child_visual(source); + return face.kind === "image" + ? html`` + : html``; + } + static get styles() { const base = css` :host { display: block; } @@ -424,7 +432,7 @@ class TaskMateLeaderboardCard extends LitElement { : html`
${this._t('leaderboard.ordinal_' + rankNum)}
`}
- + ${this._avatarGlyph(child)}
@@ -483,7 +491,7 @@ class TaskMateLeaderboardCard extends LitElement {
🥇
- + ${this._avatarGlyph(child)}
${child.name}
@@ -566,11 +574,11 @@ class TaskMateLeaderboardCard extends LitElement { _designTone(i) { return `var(--tmd-c${(i % 6) + 1})`; } _av(child, tone, size) { - const a = child.avatar || ""; - const inner = a.startsWith("mdi:") - ? html`` - : a - ? html`${child.name}` + const face = window.__taskmate_child_visual(child); + const inner = face.kind === "icon" + ? html`` + : face.kind === "image" + ? html`${child.name}` : (child.name || "?").split(" ").map(w => w[0]).slice(0, 2).join("").toUpperCase(); return html`
${inner}
`; } diff --git a/custom_components/taskmate/www/taskmate-overview-card.js b/custom_components/taskmate/www/taskmate-overview-card.js index 362059e0..da8aec10 100644 --- a/custom_components/taskmate/www/taskmate-overview-card.js +++ b/custom_components/taskmate/www/taskmate-overview-card.js @@ -56,6 +56,14 @@ class TaskMateOverviewCard extends LitElement { return fn ? fn(this.hass, key, params) : key; } + /** Avatar: child's photo → MDI avatar → default icon. */ + _avatarGlyph(source, fallback = "mdi:account-circle", cls = "tm-face-img") { + const face = window.__taskmate_child_visual(source); + return face.kind === "image" + ? html`` + : html``; + } + static get styles() { const base = css` :host { @@ -493,11 +501,11 @@ class TaskMateOverviewCard extends LitElement { _designTone(i) { return `var(--tmd-c${(i % 6) + 1})`; } _av(child, tone, size) { - const a = child.avatar || ""; - const inner = a.startsWith("mdi:") - ? html`` - : a - ? html`${child.name}` + const face = window.__taskmate_child_visual(child); + const inner = face.kind === "icon" + ? html`` + : face.kind === "image" + ? html`${child.name}` : (child.name || "?").split(" ").map(w => w[0]).slice(0, 2).join("").toUpperCase(); return html`
${inner}
`; } @@ -723,9 +731,6 @@ class TaskMateOverviewCard extends LitElement { } _renderChildTile(child, chores, completions, pointsIcon, _pointsName) { - // Avatar now included directly in children array from the overview sensor - const avatar = child.avatar || "mdi:account-circle"; - // Get today's day of week from sensor (e.g. "monday") const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config?.entity)) || {}; const todayDow = attrs.today_day_of_week || @@ -782,7 +787,7 @@ class TaskMateOverviewCard extends LitElement { return html`
- + ${this._avatarGlyph(child)}
diff --git a/custom_components/taskmate/www/taskmate-panel.js b/custom_components/taskmate/www/taskmate-panel.js index 99d6a9ff..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); @@ -1973,6 +1977,7 @@ class TaskMatePanel extends HTMLElement { unavailability_entity: c.unavailability_entity || "", pause_streak_when_unavailable: !!c.pause_streak_when_unavailable, linked_user_id: c.linked_user_id || "", + picture_entity: c.picture_entity || "", birthday: c.birthday || "", age_group: c.age_group || "", presence_entity: c.presence_entity || "", @@ -1985,7 +1990,7 @@ class TaskMatePanel extends HTMLElement { name: "", avatar: "mdi:account-circle", availability_entity: "", availability_inverted: false, unavailability_entity: "", pause_streak_when_unavailable: false, - linked_user_id: "", presence_entity: "", + linked_user_id: "", picture_entity: "", presence_entity: "", birthday: "", age_group: "", kiosk_pin: "", kiosk_pin_clear: false, has_kiosk_pin: false, } }); @@ -2036,13 +2041,15 @@ class TaskMatePanel extends HTMLElement { unavailability_entity: d.unavailability_entity || "", pause_streak_when_unavailable: !!d.pause_streak_when_unavailable, linked_user_id: d.linked_user_id || "", birthday: (d.birthday || "").trim(), age_group: d.age_group || "", - presence_entity: d.presence_entity || "" } + presence_entity: d.presence_entity || "", + picture_entity: d.picture_entity || "" } : { type: "taskmate/update_child", child_id: d.id, name: d.name.trim(), avatar: d.avatar || "mdi:account-circle", availability_entity: d.availability_entity || "", availability_inverted: !!d.availability_inverted, unavailability_entity: d.unavailability_entity || "", pause_streak_when_unavailable: !!d.pause_streak_when_unavailable, linked_user_id: d.linked_user_id || "", birthday: (d.birthday || "").trim(), age_group: d.age_group || "", - presence_entity: d.presence_entity || "" }; + presence_entity: d.presence_entity || "", + picture_entity: d.picture_entity || "" }; const { ok, err, res } = await this._callWS(payload); if (!ok) { this._showToast("err", this._t("panel.toast_save_failed", {error: err})); return; } // The PIN goes through its own command so it is hashed server-side and @@ -3572,6 +3579,7 @@ class TaskMatePanel extends HTMLElement { this._zoneCache = {}; this._applyDesign(); this._bindHaPickers(); + this._mountBackdateCard(); if (this._palette) this._focusPalette(); return; } @@ -3632,6 +3640,7 @@ class TaskMatePanel extends HTMLElement { } this._applyDesign(); + this._mountBackdateCard(); } // A dialog plays its opening animation only when it first appears (#974). @@ -4507,6 +4516,7 @@ class TaskMatePanel extends HTMLElement {
+
@@ -9692,6 +9702,242 @@ class TaskMatePanel extends HTMLElement { } } + // Log a past job: one done on an earlier day that nobody ticked. The + // backend (complete_chore with completed_date) re-checks every rule here — + // the last 7 days, the child's own non-rotating job, due that day, that + // day's limit — so this list is a convenience, not the gate. + // "Today" is Home Assistant's today: the backend checks the day against its + // own clock and zone, so a browser in another zone (or a tablet with a wrong + // clock) must not offer a day the backend would call today or too old. + _backdateDays() { + const tz = this._hass?.config?.time_zone; + const lang = this._hass?.locale?.language || this._hass?.language || undefined; + let todayKey; + try { + todayKey = new Date().toLocaleDateString("en-CA", tz ? { timeZone: tz } : undefined); + } catch (err) { + todayKey = new Date().toLocaleDateString("en-CA"); + } + const [y, m, d0] = todayKey.split("-").map(Number); + const days = []; + for (let back = 1; back <= 7; back++) { + // Plain calendar arithmetic on the date parts, at noon UTC so no zone + // can push it onto a neighbouring day. + const d = new Date(Date.UTC(y, m - 1, d0 - back, 12)); + days.push({ + v: d.toISOString().slice(0, 10), + l: d.toLocaleDateString(lang, { weekday: "long", day: "numeric", month: "short", timeZone: "UTC" }), + dow: ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"][d.getUTCDay()], + }); + } + return days; + } + + _backdateChores(childId, dow, dayKey) { + // Once TaskMate has described the day, offer exactly what it would accept + // that hasn't already used up that week's quota. Until then, a first guess + // from the weekday alone (logging still checks everything). + const info = this._backdateInfo; + if (info && info.day === dayKey) { + const ids = (info.loggable && info.loggable[childId]) || []; + const week = (info.week_progress && info.week_progress[childId]) || {}; + return (this._state.chores || []).filter(c => { + if (!ids.includes(c.id)) return false; + const target = Number(c.weekly_target) || 0; + return !target || (Number(week[c.id]) || 0) < target; + }); + } + return (this._state.chores || []).filter(c => { + if (c.enabled === false) return false; + if ((c.disabled_for || []).includes(childId)) return false; + if ((c.assigned_to || []).length && !(c.assigned_to || []).includes(childId)) return false; + if ((c.assignment_mode || "everyone") !== "everyone") return false; + // TaskMate refuses these for a past day: an open-ended job needs the + // child's note and a price, and a team's members can't be reconstructed. + if (c.open_ended || c.team_size >= 2) return false; + // A weekly-target job has no fixed days: the child picks them. + if (Number(c.weekly_target) > 0) return true; + if ((c.schedule_mode || "specific_days") === "specific_days") { + const due = c.due_days || []; + return !due.length || due.includes(dow); + } + return true; + }); + } + + async _openBackdateDialog(childId) { + const child = (this._state.children || []).find(c => c.id === childId); + if (!child) return; + const day = this._backdateDays()[0]; + const first = this._backdateChores(childId, day.dow, day.v)[0]; + this._backdateKey = ""; + this._backdateUseList = false; + this._backdateCardReady = undefined; + this._backdateInfo = null; + this._openDialog({ kind: "backdate", data: { child_id: childId, day: day.v, chore_id: first ? first.id : "" } }); + this._backdateCardReady = await this._loadBackdateCard(); + if (this._dialog && this._dialog.kind === "backdate") this._render(); + } + + // The day is shown with the dashboard's own sticker chart card, so jobs are + // ticked exactly as the children tick them. The panel isn't a dashboard and + // never loads dashboard resources, so the card (and the attribute helper it + // reads the sensors through) are imported here, at the same versioned URLs + // a dashboard uses — a browser that has both open runs each module once. + // If the card can't load, the dialog falls back to a plain job list. + async _loadBackdateCard() { + if (customElements.get("taskmate-sticker-chart-card")) return true; + try { + await import(`/taskmate/taskmate-attr-resolver.js?v=${PANEL_VERSION}`); + await import(`/taskmate/taskmate-sticker-chart-card.js?v=${PANEL_VERSION}`); + return !!customElements.get("taskmate-sticker-chart-card"); + } catch (err) { + console.warn("TaskMate: sticker chart card unavailable in the panel", err); + return false; + } + } + + _overviewEntity() { + const states = (this._hass && this._hass.states) || {}; + return Object.keys(states).find(id => { + const a = id.startsWith("sensor.") && states[id].attributes; + return a && a.points_name !== undefined && Array.isArray(a.children); + }) || ""; + } + + // Called after every render: the card element is created once and moved + // into the dialog's placeholder, so a re-render never loses its state. + // + // Nothing here may throw: it runs inside the panel's render, so an error + // would leave the whole panel dead until a reload. If the card can't be + // built (its module hasn't defined it yet, or the overview sensor is + // missing), the dialog falls back to the plain job list instead. + _mountBackdateCard() { + const host = this.querySelector(".tm-backdate-host"); + if (!host || !this._dialog || this._dialog.kind !== "backdate") return; + // Not defined yet: _openBackdateDialog re-renders once the import settles. + if (!customElements.get("taskmate-sticker-chart-card")) return; + try { + const entity = this._overviewEntity(); + if (!entity) throw new Error("no TaskMate overview sensor"); + const d = this._dialog.data; + if (!this._backdateCard) { + this._backdateCard = document.createElement("taskmate-sticker-chart-card"); + this._backdateCard.addEventListener("taskmate-day-changed", () => this._refreshBackdateDay()); + } + const card = this._backdateCard; + const key = `${d.child_id}|${d.day}`; + if (this._backdateKey !== key) { + card.setConfig({ entity, child_id: d.child_id, date: d.day, show_claim: false }); + this._backdateKey = key; + // Nothing from the previous day may linger while this one loads. + card.dayLoggable = undefined; + card.dayWeekProgress = {}; + card.dayCompletions = []; + this._refreshBackdateDay(); + // Changing the day isn't an unsaved edit: every tick is saved as it's made. + this._dialogInitialHash = this._hashDialog(); + } + card.hass = this._hass; + if (card.parentNode !== host) host.appendChild(card); + } catch (err) { + console.warn("TaskMate: sticker chart card couldn't be shown in the panel", err); + if (!this._backdateUseList) { + this._backdateUseList = true; + this._backdateCard = null; + // Re-render on the next turn rather than from inside a render. + setTimeout(() => { if (this._dialog && this._dialog.kind === "backdate") this._render(); }, 0); + } + } + } + + // What TaskMate says about the chosen day: its completions, that week's + // weekly-target progress and the chores it would accept. Feeds both the + // card and the plain-list fallback. + async _refreshBackdateDay() { + const d = this._dialog && this._dialog.data; + if (!d) return; + const day = d.day; + this._backdateInfoPending = day; + const { ok, res, err } = await this._callWS({ type: "taskmate/day_completions", date: day }); + if (this._backdateInfoPending === day) this._backdateInfoPending = null; + if (!ok) { this._showToast("err", this._t("panel.toast_save_failed", { error: err })); return; } + if (!this._dialog || this._dialog.kind !== "backdate" || this._dialog.data.day !== day) return; + this._backdateInfo = { + day, + loggable: res.loggable || {}, + week_progress: res.week_progress || {}, + }; + const card = this._backdateCard; + if (card) { + card.dayLoggable = this._backdateInfo.loggable; + card.dayWeekProgress = this._backdateInfo.week_progress; + card.dayCompletions = res.completions || []; + } else { + this._render(); + } + } + + _renderBackdateDialog() { + const d = this._dialog.data; + const child = (this._state.children || []).find(c => c.id === d.child_id) || {}; + const name = child.name || this._t("panel.child_unnamed"); + const days = this._backdateDays(); + const day = days.find(x => x.v === d.day) || days[0]; + if (this._backdateCardReady !== false && !this._backdateUseList) { + return this._dialogShell( + this._t("panel.backdate_title", { name }), + [ + `

${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; @@ -9733,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(); @@ -9846,6 +10093,10 @@ class TaskMatePanel extends HTMLElement { (this._haUsers || []).map(u => ({ v: u.id, l: u.is_admin ? `${u.name} (admin)` : u.name })) ), this._t("panel.child_link_user_hint")), + // Any entity with a picture: normally the child's person, but an image + // or camera entity works the same way. + this._entityPickerField(this._t("panel.child_picture_entity_label"), "picture_entity", d.picture_entity, + [], this._t("panel.child_picture_entity_hint"), { withPicture: true }), this._kioskPinField(d), ].join(""), ` @@ -10916,13 +11167,13 @@ class TaskMatePanel extends HTMLElement { else { (this._settingsEntityDraft = this._settingsEntityDraft || {})[field] = value; } } - _entityPickerField(label, name, value, domains, hintHtml = "") { + _entityPickerField(label, name, value, domains, hintHtml = "", { withPicture = false } = {}) { const friendly = value && this._hass?.states?.[value]?.attributes?.friendly_name || ""; const icon = value ? (this._hass?.states?.[value]?.attributes?.icon || this._entityDomainIcon(value)) : ""; return `
${this._esc(label)} -
+
${value ? `
@@ -10949,6 +11200,13 @@ class TaskMatePanel extends HTMLElement { const entities = Object.entries(this._hass.states) .filter(([id, st]) => { if (domains.length && !domains.some(d => id.startsWith(d + "."))) return false; + // A photo picker lists only entities that have a picture to show, and + // never TaskMate's own child sensors: they show the photo chosen here, + // so picking one would make the face its own source. + if (picker.dataset.epPicture) { + if (!st.attributes?.entity_picture) return false; + if (this._hass.entities?.[id]?.platform === "taskmate") return false; + } if (!query) return true; const fn = st.attributes?.friendly_name || ""; return id.toLowerCase().includes(query) || fn.toLowerCase().includes(query); diff --git a/custom_components/taskmate/www/taskmate-parent-dashboard-card.js b/custom_components/taskmate/www/taskmate-parent-dashboard-card.js index d187fedb..282776dc 100644 --- a/custom_components/taskmate/www/taskmate-parent-dashboard-card.js +++ b/custom_components/taskmate/www/taskmate-parent-dashboard-card.js @@ -57,6 +57,14 @@ class TaskMateParentDashboardCard extends LitElement { return fn ? fn(this.hass, key, params) : key; } + /** Avatar: child's photo → MDI avatar → default icon. */ + _avatarGlyph(source, fallback = "mdi:account-circle", cls = "tm-face-img") { + const face = window.__taskmate_child_visual(source); + return face.kind === "image" + ? html`` + : html``; + } + // Build "Chore · Child · date" for the lightbox caption (missing parts drop). _photoCaption(chore, child, iso) { let when = ""; @@ -665,12 +673,14 @@ class TaskMateParentDashboardCard extends LitElement { _designTone(i) { return `var(--tmd-c${(i % 6) + 1})`; } - _av(name, avatar, tone, size) { - const a = avatar || ""; - const inner = a.startsWith("mdi:") - ? html`` - : a - ? html`${name || ''}` + // `source` is a child object (so the child's photo travels with + // the avatar) or a bare icon string; the shared helper accepts either. + _av(name, source, tone, size) { + const face = window.__taskmate_child_visual(source); + const inner = face.kind === "icon" + ? html`` + : face.kind === "image" + ? html`${name || ''}` : (name || "?").split(" ").map(w => w[0]).slice(0, 2).join("").toUpperCase(); return html`
${inner}
`; } @@ -832,7 +842,7 @@ class TaskMateParentDashboardCard extends LitElement { if (design === "console") { return html`
- ${this._av(child.name, child.avatar, tone, 34)} + ${this._av(child.name, child, tone, 34)}
${child.name} ${approved}/${total}
@@ -844,7 +854,7 @@ class TaskMateParentDashboardCard extends LitElement { const isParent = window.__taskmate_is_parent(this.hass); return html`
- ${this._av(child.name, child.avatar, tone, 36)} + ${this._av(child.name, child, tone, 36)}
${child.name} @@ -862,7 +872,7 @@ class TaskMateParentDashboardCard extends LitElement { return html`
- ${this._av(child.name, child.avatar, tone, 42)} + ${this._av(child.name, child, tone, 42)}
${child.name}
${_ltrNums(this._t('dashboard.chores_done', { done: approved, total }, `${approved} / ${total} chores done`))}
@@ -899,7 +909,7 @@ class TaskMateParentDashboardCard extends LitElement { @click="${() => this._handleReject(comp.completion_id)}">✕`; return html`
- ${this._av(child?.name || '?', child?.avatar, tone, 36)} + ${this._av(child?.name || '?', child, tone, 36)}
${comp.chore_name || choreMap[comp.chore_id]?.name || this._t('common.unknown')}
${child?.name || this._t('common.unknown')}${time ? ` · ${time}` : ""} · +${pts}
@@ -955,7 +965,7 @@ class TaskMateParentDashboardCard extends LitElement { const loading = !!this._loading[key]; return html`
- ${this._av(currentChild?.name || '?', currentChild?.avatar || 'mdi:rotate-3d-variant', tone, 36)} + ${this._av(currentChild?.name || '?', currentChild?.avatar ? currentChild : 'mdi:rotate-3d-variant', tone, 36)}
${chore.name}
${currentChild?.name || unknown} · ${chore.assignment_mode}${group ? ` · ${group.policy === 'sticky' ? '🔗' : '🔀'} ${group.name}` : ""}
@@ -976,7 +986,7 @@ class TaskMateParentDashboardCard extends LitElement { const tone = this._designTone(i); return html`
- ${this._av(child.name, child.avatar, tone, 36)} + ${this._av(child.name, child, tone, 36)}
${child.name}
${child.points} ${pointsName}
@@ -1078,7 +1088,7 @@ class TaskMateParentDashboardCard extends LitElement { return html`
- + ${this._avatarGlyph(child)}
@@ -1160,7 +1170,7 @@ class TaskMateParentDashboardCard extends LitElement { return html`
- + ${this._avatarGlyph(currentChild, "mdi:rotate-3d-variant")}
${chore.name} @@ -1211,7 +1221,7 @@ class TaskMateParentDashboardCard extends LitElement { return html`
- + ${this._avatarGlyph(child)}
${comp.chore_name || chore?.name || this._t('common.unknown')}
@@ -1294,7 +1304,7 @@ class TaskMateParentDashboardCard extends LitElement { ${children.map(child => html`
- + ${this._avatarGlyph(child)}
${child.name} diff --git a/custom_components/taskmate/www/taskmate-points-card.js b/custom_components/taskmate/www/taskmate-points-card.js index ac9d868c..14070d76 100644 --- a/custom_components/taskmate/www/taskmate-points-card.js +++ b/custom_components/taskmate/www/taskmate-points-card.js @@ -783,20 +783,24 @@ class TaskMatePointsCard extends LitElement { _designTone(i) { return `var(--tmd-c${(i % 6) + 1})`; } + /** Avatar + photo, read off the child's own sensor. */ _childAvatarValue(child) { const childEntityId = Object.keys(this.hass.states).find( (eid) => this.hass.states[eid].attributes?.child_id === child.id ); - const childEntity = childEntityId ? this.hass.states[childEntityId] : null; - return childEntity?.attributes?.avatar || "mdi:account-circle"; + const attrs = childEntityId ? this.hass.states[childEntityId].attributes : null; + return { + avatar: attrs?.avatar || "mdi:account-circle", + avatar_image: attrs?.avatar_image || "", + }; } _av(child, tone, size) { - const a = this._childAvatarValue(child); - const inner = a.startsWith("mdi:") - ? html`` - : a - ? html`${child.name}` + const face = window.__taskmate_child_visual(this._childAvatarValue(child)); + const inner = face.kind === "icon" + ? html`` + : face.kind === "image" + ? html`${child.name}` : (child.name || "?").split(" ").map(w => w[0]).slice(0, 2).join("").toUpperCase(); return html`
${inner}
`; } @@ -925,17 +929,15 @@ class TaskMatePointsCard extends LitElement { const isLoading = this._loading[child.id]; // Get child entity for avatar - const childEntityId = Object.keys(this.hass.states).find( - (eid) => this.hass.states[eid].attributes?.child_id === child.id - ); - const childEntity = childEntityId ? this.hass.states[childEntityId] : null; - const avatar = childEntity?.attributes?.avatar || "mdi:account-circle"; + const face = window.__taskmate_child_visual(this._childAvatarValue(child)); return html`
- + ${face.kind === "image" + ? html`` + : html``}
${child.name}
diff --git a/custom_components/taskmate/www/taskmate-points-display-card.js b/custom_components/taskmate/www/taskmate-points-display-card.js index 2e0ea856..cb570016 100644 --- a/custom_components/taskmate/www/taskmate-points-display-card.js +++ b/custom_components/taskmate/www/taskmate-points-display-card.js @@ -59,8 +59,7 @@ function weeklyPoints(child, tz) { } function childAvatar(child, colour) { - const av = child.avatar || "mdi:account-circle"; - const isIcon = av.startsWith("mdi:"); + const face = window.__taskmate_child_visual(child); const initials = (child.name || "?") .split(" ") .map(w => w[0]) @@ -69,10 +68,10 @@ function childAvatar(child, colour) { .toUpperCase(); return html`
- ${isIcon - ? html`` - : av - ? html`${child.name}` + ${face.kind === "icon" + ? html`` + : face.kind === "image" + ? html`${child.name}` : initials}
`; } @@ -961,11 +960,11 @@ class TaskMatePointsDisplayCard extends LitElement { _designTone(i) { return `var(--tmd-c${(i % 6) + 1})`; } _av(child, tone, size) { - const a = child.avatar || ""; - const inner = a.startsWith("mdi:") - ? html`` - : a - ? html`${child.name}` + const face = window.__taskmate_child_visual(child); + const inner = face.kind === "icon" + ? html`` + : face.kind === "image" + ? html`${child.name}` : (child.name || "?").split(" ").map(w => w[0]).slice(0, 2).join("").toUpperCase(); return html`
${inner}
`; } diff --git a/custom_components/taskmate/www/taskmate-recap-card.js b/custom_components/taskmate/www/taskmate-recap-card.js index 84153a04..4f76d333 100644 --- a/custom_components/taskmate/www/taskmate-recap-card.js +++ b/custom_components/taskmate/www/taskmate-recap-card.js @@ -430,11 +430,12 @@ class TaskMateRecapCard extends LitElement { } _avatar(child) { - const a = child?.avatar || ""; - const inner = a.startsWith("mdi:") - ? html`` - : /^(https?:|\/)/.test(a) - ? html`` + // The shared face helper: the child's photo, else their MDI avatar. + const face = window.__taskmate_child_visual ? window.__taskmate_child_visual(child) : { kind: "none" }; + const inner = face.kind === "icon" + ? html`` + : face.kind === "image" && /^(https?:|\/)/.test(face.url) + ? html`` : (child?.name || "?").slice(0, 1).toUpperCase(); return html`${inner}`; } diff --git a/custom_components/taskmate/www/taskmate-reorder-card.js b/custom_components/taskmate/www/taskmate-reorder-card.js index 020a9ab4..fd1a4fbe 100644 --- a/custom_components/taskmate/www/taskmate-reorder-card.js +++ b/custom_components/taskmate/www/taskmate-reorder-card.js @@ -818,12 +818,12 @@ class TaskMateReorderCard extends LitElement { ══════════════════════════════════════════════════════════════════════ */ _designAv(child, size) { - const a = child.avatar || ""; + const face = window.__taskmate_child_visual(child); const tone = "var(--tmd-c1)"; - const inner = a.startsWith("mdi:") - ? html`` - : a - ? html`${child.name}` + const inner = face.kind === "icon" + ? html`` + : face.kind === "image" + ? html`${child.name}` : (child.name || "?").split(" ").map(w => w[0]).slice(0, 2).join("").toUpperCase(); return html`${inner}`; } diff --git a/custom_components/taskmate/www/taskmate-reward-progress-card.js b/custom_components/taskmate/www/taskmate-reward-progress-card.js index 0253f615..02586769 100644 --- a/custom_components/taskmate/www/taskmate-reward-progress-card.js +++ b/custom_components/taskmate/www/taskmate-reward-progress-card.js @@ -43,6 +43,14 @@ class TaskMateRewardProgressCard extends LitElement { return fn ? fn(this.hass, key, params) : key; } + /** Avatar: child's photo → MDI avatar → default icon. */ + _avatarGlyph(source, fallback = "mdi:account-circle", cls = "tm-face-img") { + const face = window.__taskmate_child_visual(source); + return face.kind === "image" + ? html`` + : html``; + } + static get styles() { const base = css` :host { display: block; } @@ -601,7 +609,7 @@ class TaskMateRewardProgressCard extends LitElement {
- + ${this._avatarGlyph(child)}
${child.name}
@@ -661,7 +669,7 @@ class TaskMateRewardProgressCard extends LitElement { ${children.map(child => html`
- + ${this._avatarGlyph(child)}
${child.name}: ${child.points}
@@ -716,11 +724,11 @@ class TaskMateRewardProgressCard extends LitElement { _designTone(i) { return `var(--tmd-c${(i % 6) + 1})`; } _av(child, tone, size) { - const a = (child && child.avatar) || ""; - const inner = a.startsWith("mdi:") - ? html`` - : a - ? html`${child.name}` + const face = window.__taskmate_child_visual(child); + const inner = face.kind === "icon" + ? html`` + : face.kind === "image" + ? html`${child.name}` : ((child && child.name) || "?").split(" ").map(w => w[0]).slice(0, 2).join("").toUpperCase(); return html`
${inner}
`; } diff --git a/custom_components/taskmate/www/taskmate-rewards-card.js b/custom_components/taskmate/www/taskmate-rewards-card.js index 71e79ab1..77649839 100644 --- a/custom_components/taskmate/www/taskmate-rewards-card.js +++ b/custom_components/taskmate/www/taskmate-rewards-card.js @@ -66,6 +66,14 @@ class TaskMateRewardsCard extends LitElement { return fn ? fn(this.hass, key, params) : key; } + /** Avatar: child's photo → MDI avatar → default icon. */ + _avatarGlyph(source, fallback = "mdi:account-circle", cls = "tm-face-img") { + const face = window.__taskmate_child_visual(source); + return face.kind === "image" + ? html`` + : html``; + } + static get styles() { const base = css` :host { @@ -1125,7 +1133,7 @@ class TaskMateRewardsCard extends LitElement { ${children.map((c) => html`
this._selectChild(c.id)}> - + ${this._avatarGlyph(c, "mdi:account-circle", "tm-face-chip")} ${c.name}
`)} @@ -1837,11 +1845,11 @@ class TaskMateRewardsCard extends LitElement { _designTone(i) { return `var(--tmd-c${(i % 6) + 1})`; } _av(child, tone, size) { - const a = (child && child.avatar) || ""; - const inner = a.startsWith("mdi:") - ? html`` - : a - ? html`${child.name}` + const face = window.__taskmate_child_visual(child); + const inner = face.kind === "icon" + ? html`` + : face.kind === "image" + ? html`${child.name}` : ((child && child.name) || "?").split(" ").map(w => w[0]).slice(0, 2).join("").toUpperCase(); return html`
${inner}
`; } 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`` }; + } + if (lead) return { label, glyph: lead[1] }; + const n = name.toLowerCase(); + for (const [re, e] of CHORE_EMOJI) if (re.test(n)) return { label, glyph: e }; + return { label, glyph: "⭐" }; + } + + // -- Actions ----------------------------------------------------------------- + + _toast(message) { + this.dispatchEvent(new CustomEvent("hass-notification", { + detail: { message }, bubbles: true, composed: true, + })); + } + + async _complete(child, row) { + if (row.total >= row.limit || this._busy.has(row.key)) return; + const pastDate = this._pastDay() ? this.config.date : ""; + // A grown-up logging a past day is the reviewer, so no photo is needed. + if (row.chore.require_photo && !pastDate) { + this._toast(this._t("sticker_chart.needs_photo")); + return; + } + this._busy.add(row.key); + const lastOfDay = this._finishesDay(child, row); + const prev = this._optimistic[row.key]; + this._optimistic = { + ...this._optimistic, + [row.key]: { + childId: child.id, choreId: row.chore.id, base: row.mine.length, + count: (prev && Date.now() - prev.at < OPTIMISTIC_MS ? prev.count : 0) + 1, at: Date.now(), + }, + }; + try { + const button = !pastDate && window.__taskmate_find_button + && window.__taskmate_find_button(this.hass, child.id, "complete", row.chore.id); + if (pastDate) { + await this.hass.callService("taskmate", "complete_chore", { + chore_id: row.chore.id, child_id: child.id, as_parent: true, completed_date: pastDate, + }); + this._dayChanged(); + } else if (button) { + await this.hass.callService("button", "press", { entity_id: button }); + } else { + await this.hass.callService("taskmate", "complete_chore", { + chore_id: row.chore.id, child_id: child.id, + }); + } + // Logging a past day happens in the admin panel's dialog, where a coin + // chime and a full-screen pop-up would land on the grown-up, not the child. + if (!pastDate) { + this._playSound(row.chore.completion_sound || this.config.default_sound || "coin"); + this._celebrate(child, row, lastOfDay); + } + } catch (err) { + const next = { ...this._optimistic }; + delete next[row.key]; + this._optimistic = next; + this._failed(err, child); + } finally { + this._busy.delete(row.key); + } + } + + /** + * Explain a refusal rather than showing HA's raw error. + * + * A child in TaskMate can be linked to one HA user, and then only that user + * (or a grown-up) may tick their jobs. A shared family tablet signed in as + * somebody else gets "Unauthorized" from the button entity / service, which + * means nothing to whoever is standing at the fridge. + */ + _failed(err, child) { + const msg = String(err?.message || err || ""); + this._toast(/unauthori|not authorized|admin|permission|forbidden/i.test(msg) + ? this._t("sticker_chart.not_allowed", { name: child?.name || "" }) + : this._t("sticker_chart.complete_failed", { error: msg })); + } + + async _undo(row) { + if (this.config.allow_undo === false) return; + const own = row.mine.filter(c => c.child_id !== "__parent__" && c.completion_id); + const latest = own[own.length - 1]; + if (!latest) return; + if (this._optimistic[row.key]) { + const next = { ...this._optimistic }; + delete next[row.key]; + this._optimistic = next; + } + try { + await this.hass.callService("taskmate", "reject_chore", { completion_id: latest.completion_id }); + if (this._pastDay()) this._dayChanged(); + else this._playSound(this.config.undo_sound || "undo"); + this._toast(this._t("sticker_chart.undone")); + } catch (err) { + const msg = String(err?.message || err || ""); + this._toast(/admin|parent|unauthori|permission/i.test(msg) + ? this._t("sticker_chart.undo_denied") + : this._t("sticker_chart.complete_failed", { error: msg })); + } + } + + async _claim(child, reward) { + if (this._confirming !== child.id) { + this._confirming = child.id; + clearTimeout(this._confirmTimer); + this._confirmTimer = setTimeout(() => { this._confirming = ""; }, CONFIRM_MS); + return; + } + this._confirming = ""; + clearTimeout(this._confirmTimer); + try { + const button = window.__taskmate_find_button + && window.__taskmate_find_button(this.hass, child.id, "claim", reward.id); + if (button) { + await this.hass.callService("button", "press", { entity_id: button }); + } else { + await this.hass.callService("taskmate", "claim_reward", { reward_id: reward.id, child_id: child.id }); + } + this._toast(this._t("sticker_chart.claimed", { reward: reward.name, name: child.name })); + } catch (err) { + this._failed(err, child); + } + } + + // Press-and-hold means undo; a short press means complete. Pointer events + // cover mouse and touch alike, and the context menu a long touch would + // otherwise raise is suppressed so the hold reads as one gesture. + _down(row) { + this._held = false; + clearTimeout(this._holdTimer); + this._holdTimer = setTimeout(() => { + this._held = true; + this._undo(row); + }, HOLD_MS); + } + + _up(child, row) { + clearTimeout(this._holdTimer); + if (this._held) { this._held = false; return; } + this._complete(child, row); + } + + _cancel() { clearTimeout(this._holdTimer); } + + /** A past day's completions changed: the host re-fetches `dayCompletions`. */ + _dayChanged() { + this.dispatchEvent(new CustomEvent("taskmate-day-changed", { + detail: { date: this.config.date }, bubbles: true, composed: true, + })); + } + + /** TaskMate's shared sound engine, the same one the child card plays through. */ + _playSound(name) { + const engine = window.__taskmate_sounds; + if (!engine || this.config.sound === false) return; + engine.play(name, (this._attrs() || {}).custom_sounds || []); + } + + /** + * Will this tap finish every job on today's chart? + * + * Only when it completes this chore outright — no approval to wait for — + * and every other job is already done. Judged before the tap lands, from + * the same rows the tiles are drawn from. + */ + _finishesDay(child, row) { + if ((!this._pastDay() && row.chore.requires_approval !== false) || row.total + 1 < row.limit) return false; + const rows = this._todaysChores(child, this._attrs() || {}); + return rows.length > 0 && rows.every(r => r.key === row.key || r.state === "done"); + } + + /** + * The child card's completion moment: a dimmed-screen pop-up with the + * points just earned and falling confetti. Finishing the whole day gets a + * bigger version, the way the old sticker chart's "all done" pop-up did. + */ + _celebrate(child, row, day) { + if (this.config.celebrate === false) return; + const latest = row.mine[row.mine.length - 1]; + const points = latest && latest.points != null ? latest.points : (row.chore.points || 0); + this._celebrating = { day, name: child.name, points }; + this._confetti = Array.from({ length: day ? 140 : 50 }, () => ({ + x: Math.random() * 100, + delay: Math.random() * (day ? 1.2 : 0.5), + size: Math.random() * 8 + 6, + round: Math.random() > 0.5, + })); + clearTimeout(this._celebrateTimer); + clearTimeout(this._confettiTimer); + this._celebrateTimer = setTimeout(() => { this._celebrating = null; }, day ? 6000 : 2500); + this._confettiTimer = setTimeout(() => { this._confetti = []; }, day ? 5000 : 3500); + } + + _renderCelebration(attrs) { + const c = this._celebrating; + const close = () => { this._celebrating = null; }; + return html` +
+
e.stopPropagation()}> + ${c.day ? html` +
🏆🎉⭐🎉🏆
+
${this._t("sticker_chart.all_done", { name: c.name })}
+
${this._t("sticker_chart.day_done_message")}
+ ` : html` +
🌟🌟🌟
+
${this._t("child.celebration_title")}
+
${this._t("child.celebration_message")}
+ `} +
+ + +${c.points} +
+
+
`; + } + + _renderConfetti() { + const colors = ["#ff6b9d", "#9b59b6", "#3498db", "#2ecc71", "#f1c40f", "#e67e22"]; + return html` +
+ ${this._confetti.map((p, i) => html` +
`)} +
`; + } + + // Keyboard twin of the pointer gestures: Enter/Space completes, and + // Delete/Backspace stands in for press-and-hold, which a keyboard can't do. + _key(e, child, row) { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + this._complete(child, row); + } else if ((e.key === "Delete" || e.key === "Backspace") && this._undoable(row)) { + e.preventDefault(); + this._undo(row); + } + } + + /** Whether the tile has a tick of the child's own that undo would take back. */ + _undoable(row) { + return this.config.allow_undo !== false + && row.mine.some(c => c.child_id !== "__parent__" && c.completion_id); + } + + disconnectedCallback() { + super.disconnectedCallback(); + clearTimeout(this._holdTimer); + clearTimeout(this._confirmTimer); + clearTimeout(this._celebrateTimer); + clearTimeout(this._confettiTimer); + } + + // -- Render ------------------------------------------------------------------ + + _face(child) { + const face = window.__taskmate_child_visual(child); + if (face.kind === "image") return html``; + return html``; + } + + _renderTile(child, row, index) { + const { label, glyph } = this._glyph(row.chore); + const grad = GRADIENTS[index % GRADIENTS.length]; + const pct = Math.round((100 * Math.min(row.total, row.limit)) / row.limit); + const stateLabel = this._t(`sticker_chart.state.${row.state}`); + return html` + `; + } + + // One block per star up to MAX_BLOCKS. Past that each block stands for + // several stars, so a big week looks the same as a small one (a separate + // plain bar made two children's charts look unlike each other). A block + // lights only once all its stars are earned, and the last one when the + // goal is reached, so the row is never fuller than the count above it. + _renderBlocks(points, goal, unit) { + const per = Math.max(1, Math.ceil(goal / MAX_BLOCKS)); + const total = Math.ceil(goal / per); + const on = points >= goal ? total : Math.floor(Math.max(0, points) / per); + return html` + ${per > 1 ? html`
${this._t("sticker_chart.block_key", { count: per, unit })}
` : ""}`; + } + + _renderChild(child, index, attrs) { + const unit = attrs.points_name || this._t("common.stars"); + const points = Math.max(0, Number(child.points) || 0); + const reward = this._reward(attrs.rewards, child); + const goal = reward ? this._cost(reward, child) : 0; + const reached = goal > 0 && points >= goal; + + // What the stickers and blocks count towards: the reward's cost against + // the child's balance, or this week's jobs — every star on offer since + // Monday against the stars earned since then, so extra stars can't pile + // up unseen past the goal. + const weekly = this.config.goal === "week"; + const target = weekly ? Math.max(0, Number(child.week_points_available) || 0) : goal; + const progress = weekly ? Math.max(0, Number(child.week_points_earned) || 0) : points; + const earned = target > 0 ? Math.min(progress, target) : progress; + const full = target > 0 && progress >= target; + const shown = Math.min(earned, MAX_STICKERS); + + const rows = this._todaysChores(child, attrs); + const done = rows.filter(r => r.state === "done").length; + const allDone = rows.length > 0 && done === rows.length; + + const tone = `var(--tmd-c${(index % 6) + 1}, ${index % 2 ? "#e84393" : "#3498db"})`; + const title = this.config.title || this._t("sticker_chart.default_title", { name: child.name }); + + const pendingClaim = reward && (attrs.pending_reward_claims || []) + .some(c => c.child_id === child.id && c.reward_id === reward.id); + + // A past day (the admin panel's "Log a past job"): just that day's jobs, + // done and not done, ticked the same way. The running totals and the + // reward belong to today, so they are left off. + const past = this._pastDay(); + if (past) { + const day = past.toLocaleDateString(this.hass?.locale?.language || this.hass?.language || undefined, + { weekday: "long", day: "numeric", month: "short" }); + return html` +
+
+ + ${child.name} + ${points} +
+
+
+ ${_ltrNums(this._t("sticker_chart.day_count", { day, done, total: rows.length }))} + ${allDone ? html` 🏆` : ""} +
+
+ ${rows.length ? html` +
+ ${rows.map((row, i) => this._renderTile(child, row, i))} +
` : html` +
${this._t("sticker_chart.no_jobs_day", { day })}
`} +
`; + } + + return html` +
+
+ + ${title} + ${points} +
+ +
+
+
+ ${this.config.show_today === false ? "" : html` +
+ ${_ltrNums(this._t("sticker_chart.today", { done, total: rows.length }))} + ${allDone ? html` 🏆 ${this._t("sticker_chart.all_done", { name: child.name })}` : ""} +
`} + +
+
${this._face(child)}
+
+ + ${weekly ? (target > 0 ? html` +
${_ltrNums(this._t("sticker_chart.week_goal", { points: earned, goal: target, unit }))}
+ ${this._renderBlocks(progress, target, unit)} + ${full ? html`
🌟 ${this._t("sticker_chart.week_perfect")} 🌟
` : ""} + ` : "") : goal > 0 ? html` +
${_ltrNums(this._t("sticker_chart.goal", { points: earned, goal, unit }))}
+ ${this._renderBlocks(points, goal, unit)} + ${reached ? html`
🎉 ${this._t("sticker_chart.goal_reached")} 🎉
` : ""} + ` : html`
${this._t("sticker_chart.no_reward")}
`} +
+ + ${rows.length ? html` +
+ ${rows.map((row, i) => this._renderTile(child, row, i))} +
` : html` +
${this._t("sticker_chart.no_jobs_today")}
`} + + ${reward ? html` +
+ +
+
${this._t("sticker_chart.goal_tile")}
+
${reward.name} · ${goal} ${unit}
+
+
+ + ${this.config.show_claim === false ? "" : html` + `} + ` : ""} +
`; + } + + render() { + if (!this.hass || !this.config) return html``; + if (window.__taskmate_design) { + window.__taskmate_design.apply(this, this.hass, this.config, this.config.entity); + } + const attrs = this._attrs(); + if (!attrs) { + return html`
+ +
${this._t("common.entity_not_found", { entity: this.config.entity })}
+
`; + } + const children = this._children(attrs); + if (!children.length) { + return html`
+ +
${this._t("sticker_chart.no_child")}
+
`; + } + return html` + +
+ ${children.map((child, i) => this._renderChild(child, i, attrs))} +
+ ${this._celebrating ? this._renderCelebration(attrs) : ""} + ${this._confetti.length ? this._renderConfetti() : ""} +
`; + } +} + +class TaskMateStickerChartCardEditor extends LitElement { + static get properties() { + return { hass: { type: Object }, config: { type: Object } }; + } + + _t(key, params) { + const fn = window.__taskmate_localize; + return fn ? fn(this.hass, key, params) : key; + } + + static get styles() { + return css` + :host { display: block; } + ha-form { display: block; margin-bottom: 16px; } + `; + } + + setConfig(config) { this.config = config; } + + _attrs() { + return (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config?.entity)) + || (this.config?.entity ? this.hass?.states?.[this.config.entity]?.attributes : null) + || {}; + } + + _buildSchema() { + const attrs = this._attrs(); + const children = attrs.children || []; + const rewards = attrs.rewards || []; + return [ + { name: "entity", selector: { entity: { domain: "sensor" } } }, + { + name: "child_id", + selector: { + select: { + options: [ + { value: "", label: this._t("sticker_chart.editor.child_all") }, + ...children.map(c => ({ value: c.id, label: c.name })), + ], + mode: "dropdown", + }, + }, + }, + { + name: "reward_id", + selector: { + select: { + options: [ + { value: "", label: this._t("sticker_chart.editor.reward_auto") }, + ...rewards.map(r => ({ value: r.id, label: `${r.name} (${r.cost})` })), + ], + mode: "dropdown", + }, + }, + }, + { + name: "goal", + selector: { + select: { + options: [ + { value: "reward", label: this._t("sticker_chart.editor.goal_reward") }, + { value: "week", label: this._t("sticker_chart.editor.goal_week") }, + ], + mode: "dropdown", + }, + }, + }, + { name: "title", selector: { text: {} } }, + { name: "columns", selector: { number: { min: 2, max: 6, mode: "slider" } } }, + { name: "show_today", selector: { boolean: {} } }, + { name: "show_claim", selector: { boolean: {} } }, + { name: "allow_undo", selector: { boolean: {} } }, + { name: "sound", selector: { boolean: {} } }, + { + name: "card_design", + selector: { + select: { + options: window.__taskmate_design + ? window.__taskmate_design.editorOptions(this._t.bind(this)) + : [{ value: "global", label: "Use global default" }], + mode: "dropdown", + }, + }, + }, + ]; + } + + _computeLabel = (entry) => { + const labels = { + entity: this._t("common.editor.overview_entity"), + child_id: this._t("sticker_chart.editor.child"), + reward_id: this._t("sticker_chart.editor.reward"), + goal: this._t("sticker_chart.editor.goal"), + title: this._t("sticker_chart.editor.title"), + columns: this._t("sticker_chart.editor.columns"), + show_today: this._t("sticker_chart.editor.show_today"), + show_claim: this._t("sticker_chart.editor.show_claim"), + allow_undo: this._t("sticker_chart.editor.allow_undo"), + sound: this._t("sticker_chart.editor.sound"), + card_design: this._t("common.design.field_label"), + }; + return labels[entry.name] ?? entry.name; + }; + + _computeHelper = (entry) => { + const helpers = { + entity: this._t("common.editor.overview_entity_helper"), + reward_id: this._t("sticker_chart.editor.reward_helper"), + goal: this._t("sticker_chart.editor.goal_helper"), + title: this._t("sticker_chart.editor.title_helper"), + show_today: this._t("sticker_chart.editor.show_today_helper"), + show_claim: this._t("sticker_chart.editor.show_claim_helper"), + allow_undo: this._t("sticker_chart.editor.allow_undo_helper"), + }; + return helpers[entry.name] ?? ""; + }; + + // Booleans that default to true are only written when switched off, and the + // default column count is omitted, so a card's YAML stays minimal. + _valueChanged(e) { + e.stopPropagation(); + const defaultsOn = new Set(["show_today", "show_claim", "allow_undo", "sound"]); + const newConfig = {}; + for (const [key, value] of Object.entries(e.detail.value)) { + if (value === "" || value === null || value === undefined) continue; + if (key === "card_design" && value === "global") continue; + if (key === "goal" && value === "reward") continue; + if (defaultsOn.has(key) && value === true) continue; + if (key === "columns" && Number(value) === 4) continue; + newConfig[key] = value; + } + this.dispatchEvent(new CustomEvent("config-changed", { + detail: { config: newConfig }, bubbles: true, composed: true, + })); + } + + render() { + if (!this.hass || !this.config) return html``; + const data = { goal: "reward", show_today: true, show_claim: true, allow_undo: true, sound: true, columns: 4, ...this.config }; + return html` + + `; + } +} + +// A version bump can briefly leave two copies of this module in one page (the +// old ?v= URL and the new one). Defining a name twice throws, which would take +// the editor down with the card, so each define is guarded. +if (!customElements.get("taskmate-sticker-chart-card")) { + customElements.define("taskmate-sticker-chart-card", TaskMateStickerChartCard); +} +if (!customElements.get("taskmate-sticker-chart-card-editor")) { + customElements.define("taskmate-sticker-chart-card-editor", TaskMateStickerChartCardEditor); +} + +window.customCards = window.customCards || []; +if (!window.customCards.some(c => c.type === "taskmate-sticker-chart-card")) window.customCards.push({ + type: "taskmate-sticker-chart-card", + name: "TaskMate Sticker Chart", + description: "The kids' sticker chart: today's chores as stickers to tap, stars filling towards a reward", + preview: true, + getEntitySuggestion: (hass, entityId) => + window.__taskmate_suggest(hass, entityId, "taskmate-sticker-chart-card", "overview"), +}); + +// Version is injected by the HA resource URL (?v=x.x.x) and read from the DOM +const _tmVersion = new URLSearchParams( + Array.from(document.querySelectorAll('script[src*="/taskmate-sticker-chart-card.js"]')) + .map(s => s.src.split("?")[1]).find(Boolean) || "" +).get("v") || "?"; +console.info( + "%c TASKMATE STICKER CHART CARD %c v" + _tmVersion + " ", + "background:#8e44ad;color:white;font-weight:bold;padding:2px 4px;border-radius:4px 0 0 4px;", + "background:#2c3e50;color:white;font-weight:bold;padding:2px 4px;border-radius:0 4px 4px 0;" +); diff --git a/custom_components/taskmate/www/taskmate-streak-card.js b/custom_components/taskmate/www/taskmate-streak-card.js index ae79042a..ba3c8f56 100644 --- a/custom_components/taskmate/www/taskmate-streak-card.js +++ b/custom_components/taskmate/www/taskmate-streak-card.js @@ -38,6 +38,14 @@ class TaskMateStreakCard extends LitElement { return fn ? fn(this.hass, key, params) : key; } + /** Avatar: child's photo → MDI avatar → default icon. */ + _avatarGlyph(source, fallback = "mdi:account-circle", cls = "tm-face-img") { + const face = window.__taskmate_child_visual(source); + return face.kind === "image" + ? html`` + : html``; + } + static get styles() { const base = css` :host { @@ -342,16 +350,13 @@ class TaskMateStreakCard extends LitElement { const dayDots = this._buildDayDots(childCompletions, chores, child.id, daysShown); const achievements = this._getAchievements(child, childCompletions, streak, entity_ref); - // Avatar now included directly in children array from the overview sensor - const avatar = child.avatar || "mdi:account-circle"; - const streakClass = streak >= 7 ? "hot" : streak >= 3 ? "warm" : "cold"; const streakEmoji = streak >= 14 ? "🔥🔥" : streak >= 7 ? "🔥" : streak >= 3 ? "⚡" : streak >= 1 ? "✨" : "💤"; return html`
-
+
${this._avatarGlyph(child)}
${child.name}
@@ -493,11 +498,11 @@ class TaskMateStreakCard extends LitElement { _designTone(i) { return `var(--tmd-c${(i % 6) + 1})`; } _av(child, tone, size) { - const a = child.avatar || ""; - const inner = a.startsWith("mdi:") - ? html`` - : a - ? html`${child.name}` + const face = window.__taskmate_child_visual(child); + const inner = face.kind === "icon" + ? html`` + : face.kind === "image" + ? html`${child.name}` : (child.name || "?").split(" ").map(w => w[0]).slice(0, 2).join("").toUpperCase(); return html`
${inner}
`; } diff --git a/custom_components/taskmate/www/taskmate-weekly-card.js b/custom_components/taskmate/www/taskmate-weekly-card.js index e3485a0f..982d3276 100644 --- a/custom_components/taskmate/www/taskmate-weekly-card.js +++ b/custom_components/taskmate/www/taskmate-weekly-card.js @@ -38,6 +38,14 @@ class TaskMateWeeklyCard extends LitElement { return fn ? fn(this.hass, key, params) : key; } + /** Avatar: child's photo → MDI avatar → default icon. */ + _avatarGlyph(source, fallback = "mdi:account-circle", cls = "tm-face-img") { + const face = window.__taskmate_child_visual(source); + return face.kind === "image" + ? html`` + : html``; + } + static get styles() { const base = css` :host { @@ -466,14 +474,11 @@ class TaskMateWeeklyCard extends LitElement { new Date(c.completed_at).toLocaleDateString("en-CA", { timeZone: tz }) )).size; - // Avatar now included directly in children array from the overview sensor - const avatar = child.avatar || "mdi:account-circle"; - const pct = Math.min((childDaysActive / 7) * 100, 100); return html`
-
+
${this._avatarGlyph(child)}
${child.name}
@@ -503,11 +508,11 @@ class TaskMateWeeklyCard extends LitElement { _designTone(i) { return `var(--tmd-c${(i % 6) + 1})`; } _av(child, tone, size) { - const a = child.avatar || ""; - const inner = a.startsWith("mdi:") - ? html`` - : a - ? html`${child.name}` + const face = window.__taskmate_child_visual(child); + const inner = face.kind === "icon" + ? html`` + : face.kind === "image" + ? html`${child.name}` : (child.name || "?").split(" ").map(w => w[0]).slice(0, 2).join("").toUpperCase(); return html`
${inner}
`; } diff --git a/tests/frontend/harness.cjs b/tests/frontend/harness.cjs index 75263435..d3b42e31 100644 --- a/tests/frontend/harness.cjs +++ b/tests/frontend/harness.cjs @@ -170,6 +170,14 @@ function loadCard(filename, overrides = {}) { editorOptions: () => [], }, __taskmate_chore_visual: (chore) => (chore?.icon ? { kind: "icon", icon: chore.icon } : { kind: "none" }), + // Mirrors taskmate-design.js: photo -> MDI avatar -> avatar-as-picture -> none. + __taskmate_child_visual: (child) => { + const c = typeof child === "string" ? { avatar: child } : child || {}; + if (c.avatar_image) return { kind: "image", url: c.avatar_image }; + const av = c.avatar || ""; + if (av.startsWith("mdi:")) return { kind: "icon", icon: av }; + return av ? { kind: "image", url: av } : { kind: "none" }; + }, matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }), addEventListener() {}, removeEventListener() {}, @@ -227,7 +235,10 @@ function loadCard(filename, overrides = {}) { "import.meta.url", JSON.stringify(`http://localhost/${filename}?v=test`), ); - vm.runInNewContext(source, sandbox); + // A module with top-level `await` is not a valid script, so it is wrapped in + // an async function. When nothing is actually awaited (the Lit base is found + // straight away) the whole body still runs synchronously. + vm.runInNewContext(overrides.topLevelAwait ? `(async () => {\n${source}\n})()` : source, sandbox); return { elements, window: sandboxWindow, get: (name) => elements.get(name) }; } diff --git a/tests/frontend/taskmate-child-photo-cards.test.cjs b/tests/frontend/taskmate-child-photo-cards.test.cjs new file mode 100644 index 00000000..5d417874 --- /dev/null +++ b/tests/frontend/taskmate-child-photo-cards.test.cjs @@ -0,0 +1,99 @@ +// Cards that draw a child's face read it through the shared face helper +// (window.__taskmate_child_visual), so a child's photo shows there too and a +// child without one keeps their avatar (Copilot review on #1062). + +const assert = require("node:assert/strict"); +const { test } = require("node:test"); + +const { loadCard, render } = require("./harness.cjs"); + +const PHOTO = "/api/image/serve/abc/512x512"; +const withPhoto = { id: "k1", name: "Millie", avatar: "mdi:account-child", avatar_image: PHOTO }; +const withoutPhoto = { id: "k2", name: "Evie", avatar: "mdi:unicorn" }; + +class CustomEvent { + constructor(type, init = {}) { + this.type = type; + this.detail = init.detail; + } +} + +function card(file, tag) { + const loaded = loadCard(file, { + sandbox: { CustomEvent, setTimeout: () => 0, clearTimeout() {}, setInterval: () => 0, clearInterval() {} }, + }); + return Object.create(loaded.get(tag).prototype); +} + +const markup = (tree) => render(tree).markup; + +test("the chore board shows a child's photo, and their avatar without one", () => { + const board = card("taskmate-chore-board-card.js", "taskmate-chore-board-card"); + board._kidColour = () => "#000"; + assert.match(markup(board._avatar({}, withPhoto)), new RegExp(`/); +}); + +test("the kiosk shows a child's photo, and their avatar without one", () => { + const kiosk = card("taskmate-kiosk-card.js", "taskmate-kiosk-card"); + assert.match(markup(kiosk._face(withPhoto, "#000")), new RegExp(`/); +}); + +test("the recap shows a child's photo, and their avatar without one", () => { + const recap = card("taskmate-recap-card.js", "taskmate-recap-card"); + assert.match(markup(recap._avatar(withPhoto)), new RegExp(`/); +}); + +// Second Copilot review on #1062: bounty, auction and the child card's team row. + +test("the bounty board shows a child's photo, and their initial without one", () => { + const bounty = card("taskmate-bounty-card.js", "taskmate-bounty-card"); + bounty._children = () => [withPhoto, withoutPhoto]; + bounty._childName = (id) => (id === "k1" ? "Millie" : "Evie"); + assert.match(markup(bounty._avatar("k1")), new RegExp(`E<\/span>/); +}); + +test("the auction card shows a child's photo, and their initial without one", () => { + const auction = card("taskmate-auction-card.js", "taskmate-auction-card"); + auction._children = () => [withPhoto, withoutPhoto]; + auction._childName = (id) => (id === "k1" ? "Millie" : "Evie"); + assert.match(markup(auction._avatar("k1")), new RegExp(`E<\/span>/); +}); + +test("a teamwork chore's joined children show their photos on the child card", () => { + const child = card("taskmate-child-card.js", "taskmate-child-card"); + child.config = { entity: "sensor.taskmate_overview" }; + child.hass = { states: { "sensor.taskmate_overview": { attributes: { children: [withPhoto, withoutPhoto] } } } }; + child._t = (key) => key; + const out = markup(child._renderTeamProgress({ joined: ["k1", "k2"], size: 3, mine: false })); + assert.match(out, new RegExp(`/); +}); + +// Third Copilot review on #1062: the badges header, and the photo picker. + +test("the badges card shows the child's photo, and their avatar without one", () => { + const badges = card("taskmate-badges-card.js", "taskmate-badges-card"); + assert.match(markup(badges._face({ avatar_image: PHOTO }, "mdi:unicorn")), new RegExp(`/); +}); + +test("the photo picker offers any entity with a picture, not only people", () => { + const src = require("node:fs").readFileSync( + require("node:path").join(__dirname, "../../custom_components/taskmate/www/taskmate-panel.js"), "utf8"); + const call = src.slice(src.indexOf('"picture_entity", d.picture_entity,')); + assert.match(call.slice(0, 200), /\[\], this\._t\("panel\.child_picture_entity_hint"\), \{ withPicture: true \}/); + const filter = src.slice(src.indexOf("if (picker.dataset.epPicture) {")); + assert.match(filter.slice(0, 200), /if \(!st\.attributes\?\.entity_picture\) return false;/); +}); + +test("the photo picker leaves out TaskMate's own child sensors, which show the photo chosen there", () => { + const src = require("node:fs").readFileSync( + require("node:path").join(__dirname, "../../custom_components/taskmate/www/taskmate-panel.js"), "utf8"); + const filter = src.slice(src.indexOf("if (picker.dataset.epPicture) {")); + assert.match(filter.slice(0, 200), /this\._hass\.entities\?\.\[id\]\?\.platform === "taskmate"\) return false;/); +}); diff --git a/tests/frontend/taskmate-sticker-chart-backdate.test.cjs b/tests/frontend/taskmate-sticker-chart-backdate.test.cjs new file mode 100644 index 00000000..3e93f850 --- /dev/null +++ b/tests/frontend/taskmate-sticker-chart-backdate.test.cjs @@ -0,0 +1,394 @@ +// Logging a past job from the admin panel (the sticker chart card in its +// past-day mode, mounted in the panel's dialog). +// +// The panel mounts the card inside its own render, so nothing there may throw: +// an error would leave the whole panel dead until a reload. And a grown-up +// ticking a past day must not get the child's coin chime or full-screen pop-up. + +const assert = require("node:assert/strict"); +const { test } = require("node:test"); + +const { loadCard } = require("./harness.cjs"); + +const ENTITY = "sensor.taskmate_overview"; + +// ── The card, in past-day mode ─────────────────────────────────────────────── + +const played = []; +class CustomEvent { + constructor(type, init = {}) { + this.type = type; + this.detail = init.detail; + } +} + +const loaded = loadCard("taskmate-sticker-chart-card.js", { + topLevelAwait: true, + sandbox: { CustomEvent }, + window: { __taskmate_sounds: { play: (name) => played.push(name) } }, +}); +const Card = loaded.get("taskmate-sticker-chart-card"); + +function cardFor(config) { + const calls = []; + const card = new Card(); + card.setConfig({ entity: ENTITY, child_id: "k1", ...config }); + card.hass = { + language: "en", + callService: async (domain, service, data) => { calls.push({ domain, service, data }); }, + states: { + [ENTITY]: { + state: "ok", + attributes: { + points_name: "Gems", + children: [{ id: "k1", name: "Malia", points: 5 }], + chores: [{ id: "bed", name: "Make bed", points: 2, enabled: true, requires_approval: false }], + todays_completions: [], + }, + }, + }, + }; + card.dayCompletions = []; + return { card, calls }; +} + +async function tick(card) { + const attrs = card._attrs(); + const child = attrs.children[0]; + const row = card._todaysChores(child, attrs)[0]; + await card._complete(child, row); + return row; +} + +test("ticking a past day logs it with completed_date and says the day changed", async () => { + const { card, calls } = cardFor({ date: "2026-09-14" }); + let changed = 0; + card.dispatchEvent = (e) => { if (e.type === "taskmate-day-changed") changed += 1; return true; }; + await tick(card); + assert.deepEqual(JSON.parse(JSON.stringify(calls)), [ + { domain: "taskmate", service: "complete_chore", data: { chore_id: "bed", child_id: "k1", as_parent: true, completed_date: "2026-09-14" } }, + ]); + assert.equal(changed, 1); +}); + +test("ticking a past day plays no sound and starts no celebration", async () => { + played.length = 0; + const { card } = cardFor({ date: "2026-09-14" }); + await tick(card); + assert.deepEqual(played, []); + assert.equal(card._celebrating, null); + assert.equal(card._confetti.length, 0); +}); + +test("ticking today still plays the chime and celebrates", async () => { + played.length = 0; + const { card } = cardFor({}); + await tick(card); + assert.deepEqual(played, ["coin"]); + assert.ok(card._celebrating, "the pop-up shows"); +}); + +test("undoing on a past day is silent too", async () => { + played.length = 0; + const { card } = cardFor({ date: "2026-09-14" }); + card.dayCompletions = [{ completion_id: "c1", chore_id: "bed", child_id: "k1", approved: true }]; + card.dispatchEvent = () => true; + const attrs = card._attrs(); + const row = card._todaysChores(attrs.children[0], attrs)[0]; + await card._undo(row); + assert.deepEqual(played, []); +}); + +// ── Which jobs are offered ─────────────────────────────────────────────────── + +function rowsFor(chores, { config = {}, child = {} } = {}) { + const { card } = cardFor(config); + const attrs = card._attrs(); + attrs.chores = chores.map((c) => ({ enabled: true, requires_approval: false, ...c })); + const kid = { ...attrs.children[0], ...child }; + return card._todaysChores(kid, attrs); +} + +test("an open-ended chore is not offered as a tile (its note can't be taken there)", () => { + const rows = rowsFor([{ id: "a", name: "Make bed", points: 2 }, { id: "o", name: "Extra", points: 0, open_ended: true }]); + assert.deepEqual([...rows.map((r) => r.chore.id)], ["a"]); +}); + +test("a weekly-target chore whose quota earlier days filled has nothing left to tick", () => { + const chore = { id: "w", name: "Read", points: 2, weekly_target: 3 }; + assert.equal(rowsFor([chore], { child: { weekly_chore_progress: { w: 3 } } }).length, 0); +}); + +test("a weekly-target chore with quota left is offered, capped at what's left", () => { + const chore = { id: "w", name: "Read", points: 2, weekly_target: 3, daily_limit: 5 }; + const rows = rowsFor([chore], { child: { weekly_chore_progress: { w: 2 } } }); + assert.equal(rows.length, 1); + assert.equal(rows[0].limit, 0 + 1, "one left in the week, however high the daily limit"); +}); + +test("a past day caps a weekly-target chore at what that day's week has left", () => { + const chore = { id: "w", name: "Read", points: 2, weekly_target: 3, daily_limit: 5 }; + const past = (weekCount) => { + const { card } = cardFor({ date: "2026-09-14" }); + card.dayWeekProgress = { k1: { w: weekCount } }; + const attrs = card._attrs(); + attrs.chores = [{ enabled: true, requires_approval: false, ...chore }]; + return card._todaysChores(attrs.children[0], attrs); + }; + assert.equal(past(3).length, 0, "that week's quota is already full"); + const rows = past(2); + assert.equal(rows.length, 1); + assert.equal(rows[0].limit, 1, "one left in that week, however high the daily limit"); +}); + +test("a past day ignores today's weekly progress", () => { + // Today's week is full, but the day being logged is in another week. + const chore = { id: "w", name: "Read", points: 2, weekly_target: 3 }; + const rows = rowsFor([chore], { config: { date: "2026-09-14" }, child: { weekly_chore_progress: { w: 3 } } }); + assert.equal(rows.length, 1); +}); + +// ── A past day follows what TaskMate would accept (Copilot review on #1063) ── + +function pastRows(chores, { loggable, completions = [], child = {} } = {}) { + const { card } = cardFor({ date: "2026-09-14" }); + card.dayLoggable = loggable; + card.dayCompletions = completions; + const attrs = card._attrs(); + attrs.chores = chores.map((c) => ({ enabled: true, requires_approval: false, ...c })); + return card._todaysChores({ ...attrs.children[0], ...child }, attrs).map((r) => r.chore.id); +} + +test("a past day offers only the chores TaskMate says could be logged then", () => { + // A recurring chore the weekday rule alone can't place, and one the backend refuses. + const chores = [{ id: "every3", name: "Bins", schedule_mode: "recurring", recurrence: "every_3_days" }, { id: "bed", name: "Bed" }]; + assert.deepEqual([...pastRows(chores, { loggable: { k1: ["every3"] } })], ["every3"]); + assert.deepEqual([...pastRows(chores, { loggable: { k1: [] } })], []); +}); + +test("a past day keeps a job ticked that day even if it is no longer loggable, so it can be undone", () => { + const done = [{ completion_id: "x", chore_id: "bed", child_id: "k1", approved: true, points: 2 }]; + assert.deepEqual([...pastRows([{ id: "bed", name: "Bed" }], { loggable: { k1: [] }, completions: done })], ["bed"]); +}); + +test("before the day has been described the weekday rule is used as a first guess", () => { + const chores = [{ id: "sat", name: "Car", schedule_mode: "specific_days", due_days: ["saturday"] }, { id: "bed", name: "Bed" }]; + assert.deepEqual([...pastRows(chores, { loggable: undefined })], ["bed"]); +}); + +test("a finished one-off job keeps its tile on the day it was done, so it can be undone", () => { + const chore = { id: "once", name: "Tidy shed", schedule_mode: "one_shot", disabled_for: ["k1"] }; + const done = [{ completion_id: "x", chore_id: "once", child_id: "k1", approved: true, points: 2 }]; + assert.deepEqual([...pastRows([chore], { loggable: { k1: [] }, completions: done })], ["once"]); + assert.deepEqual([...pastRows([chore], { loggable: { k1: [] } })], [], "not done that day: it stays hidden"); +}); + +test("reduced motion stops the celebration bouncing and the confetti falling", () => { + const src = require("node:fs").readFileSync( + require("node:path").join(__dirname, "../../custom_components/taskmate/www/taskmate-sticker-chart-card.js"), "utf8"); + const block = src.slice(src.indexOf("@media (prefers-reduced-motion: reduce)")); + const rule = block.slice(0, block.indexOf("}\n }") + 1); + assert.match(rule, /\.celebration-overlay, \.celebration-content, \.celebration-stars \{ animation: none; \}/); + assert.match(rule, /\.confetti-container \{ display: none; \}/); +}); + +test("a past day does not offer a team chore, which can't be reconstructed", () => { + const team = { id: "t", name: "Wash up", points: 2, team: { size: 2 } }; + assert.equal(rowsFor([team], { config: { date: "2026-09-14" } }).length, 0); + assert.equal(rowsFor([team]).length, 1, "today it is still offered: tapping joins the team"); +}); + +// ── The panel's dialog ─────────────────────────────────────────────────────── + +const panelLoaded = (() => { + let created = 0; + const makeCard = () => { + created += 1; + return { + setConfig() { if (!this._ok) throw new Error("Please define an entity"); }, + addEventListener() {}, + _ok: true, + }; + }; + const res = loadCard("taskmate-panel.js", { + sandbox: { + document: { + createElement: () => makeCard(), + querySelector: () => null, + querySelectorAll: () => [], + addEventListener() {}, + head: { appendChild() {} }, + }, + }, + }); + res.created = () => created; + return res; +})(); +const Panel = panelLoaded.get("taskmate-panel"); + +function panelWith({ hass, host = { parentNode: null, appendChild() {} } } = {}) { + const panel = Object.create(Panel.prototype); + panel._hass = hass || { language: "en", states: {} }; + panel._dialog = { kind: "backdate", data: { child_id: "k1", day: "2026-09-14", chore_id: "" } }; + panel._state = { children: [{ id: "k1", name: "Malia" }], chores: [] }; + panel._t = (key) => key; + panel.querySelector = (sel) => (sel === ".tm-backdate-host" ? host : null); + panel._refreshBackdateDay = () => {}; + panel._hashDialog = () => "h"; + return panel; +} + +const withOverview = { language: "en", states: { [ENTITY]: { state: "ok", attributes: { points_name: "Gems", children: [] } } } }; + +test("mounting before the card module has loaded does nothing, and does not throw", () => { + panelLoaded.elements.delete("taskmate-sticker-chart-card"); + const before = panelLoaded.created(); + const panel = panelWith({ hass: withOverview }); + assert.doesNotThrow(() => panel._mountBackdateCard()); + assert.equal(panelLoaded.created(), before, "no element is made for a module that isn't there"); + assert.equal(panel._backdateCard, undefined); + assert.notEqual(panel._backdateUseList, true, "it's still loading, not failed"); +}); + +test("a missing overview sensor drops the dialog to the plain list instead of throwing", async () => { + panelLoaded.elements.set("taskmate-sticker-chart-card", function Card() {}); + const panel = panelWith({ hass: { language: "en", states: {} } }); + let rendered = 0; + panel._render = () => { rendered += 1; }; + assert.doesNotThrow(() => panel._mountBackdateCard()); + assert.equal(panel._backdateUseList, true); + await new Promise((resolve) => setTimeout(resolve, 5)); + assert.equal(rendered, 1, "re-rendered once, on the next turn, to show the list"); +}); + +test("a card whose setConfig throws is handled the same way", () => { + panelLoaded.elements.set("taskmate-sticker-chart-card", function Card() {}); + const panel = panelWith({ hass: withOverview }); + panel._backdateCard = { setConfig() { throw new Error("boom"); }, addEventListener() {} }; + panel._render = () => {}; + assert.doesNotThrow(() => panel._mountBackdateCard()); + assert.equal(panel._backdateUseList, true); +}); + +test("with the module and the sensor present the card is mounted and given its day", () => { + panelLoaded.elements.set("taskmate-sticker-chart-card", function Card() {}); + const appended = []; + const host = { parentNode: null, appendChild: (el) => appended.push(el) }; + const panel = panelWith({ hass: withOverview, host }); + panel._mountBackdateCard(); + assert.equal(panel._backdateUseList, undefined); + assert.equal(appended.length, 1); + assert.equal(panel._backdateKey, "k1|2026-09-14"); +}); + +// ── The day picker follows Home Assistant's clock, not the browser's ──────── + +const dayBefore = (tz) => { + const [y, m, d] = new Date().toLocaleDateString("en-CA", { timeZone: tz }).split("-").map(Number); + return new Date(Date.UTC(y, m - 1, d - 1, 12)).toISOString().slice(0, 10); +}; + +for (const tz of ["Pacific/Kiritimati", "Pacific/Pago_Pago", "Europe/London"]) { + test(`the day picker starts the day before today in ${tz}`, () => { + const panel = panelWith({ hass: { language: "en", config: { time_zone: tz }, states: {} } }); + const days = panel._backdateDays(); + assert.equal(days.length, 7); + assert.equal(days[0].v, dayBefore(tz)); + // Seven consecutive days, newest first, and never today itself. + for (let i = 1; i < days.length; i++) { + assert.equal(new Date(days[i - 1].v) - new Date(days[i].v), 86400e3); + } + assert.ok(!days.some((d) => d.v === new Date().toLocaleDateString("en-CA", { timeZone: tz }))); + }); +} + +test("each offered day carries the weekday the backend will check", () => { + const panel = panelWith({ hass: { language: "en", config: { time_zone: "Europe/London" }, states: {} } }); + for (const day of panel._backdateDays()) { + const weekday = new Date(`${day.v}T12:00:00Z`).toLocaleDateString("en-US", { weekday: "long", timeZone: "UTC" }); + assert.equal(day.dow, weekday.toLowerCase()); + } +}); + +// ── The plain list follows the day TaskMate described ──────────────────────── + +test("the plain list offers what TaskMate would accept, minus weekly quotas already full", () => { + const panel = panelWith(); + panel._state.chores = [ + { id: "bed", name: "Bed", points: 1 }, + { id: "bins", name: "Bins", points: 1, schedule_mode: "recurring" }, + { id: "read", name: "Read", points: 1, weekly_target: 2 }, + { id: "swim", name: "Swim", points: 1, weekly_target: 2 }, + { id: "off", name: "Off", points: 1 }, + ]; + panel._backdateInfo = { + day: "2026-09-14", + loggable: { k1: ["bins", "read", "swim"] }, + week_progress: { k1: { read: 2, swim: 1 } }, + }; + const ids = panel._backdateChores("k1", "monday", "2026-09-14").map((c) => c.id); + assert.deepEqual([...ids], ["bins", "swim"]); +}); + +test("the plain list asks TaskMate about a day it hasn't described yet", async () => { + const panel = panelWith(); + panel._backdateUseList = true; + panel._backdateCardReady = false; + panel._backdateDays = () => [{ v: "2026-09-14", l: "Monday", dow: "monday" }]; + panel._select = () => ""; + panel._esc = (x) => x; + panel._dialogShell = () => ""; + let asked = 0; + panel._refreshBackdateDay = () => { asked += 1; }; + panel._renderBackdateDialog(); + await new Promise((resolve) => setTimeout(resolve, 5)); + assert.equal(asked, 1); +}); + +// ── Keyboard ───────────────────────────────────────────────────────────────── + +function keyOn(config, mine) { + const { card, calls } = cardFor(config); + card.dayCompletions = mine; + card.dispatchEvent = () => true; + const attrs = card._attrs(); + const child = attrs.children[0]; + const row = card._todaysChores(child, attrs)[0]; + const press = async (key) => { + let prevented = false; + card._key({ key, preventDefault: () => { prevented = true; } }, child, row); + await new Promise((r) => setTimeout(r, 0)); + return prevented; + }; + return { card, calls, row, press }; +} + +const TICKED = [{ completion_id: "c1", chore_id: "bed", child_id: "k1", approved: true }]; + +test("Delete on a ticked tile undoes it, as press-and-hold does", async () => { + const { calls, press } = keyOn({ date: "2026-09-14" }, TICKED); + assert.equal(await press("Delete"), true); + assert.deepEqual(JSON.parse(JSON.stringify(calls)), [ + { domain: "taskmate", service: "reject_chore", data: { completion_id: "c1" } }, + ]); +}); + +test("Backspace undoes too, and the tile advertises both keys", async () => { + const { card, calls, row, press } = keyOn({ date: "2026-09-14" }, TICKED); + assert.equal(card._undoable(row), true); + await press("Backspace"); + assert.equal(calls[0].service, "reject_chore"); +}); + +test("Delete leaves an unticked tile alone and lets the key through", async () => { + const { card, calls, row, press } = keyOn({ date: "2026-09-14" }, []); + assert.equal(card._undoable(row), false); + assert.equal(await press("Delete"), false); + assert.deepEqual(calls, []); +}); + +test("Delete does nothing when undo is switched off", async () => { + const { calls, press } = keyOn({ date: "2026-09-14", allow_undo: false }, TICKED); + assert.equal(await press("Delete"), false); + assert.deepEqual(calls, []); +}); diff --git a/tests/test_child_picture_entity.py b/tests/test_child_picture_entity.py new file mode 100644 index 00000000..95c4580a --- /dev/null +++ b/tests/test_child_picture_entity.py @@ -0,0 +1,209 @@ +"""A child's face can come from any entity's picture — normally their person. + +`Child.picture_entity` stores only the entity id; the picture URL is read from +that entity on every update, so a photo changed in Settings › People needs +nothing re-saved here and a deleted entity cannot leave a broken image behind. + +It is deliberately independent of `linked_user_id`: a young child commonly has +a person entity with a photo but no Home Assistant login of their own. + +Every lookup fails safe to "" and the caller falls back to the MDI avatar. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import custom_components.taskmate.coord_avatars as coord_avatars +from custom_components.taskmate.coord_avatars import AvatarsMixin +from custom_components.taskmate.models import Child + +from .test_coordinator_logic import _make_coord + +MILLIE_PIC = "/api/image/serve/millie/512x512" +EVIE_PIC = "/api/image/serve/evie/512x512" + + +def _state(picture=None, user_id=None): + state = MagicMock() + attrs = {} + if picture is not None: + attrs["entity_picture"] = picture + if user_id is not None: + attrs["user_id"] = user_id + state.attributes = attrs + return state + + +def _hass(states): + hass = MagicMock() + hass.states.get = MagicMock(side_effect=lambda eid: states.get(eid)) + return hass + + +def _coord(children, states): + coord = _make_coord(children=list(children)) + coord.hass = _hass(states) + return coord + + +class TestResolution: + def test_uses_the_chosen_entitys_picture(self): + child = Child(name="Millie", id="millie", picture_entity="person.millie") + coord = _coord([child], {"person.millie": _state(MILLIE_PIC, user_id="uid-millie")}) + assert coord.child_avatar_image(child) == MILLIE_PIC + + def test_works_for_a_person_with_no_ha_login(self): + """Evie's person has a photo but no linked user — the case that the + account-based lookup could never have served.""" + child = Child(name="Evie", id="evie", picture_entity="person.evie") + coord = _coord([child], {"person.evie": _state(EVIE_PIC, user_id=None)}) + assert coord.child_avatar_image(child) == EVIE_PIC + + def test_does_not_need_a_linked_account(self): + child = Child(name="Evie", id="evie", picture_entity="person.evie", linked_user_id="") + coord = _coord([child], {"person.evie": _state(EVIE_PIC)}) + assert coord.child_avatar_image(child) == EVIE_PIC + + def test_nothing_chosen_means_no_picture(self): + child = Child(name="Millie", id="millie") + assert _coord([child], {"person.millie": _state(MILLIE_PIC)}).child_avatar_image(child) == "" + + def test_entity_that_no_longer_exists(self): + child = Child(name="Millie", id="millie", picture_entity="person.gone") + assert _coord([child], {}).child_avatar_image(child) == "" + + def test_entity_without_a_picture(self): + child = Child(name="Millie", id="millie", picture_entity="person.millie") + assert _coord([child], {"person.millie": _state(None)}).child_avatar_image(child) == "" + + def test_surrounding_whitespace_is_ignored(self): + child = Child(name="Millie", id="millie", picture_entity=" person.millie ") + assert _coord([child], {"person.millie": _state(MILLIE_PIC)}).child_avatar_image(child) == MILLIE_PIC + + +class TestStorage: + def test_round_trips(self): + child = Child(name="Evie", id="evie", picture_entity="person.evie") + assert Child.from_dict(child.to_dict()).picture_entity == "person.evie" + + def test_children_saved_before_the_feature_have_none(self): + old = Child(name="Evie", id="evie").to_dict() + del old["picture_entity"] + assert Child.from_dict(old).picture_entity == "" + + +class TestSensorExposure: + """Cards read `avatar_image`; `avatar` stays the MDI icon so the avatar + picker's 'which one is selected' comparison keeps working.""" + + def _summary(self, child, states): + from custom_components.taskmate import sensor as sensor_module + + from .test_sensor_attributes import _stress_coordinator + + coord = _stress_coordinator() + coord.data["children"] = [child] + coord.hass = _hass(states) + # The shared fixture is a MagicMock, so bind the real resolver: this + # test is about the sensor publishing what the mixin actually returns. + coord.child_avatar_image = lambda c: AvatarsMixin.child_avatar_image(coord, c) + common = sensor_module._compute_common(coord) + return sensor_module._build_children_summary(coord, common)[0] + + def test_publishes_the_picture_alongside_the_icon(self): + child = Child(name="Evie", id="evie", avatar="mdi:rocket-launch", picture_entity="person.evie") + row = self._summary(child, {"person.evie": _state(EVIE_PIC)}) + assert row["avatar_image"] == EVIE_PIC + assert row["avatar"] == "mdi:rocket-launch" + + def test_empty_when_none_chosen(self): + child = Child(name="Evie", id="evie", avatar="mdi:rocket-launch") + row = self._summary(child, {"person.evie": _state(EVIE_PIC)}) + assert row["avatar_image"] == "" + assert row["avatar"] == "mdi:rocket-launch" + + +class TestPhotoChangesReachTheSensors: + """A photo changed on the person entity must show without a TaskMate edit.""" + + @staticmethod + def _event(entity_id, old_picture, new_picture): + def state(picture): + return None if picture is None else _state(picture) + + event = MagicMock() + event.data = {"entity_id": entity_id, "old_state": state(old_picture), "new_state": state(new_picture)} + return event + + @staticmethod + def _tracking_coord(entity="person.millie"): + child = Child(name="Millie", id="millie", picture_entity=entity) + coord = _coord([child], {}) + coord.external_state_version = 0 + coord.async_update_listeners = MagicMock() + coord.storage.get_chores = MagicMock(return_value=[]) + coord._refresh_tracked_availability_entities() + return coord + + def test_the_picture_entity_is_tracked(self): + coord = self._tracking_coord() + assert coord._tracked_picture_entities == {"person.millie"} + + def test_a_children_without_a_picture_entity_track_nothing(self): + coord = self._tracking_coord(entity="") + assert coord._tracked_picture_entities == set() + + def test_a_new_photo_refreshes_the_sensors(self): + coord = self._tracking_coord() + coord._availability_state_changed(self._event("person.millie", MILLIE_PIC, "/api/image/serve/new/512x512")) + assert coord.external_state_version == 1 + coord.async_update_listeners.assert_called_once() + + def test_a_person_arriving_home_does_not(self): + """The state flips constantly; the picture did not change.""" + coord = self._tracking_coord() + coord._availability_state_changed(self._event("person.millie", MILLIE_PIC, MILLIE_PIC)) + assert coord.external_state_version == 0 + coord.async_update_listeners.assert_not_called() + + def test_a_photo_removed_is_a_change(self): + coord = self._tracking_coord() + coord._availability_state_changed(self._event("person.millie", MILLIE_PIC, None)) + assert coord.external_state_version == 1 + + def test_other_entities_are_ignored(self): + coord = self._tracking_coord() + coord._availability_state_changed(self._event("person.someone_else", None, MILLIE_PIC)) + assert coord.external_state_version == 0 + + def test_a_stray_space_in_the_stored_id_is_still_tracked(self): + """Copilot review on #1062: the photo lookup trims the id, so tracking + must too, or a change to that entity would never refresh the sensors.""" + coord = self._tracking_coord(entity=" person.millie ") + assert coord._tracked_picture_entities == {"person.millie"} + coord._availability_state_changed(self._event("person.millie", MILLIE_PIC, "/api/image/serve/new/512x512")) + assert coord.external_state_version == 1 + + +class TestOwnSensorsRefused: + """A TaskMate child sensor shows the chosen photo, so it can't be the source.""" + + def _registry(self, platform): + entry = MagicMock() + entry.platform = platform + registry = MagicMock() + registry.async_get = MagicMock(return_value=entry) + return registry + + def test_a_taskmate_sensor_is_not_used_as_the_photo(self): + child = Child(name="Millie", id="millie", picture_entity="sensor.millie_stats") + coord = _coord([child], {"sensor.millie_stats": _state(MILLIE_PIC)}) + with patch.object(coord_avatars.er, "async_get", return_value=self._registry("taskmate")): + assert coord.child_avatar_image(child) == "" + + def test_another_integrations_entity_is_used(self): + child = Child(name="Millie", id="millie", picture_entity="person.millie") + coord = _coord([child], {"person.millie": _state(MILLIE_PIC)}) + with patch.object(coord_avatars.er, "async_get", return_value=self._registry("person")): + assert coord.child_avatar_image(child) == MILLIE_PIC diff --git a/tests/test_complete_chore_on_date.py b/tests/test_complete_chore_on_date.py new file mode 100644 index 00000000..892d7af7 --- /dev/null +++ b/tests/test_complete_chore_on_date.py @@ -0,0 +1,476 @@ +"""A grown-up can log a job done on an earlier day that nobody ticked. + +`complete_chore` with `completed_date` (parent-only) stamps the completion on +that day, so it counts towards that day's limit and that week's sticker-chart +bar, and it is approved straight away. The streak is left alone. +""" + +from __future__ import annotations + +import asyncio +from datetime import UTC, date, datetime +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +import custom_components.taskmate.coord_chores as coord_chores +from custom_components.taskmate.models import Child, Chore, ChoreCompletion + +from .test_coordinator_logic import _make_coord + +# Wednesday 16 Sept 2026. +NOW = datetime(2026, 9, 16, 18, 0, tzinfo=UTC) +MONDAY = date(2026, 9, 14) +MILLIE = Child(name="Millie", id="millie") + + +def _setup(chore, completions=()): + coord = _make_coord(children=[MILLIE], completions=list(completions)) + stored = list(completions) + coord.storage.get_chore = MagicMock(side_effect=lambda cid: chore if cid == chore.id else None) + coord.storage.get_completions = MagicMock(side_effect=lambda: stored) + coord.storage.add_completion = MagicMock(side_effect=stored.append) + coord.storage.update_completion = MagicMock() + coord._award_points = AsyncMock(side_effect=lambda child, pts, **kw: pts) + # The follow-up an ordinary approval runs, so a test can see it happen. + coord.storage.get_last_completed = MagicMock(return_value={}) + coord.storage.set_last_completed = MagicMock() + coord.storage.update_chore = MagicMock() + coord._async_advance_quests = AsyncMock() + coord._async_evaluate_challenges = AsyncMock() + coord.badges = MagicMock() + coord.badges.evaluate_for_child = AsyncMock() + return coord, stored + + +def _run(coord, chore_id="bed", day=MONDAY): + with patch.object(coord_chores.dt_util, "_now", NOW): + return asyncio.run(coord.async_complete_chore_on_date(chore_id, "millie", day)) + + +def _bed(**kw): + base = {"name": "Make bed", "id": "bed", "points": 2, "schedule_mode": "specific_days", "due_days": []} + base.update(kw) + return Chore(**base) + + +def test_logs_an_approved_completion_on_that_day(): + coord, stored = _setup(_bed()) + completion = _run(coord) + assert completion.completed_at.date() == MONDAY + assert completion.approved is True + assert completion.points_awarded == 2 + assert stored == [completion] + + +def test_points_use_that_days_date_and_leave_the_streak_alone(): + coord, _ = _setup(_bed()) + _run(coord) + kwargs = coord._award_points.await_args.kwargs + assert kwargs["completion_date"] == MONDAY + assert kwargs["skip_streak"] is True + + +@pytest.mark.parametrize("day", [date(2026, 9, 16), date(2026, 9, 17), date(2026, 9, 8)]) +def test_only_the_last_seven_days_before_today(day): + coord, _ = _setup(_bed()) + with pytest.raises(ValueError, match="last 7 days"): + _run(coord, day=day) + + +def test_refuses_a_day_the_job_was_not_due(): + coord, _ = _setup(_bed(due_days=["saturday", "sunday"])) + with pytest.raises(ValueError, match="wasn't due on Monday"): + _run(coord) + + +def test_refuses_someone_elses_job(): + coord, _ = _setup(_bed(assigned_to=["evie"])) + with pytest.raises(ValueError, match="isn't one of Millie's jobs"): + _run(coord) + + +def test_refuses_rotation_chores(): + coord, _ = _setup(_bed(assignment_mode="round_robin")) + with pytest.raises(ValueError, match="rotates"): + _run(coord) + + +def test_that_days_limit_still_applies(): + done = ChoreCompletion(chore_id="bed", child_id="millie", completed_at=datetime(2026, 9, 14, 8, tzinfo=UTC)) + coord, _ = _setup(_bed(), [done]) + with pytest.raises(ValueError, match="already done 1/1 times on Monday"): + _run(coord) + + +def test_a_completion_on_another_day_does_not_use_up_the_limit(): + other = ChoreCompletion(chore_id="bed", child_id="millie", completed_at=datetime(2026, 9, 15, 8, tzinfo=UTC)) + coord, stored = _setup(_bed(), [other]) + _run(coord) + assert len(stored) == 2 + + +def test_the_service_gates_past_days_to_parents(): + """Logging a past day is a grown-up's correction: the service must demand a + parent before routing to the backdating path, whatever as_parent says.""" + from pathlib import Path + + src = (Path(__file__).parents[1] / "custom_components/taskmate/__init__.py").read_text() + block = src[src.index('completed_date = call.data.get("completed_date")') :] + block = block[: block.index("if as_parent:")] + assert "await _async_require_parent(hass, call)" in block + assert "async_complete_chore_on_date" in block + assert 'vol.Optional("completed_date"): cv.date' in src + + +def test_the_panel_offers_it_on_each_child(): + from pathlib import Path + + panel = (Path(__file__).parents[1] / "custom_components/taskmate/www/taskmate-panel.js").read_text() + assert 'data-act="backdate-open"' in panel + assert "completed_date: d.day" in panel + assert "as_parent: true" in panel + + +def test_completions_on_date_lists_only_that_day(): + rows = [ + ChoreCompletion( + chore_id="bed", child_id="millie", completed_at=datetime(2026, 9, 14, 8, tzinfo=UTC), approved=True + ), + ChoreCompletion(chore_id="bed", child_id="millie", completed_at=datetime(2026, 9, 15, 8, tzinfo=UTC)), + ChoreCompletion( + chore_id="bed", child_id="millie", completed_at=datetime(2026, 9, 14, 9, tzinfo=UTC), bonus_subtask_id="x" + ), + ] + coord, _ = _setup(_bed(), rows) + out = coord.completions_on_date(MONDAY) + assert [r["completion_id"] for r in out] == [rows[0].id] + assert out[0]["approved"] is True and out[0]["chore_id"] == "bed" + + +def test_the_panel_shows_the_sticker_card_for_the_day(): + from pathlib import Path + + panel = (Path(__file__).parents[1] / "custom_components/taskmate/www/taskmate-panel.js").read_text() + assert "taskmate-sticker-chart-card.js?v=${PANEL_VERSION}" in panel + assert '"taskmate/day_completions"' in panel + ws = (Path(__file__).parents[1] / "custom_components/taskmate/websocket.py").read_text() + assert "_ws_day_completions,\n" in ws + + +# ── It is an ordinary approval, not a shortcut ─────────────────────────────── + + +def test_runs_the_same_after_approval_hooks_as_any_approval(): + coord, _ = _setup(_bed()) + _run(coord) + coord.badges.evaluate_for_child.assert_awaited_once_with("millie", "manual") + coord._async_advance_quests.assert_awaited_once_with("millie", "bed") + coord._async_evaluate_challenges.assert_awaited_once_with("millie") + coord.storage.async_save.assert_awaited() + coord.async_refresh.assert_awaited() + + +def test_it_is_marked_backdated_and_parent_made(): + coord, _ = _setup(_bed()) + completion = _run(coord) + assert completion.backdated is True + assert completion.child_undo_allowed is False + assert ChoreCompletion.from_dict(completion.to_dict()).backdated is True + # Written only when set, so ordinary completions keep their small records. + assert "backdated" not in ChoreCompletion(chore_id="a", child_id="b", completed_at=NOW).to_dict() + + +def test_records_last_completed_when_it_is_the_latest(): + coord, _ = _setup(_bed()) + completion = _run(coord) + coord.storage.set_last_completed.assert_called_once_with("bed", "millie", completion.completed_at.isoformat()) + + +def test_does_not_pull_last_completed_back_behind_a_later_completion(): + coord, _ = _setup(_bed()) + coord.storage.get_last_completed = MagicMock(return_value={"current": "2026-09-15T08:00:00+00:00"}) + _run(coord) + coord.storage.set_last_completed.assert_not_called() + + +def test_a_one_shot_job_is_disabled_for_that_child_like_any_approval(): + chore = _bed(schedule_mode="one_shot", created_date=MONDAY.isoformat()) + coord, _ = _setup(chore) + _run(coord) + assert "millie" in chore.disabled_for + coord.storage.update_chore.assert_called() + + +# ── Chore kinds that can't (or must not) be logged afterwards ──────────────── + + +def test_refuses_open_ended_chores(): + coord, _ = _setup(_bed(open_ended=True)) + with pytest.raises(ValueError, match="open-ended"): + _run(coord) + + +def test_refuses_teamwork_chores(): + coord, _ = _setup(_bed(team_size=2)) + coord.teamwork_size = MagicMock(return_value=2) + with pytest.raises(ValueError, match="needs a team"): + _run(coord) + + +def test_refuses_a_day_won_at_auction(): + coord, _ = _setup(_bed()) + coord.auction_winner = MagicMock(return_value="evie") + with pytest.raises(ValueError, match="won at auction"): + _run(coord) + + +# ── Weekly targets (#883) ──────────────────────────────────────────────────── + + +def _week_of(day, n, chore_id="bed"): + return [ + ChoreCompletion(chore_id=chore_id, child_id="millie", completed_at=datetime(2026, 9, day, 8, tzinfo=UTC)) + for _ in range(n) + ] + + +def test_a_weekly_target_chore_can_be_logged_on_any_day_while_the_quota_is_open(): + # Due only at weekends, but a weekly-target chore's days are the child's choice. + coord, stored = _setup(_bed(weekly_target=2, due_days=["saturday"]), _week_of(15, 1)) + _run(coord) + assert len(stored) == 2 + + +def test_a_weekly_target_chore_refuses_once_that_weeks_quota_is_met(): + coord, _ = _setup(_bed(weekly_target=2, due_days=["saturday"]), _week_of(15, 1) + _week_of(16, 1)) + with pytest.raises(ValueError, match="already reached its 2"): + _run(coord) + + +def test_the_quota_is_counted_for_the_week_of_the_day_being_logged(): + # Two completions last week fill last week's quota, not this week's. + last_week = _week_of(9, 2) + coord, stored = _setup(_bed(weekly_target=2, due_days=[]), last_week) + _run(coord) # Monday 14th: this week has none yet + assert len(stored) == 3 + with pytest.raises(ValueError, match="already reached its 2"): + _run(coord, day=date(2026, 9, 10)) # Thursday of last week: already 2 + + +# ── Undoing one ────────────────────────────────────────────────────────────── + + +def _undo(completion, child, last_completed=None): + coord = _make_coord(children=[child], completions=[completion]) + coord.storage.get_chore = MagicMock(return_value=None) + coord.storage.get_last_completed = MagicMock(return_value=last_completed or {}) + coord.storage.undo_last_completed = MagicMock() + with patch.object(coord_chores.dt_util, "_now", NOW): + coord._reverse_completion_awards(completion, [completion]) + return coord + + +def _approved(backdated): + return ChoreCompletion( + chore_id="bed", + child_id="millie", + completed_at=datetime(2026, 9, 14, 12, tzinfo=UTC), + approved=True, + points_awarded=2, + backdated=backdated, + ) + + +def test_undoing_a_backdated_job_leaves_the_streak_alone(): + child = Child(name="Millie", id="millie", points=10, current_streak=5, last_completion_date="2026-09-16") + _undo(_approved(backdated=True), child) + assert child.current_streak == 5 + assert child.last_completion_date == "2026-09-16" + assert child.points == 8 # the points it paid are still taken back + + +def test_undoing_an_ordinary_approval_still_takes_a_day_off_the_streak(): + child = Child(name="Millie", id="millie", points=10, current_streak=5, last_completion_date="2026-09-14") + _undo(_approved(backdated=False), child) + assert child.current_streak == 4 + + +def test_undoing_a_backdated_job_does_not_pop_a_later_last_completed(): + child = Child(name="Millie", id="millie", points=10) + coord = _undo(_approved(backdated=True), child, {"current": "2026-09-15T08:00:00+00:00"}) + coord.storage.undo_last_completed.assert_not_called() + + +def test_undoing_a_backdated_job_that_set_last_completed_restores_it(): + child = Child(name="Millie", id="millie", points=10) + coord = _undo(_approved(backdated=True), child, {"current": "2026-09-14T12:00:00+00:00"}) + coord.storage.undo_last_completed.assert_called_once_with("bed", "millie") + + +# ── Weekly target bonus (#1044) ───────────────────────────────────────────── + + +def test_logging_the_job_that_fills_last_weeks_target_pays_last_weeks_bonus(): + """The bonus is settled for the week the completion belongs to, so a job + logged for last Thursday that fills last week's quota pays last week's.""" + chore = _bed(weekly_target=2, weekly_target_bonus=5, due_days=[]) + earlier = ChoreCompletion( + chore_id="bed", child_id="millie", completed_at=datetime(2026, 9, 9, 8, tzinfo=UTC), approved=True + ) + coord, _ = _setup(chore, [earlier]) # one already approved last week + paid = {} + coord.storage.get_weekly_target_bonus = MagicMock(side_effect=lambda ch, c: dict(paid)) + coord.storage.set_weekly_target_bonus = MagicMock(side_effect=lambda ch, c, v: paid.update(v or {})) + coord._maybe_level_up = AsyncMock() + coord._celebrate = AsyncMock() + _run(coord, day=date(2026, 9, 10)) + assert paid == {"2026-09-07": 5} # Monday of last week, not this week + + +def test_logging_a_job_that_does_not_fill_the_target_pays_nothing(): + chore = _bed(weekly_target=2, weekly_target_bonus=5, due_days=[]) + coord, _ = _setup(chore) + coord.storage.get_weekly_target_bonus = MagicMock(return_value={}) + coord.storage.set_weekly_target_bonus = MagicMock() + _run(coord) + coord.storage.set_weekly_target_bonus.assert_not_called() + + +# ── Every-N-days recurrence (#1038) ───────────────────────────────────────── + + +def _every_3_days(): + # Anchored on Saturday 12 Sept: due the 12th, 15th, 18th... + return _bed(schedule_mode="recurring", recurrence="every_3_days", recurrence_start="2026-09-12") + + +def test_an_every_n_days_chore_can_be_logged_on_one_of_its_days(): + coord, stored = _setup(_every_3_days()) + _run(coord, day=date(2026, 9, 15)) + assert len(stored) == 1 + + +def test_an_every_n_days_chore_is_refused_between_its_days(): + coord, _ = _setup(_every_3_days()) + with pytest.raises(ValueError, match="wasn't due"): + _run(coord, day=MONDAY) + + +# ── The past-day chart's weekly caps ───────────────────────────────────────── + + +def test_week_progress_on_counts_each_weekly_target_chore_for_that_week(): + reading = _bed(id="read", name="Read", weekly_target=3, due_days=[]) + plain = _bed() + done = [ + ChoreCompletion(chore_id="read", child_id="millie", completed_at=datetime(2026, 9, d, 8, tzinfo=UTC)) + for d in (8, 10, 14) # two last week, one this week + ] + coord, _ = _setup(reading, done) + coord.storage.get_chores = MagicMock(return_value=[reading, plain]) + with patch.object(coord_chores.dt_util, "_now", NOW): + assert coord.week_progress_on(date(2026, 9, 9)) == {"millie": {"read": 2}} + assert coord.week_progress_on(MONDAY) == {"millie": {"read": 1}} + # A week with nothing logged, and a chore with no target, add nothing. + assert coord.week_progress_on(date(2026, 9, 1)) == {} + + +def test_the_day_lookup_sends_that_weeks_progress(): + from pathlib import Path + + src = (Path(__file__).parents[1] / "custom_components/taskmate/websocket.py").read_text() + block = src[src.index("async def _ws_day_completions") :][:600] + assert '"week_progress": coordinator.week_progress_on(msg["date"])' in block + + +# ── Switched-off chores and holidays (Copilot review on #1063) ────────────── + + +def test_refuses_a_switched_off_chore_even_with_a_weekly_target(): + """A weekly-target chore skips the due-day check, which is where a + switched-off chore used to be caught.""" + coord, _ = _setup(_bed(weekly_target=2, due_days=[], enabled=False)) + with pytest.raises(ValueError, match="switched off"): + _run(coord) + + +def test_refuses_a_day_inside_a_holiday(): + coord, _ = _setup(_bed()) + coord.is_vacation_day = MagicMock(side_effect=lambda on=None: on == MONDAY) + with pytest.raises(ValueError, match="holiday"): + _run(coord) + _run(coord, day=date(2026, 9, 15)) # the day after is fine + + +# ── What the panel offers for a day ───────────────────────────────────────── + + +def _loggable(chores, completions=(), day=MONDAY): + coord, _ = _setup(chores[0], completions) + coord.storage.get_chores = MagicMock(return_value=list(chores)) + with patch.object(coord_chores.dt_util, "_now", NOW): + return coord.loggable_on(day) + + +def test_loggable_lists_only_what_could_be_logged_that_day(): + chores = [ + _bed(), + _bed(id="sat", name="Car", due_days=["saturday"]), + _bed(id="off", name="Off", enabled=False), + _bed(id="team", name="Wash up", team_size=2), + _bed(id="open", name="Extra", open_ended=True), + _bed(id="evie", name="Evie's", assigned_to=["evie"]), + ] + assert _loggable(chores) == {"millie": ["bed"]} + + +def test_loggable_follows_recurrences_not_just_weekdays(): + every_3 = _bed(schedule_mode="recurring", recurrence="every_3_days", recurrence_start="2026-09-12") + assert _loggable([every_3], day=date(2026, 9, 15)) == {"millie": ["bed"]} + assert _loggable([every_3], day=MONDAY) == {} + + +def test_loggable_keeps_a_chore_whose_quota_that_day_or_week_is_used_up(): + """The chart shows those as done tiles that can still be undone, so the + lookup leaves the quotas out; logging one more is still refused.""" + done = ChoreCompletion(chore_id="bed", child_id="millie", completed_at=datetime(2026, 9, 14, 8, tzinfo=UTC)) + assert _loggable([_bed()], [done]) == {"millie": ["bed"]} + + +def test_the_day_lookup_sends_the_loggable_chores(): + from pathlib import Path + + src = (Path(__file__).parents[1] / "custom_components/taskmate/websocket.py").read_text() + block = src[src.index("async def _ws_day_completions") :][:700] + assert '"loggable": coordinator.loggable_on(msg["date"])' in block + + +def test_undoing_a_backdated_job_takes_back_the_weekly_bonus_it_earned(): + """Copilot review on #1063: the refund sat inside the streak block, which + skips backdated jobs, so the bonus outlived the job that earned it.""" + child = Child(name="Millie", id="millie", points=20, total_points_earned=20) + completion = _approved(backdated=True) + paid = {"2026-09-14": 5} # the Monday of that completion's week + coord = _make_coord(children=[child], completions=[completion]) + coord.storage.get_chore = MagicMock(return_value=None) + coord.storage.get_last_completed = MagicMock(return_value={}) + coord.storage.undo_last_completed = MagicMock() + coord.storage.get_weekly_target_bonus = MagicMock(side_effect=lambda ch, c: dict(paid)) + coord.storage.set_weekly_target_bonus = MagicMock(side_effect=lambda ch, c, v: paid.clear() or paid.update(v or {})) + with patch.object(coord_chores.dt_util, "_now", NOW): + coord._reverse_completion_awards(completion, [completion]) + assert paid == {} + assert child.points == 20 - 2 - 5 # the job's points and its bonus + + +def test_refuses_a_birthday_day_off_but_not_a_mandatory_chore(): + """Copilot review on #1063: with birthday chores off, a child's birthday is + a day off for every chore but the mandatory ones.""" + coord, _ = _setup(_bed()) + coord.is_birthday_day_off = MagicMock(side_effect=lambda child, on=None: on == MONDAY) + with pytest.raises(ValueError, match="birthday"): + _run(coord) + mandatory, _ = _setup(_bed(mandatory=True)) + mandatory.is_birthday_day_off = MagicMock(side_effect=lambda child, on=None: on == MONDAY) + _run(mandatory) diff --git a/tests/test_sensor_attributes.py b/tests/test_sensor_attributes.py index 47b05eba..f78000f4 100644 --- a/tests/test_sensor_attributes.py +++ b/tests/test_sensor_attributes.py @@ -246,6 +246,9 @@ def _stress_coordinator(): "target": 100, } ) + # No linked-account pictures in the fixture; the feature has its own tests. + coord.child_avatar_image = MagicMock(return_value="") + coord.week_progress = MagicMock(return_value={}) coord.storage = MagicMock() coord.storage.get_last_completed = MagicMock(return_value={"current": "2026-04-20T08:00:00Z"}) return coord @@ -384,6 +387,18 @@ def test_state_is_earned_count(self, hass): sensor = ChildBadgesSensor(coord, _MockEntry(), child) assert sensor.native_value == 2 + def test_attrs_say_whose_badges_they_are(self, hass): + """Copilot review on #1062: the badges card can only show the child's + face (photo or avatar) if the sensor says who the child is.""" + child = Child(name="Mia", avatar="mdi:unicorn") + child.id = "c1" + coord = self._coordinator_for(child, [], []) + coord.child_avatar_image = MagicMock(return_value="/api/image/serve/mia/512x512") + attrs = ChildBadgesSensor(coord, _MockEntry(), child).extra_state_attributes + assert attrs["child_name"] == "Mia" + assert attrs["child_avatar"] == "mdi:unicorn" + assert attrs["avatar_image"] == "/api/image/serve/mia/512x512" + def test_attrs_include_earned_and_available(self, hass): child = Child(name="Mia", total_chores_completed=5) child.id = "c1" @@ -734,3 +749,13 @@ def test_history_shows_the_price_a_reward_was_bought_at(): assert by_id["c1"]["points"] == -50 # what it actually cost assert by_id["c2"]["points"] == -5 # no record kept; fall back to the live cost assert by_id["c3"]["points"] == -5 # still pending: today's price is the estimate + + +def test_common_context_rebuilds_when_an_external_entity_changes(): + """A child's photo lives on another entity, outside ``coordinator.data``.""" + coord = _stress_coordinator() + coord.external_state_version = 1 + before = sensor_module._compute_common(coord) + assert sensor_module._compute_common(coord) is before + coord.external_state_version = 2 + assert sensor_module._compute_common(coord) is not before diff --git a/tests/test_sticker_chart_card.py b/tests/test_sticker_chart_card.py new file mode 100644 index 00000000..6dea8a9c --- /dev/null +++ b/tests/test_sticker_chart_card.py @@ -0,0 +1,184 @@ +"""The sticker chart card: a sticker per point, filling towards one reward. + +This is the card the children actually look at, so the guards here are about +the things that would quietly break it for them: losing the companion-sensor +lookup (blank chores), losing the shared face helper (no account photo), a +translation key that never landed, or an unbounded sticker row. + +Structural, like the other card guards — the behaviour lives in JavaScript +that the Python suite cannot execute. +""" + +from __future__ import annotations + +import json +import pathlib +import re + +ROOT = pathlib.Path(__file__).resolve().parent.parent +WWW = ROOT / "custom_components" / "taskmate" / "www" +LOCALES = WWW / "locales" +CARD = (WWW / "taskmate-sticker-chart-card.js").read_text(encoding="utf-8") +FRONTEND = (ROOT / "custom_components" / "taskmate" / "frontend.py").read_text(encoding="utf-8") + + +def test_card_is_registered_for_serving(): + """An unregistered card file is never served, so the card simply is not there.""" + assert '"taskmate-sticker-chart-card.js"' in FRONTEND + + +def test_card_is_offered_in_the_lovelace_picker(): + assert 'type: "taskmate-sticker-chart-card"' in CARD + assert "window.customCards" in CARD + assert 'customElements.define("taskmate-sticker-chart-card"' in CARD + assert 'customElements.define("taskmate-sticker-chart-card-editor"' in CARD + + +def test_reads_through_the_companion_sensor_resolver(): + """chores/rewards live on companion sensors; reading the overview entity's + own attributes would leave the goal and today's count empty.""" + assert "__taskmate_attrs" in CARD + + +def test_uses_the_shared_face_helper(): + """So a child with a linked account picture shows their photo here too.""" + assert "__taskmate_child_visual" in CARD + assert "tm-face-img" in CARD + + +def test_sticker_row_is_bounded(): + """A 500-point reward must not paint 500 emoji into the page.""" + assert "MAX_STICKERS" in CARD + cap = int(re.search(r"const MAX_STICKERS = (\d+)", CARD).group(1)) + assert 0 < cap <= 200 + assert "Math.min(earned, MAX_STICKERS)" in CARD + + +def test_stickers_never_run_past_the_goal(): + assert "Math.min(points, goal)" in CARD + + +def test_sticker_row_is_announced_as_one_image(): + """A screen reader reading out 40 individual emoji is useless.""" + assert 'role="img"' in CARD + assert "sticker_chart.stickers_alt" in CARD + + +def _keys_used() -> set[str]: + return set(re.findall(r"""_t\(\s*["']([a-z_0-9.]+)["']""", CARD)) + + +def test_every_translation_key_it_uses_exists(): + used = _keys_used() + assert used, "no translation keys found — did _t() get renamed?" + for name in sorted(p.name for p in LOCALES.glob("*.json")): + with open(LOCALES / name, encoding="utf-8") as fh: + catalogue = json.load(fh) + missing = sorted(k for k in used if k not in catalogue) + assert missing == [], f"{name} is missing: {missing}" + + +def test_no_hardcoded_english_in_the_rendered_copy(): + """Every child-facing string goes through _t so the chart speaks the + household's language.""" + for phrase in ("Sticker Chart", "jobs done", "GOAL REACHED", "to the reward"): + rendered = re.findall(rf"html`[^`]*{re.escape(phrase)}", CARD) + assert rendered == [], f"hardcoded {phrase!r} in a template: {rendered}" + + +# ── The tiles ──────────────────────────────────────────────────────────────── + + +def test_tiles_complete_through_the_same_path_as_the_child_card(): + """Pressing the button entity keeps HA automations on it firing; the + service is the fallback before the entity is registered.""" + assert '__taskmate_find_button(this.hass, child.id, "complete", row.chore.id)' in CARD + assert '"button", "press"' in CARD + assert '"taskmate", "complete_chore"' in CARD + + +def test_card_filters_specific_days_itself(): + """The backend's availability matrix deliberately leaves day-of-week to the + cards; skipping it would show Monday-only chores every day.""" + assert 'chore.schedule_mode === "specific_days"' in CARD + assert "days.includes(todayDow)" in CARD + + +def test_a_finished_sticker_never_vanishes(): + """A recurring chore can flip unavailable once done; it must stay on the + chart as a finished sticker rather than disappearing.""" + assert "perChild[childId] === false && mine.length === 0" in CARD + + +def test_optimistic_ticks_are_reconciled_not_just_ignored(): + """A leftover optimistic tick resurrected a 'done' tile after an undo + (found in the browser harness). willUpdate must drop it once HA catches up.""" + assert "willUpdate(changedProps)" in CARD + assert "landed < opt.base + opt.count" in CARD + + +def test_undo_goes_through_the_parent_gated_service(): + assert '"taskmate", "reject_chore"' in CARD + assert "completion_id: latest.completion_id" in CARD + + +def test_photo_evidence_chores_are_not_faked_from_a_tile(): + """A tile cannot capture a photo, so it must not submit one without.""" + assert "row.chore.require_photo" in CARD + assert "sticker_chart.needs_photo" in CARD + + +def test_claiming_a_reward_takes_two_taps(): + """Spending a child's stars must not happen on one stray tap.""" + assert "CONFIRM_MS" in CARD + assert "this._confirming !== child.id" in CARD + assert '"taskmate", "claim_reward"' in CARD + + +def test_weekly_goal_counts_this_weeks_jobs(): + """`goal: week` fills the blocks with stars earned since Monday out of all + the week's jobs can give (from the overview sensor), so stars earned past a + reward's cost can't pile up unseen.""" + assert 'this.config.goal === "week"' in CARD + assert "child.week_points_available" in CARD + assert "child.week_points_earned" in CARD + assert "sticker_chart.week_goal" in CARD + assert "sticker_chart.week_perfect" in CARD + + +def test_weekly_goal_is_offered_in_the_editor_and_reward_stays_default(): + assert 'name: "goal"' in CARD + assert 'if (key === "goal" && value === "reward") continue;' in CARD + + +def test_big_goals_keep_the_block_look(): + """A goal past MAX_BLOCKS used to switch to a plain bar, so one child's + chart looked unlike the other's. Each block now stands for several stars.""" + assert 'class="bar"' not in CARD + assert "Math.ceil(goal / MAX_BLOCKS)" in CARD + assert "sticker_chart.block_key" in CARD + + +def test_a_past_day_is_ticked_through_the_backdating_service(): + """The admin panel shows this card for a past day (`config.date`): ticks + must log that day as a parent, never as today.""" + assert "completed_date: pastDate" in CARD + assert "as_parent: true" in CARD + assert "this.dayCompletions" in CARD + assert '"taskmate-day-changed"' in CARD + # rotation and team chores can't be logged for a past day, so aren't offered + assert 'if (past && (mode !== "everyone" || chore.team)) continue;' in CARD + + +def test_the_card_finds_lit_outside_a_dashboard(): + """In the admin panel there is no hui-masonry-view; the card must still load.""" + assert '"home-assistant-main"' in CARD + assert 'hasOwnProperty.call(C.prototype, "html")' in CARD + + +def test_a_refused_tap_explains_itself(): + """A tablet signed in as the wrong user gets "Unauthorized" from HA; the + chart must say what that means instead of showing the raw error.""" + assert "_failed(err, child)" in CARD + assert "sticker_chart.not_allowed" in CARD + assert "/unauthori|not authorized|admin|permission|forbidden/i" in CARD diff --git a/tests/test_week_progress.py b/tests/test_week_progress.py new file mode 100644 index 00000000..ea2f5472 --- /dev/null +++ b/tests/test_week_progress.py @@ -0,0 +1,146 @@ +"""This week's points earned vs. the most on offer, per child. + +Feeds the sticker chart's weekly goal (`goal: week`), where the blocks fill +towards every star the week's jobs can give instead of a reward's cost, so +stars earned past the goal can't pile up unseen. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from unittest.mock import patch + +import custom_components.taskmate.coord_points as coord_points +from custom_components.taskmate.models import Child, Chore, ChoreCompletion + +from .test_coordinator_logic import _make_coord + +# Wednesday 16 Sept 2026: the week is Mon 14 to Sun 20. +NOW = datetime(2026, 9, 16, 12, 0, tzinfo=UTC) +MILLIE = Child(name="Millie", id="millie") +EVIE = Child(name="Evie", id="evie") + + +def _chore(**kw): + base = {"name": "Job", "points": 1, "schedule_mode": "specific_days", "due_days": []} + base.update(kw) + return Chore(**base) + + +def _done(child_id, when, points, approved=True, subtask=""): + return ChoreCompletion( + chore_id="c", + child_id=child_id, + completed_at=when, + approved=approved, + points_awarded=points, + bonus_subtask_id=subtask, + ) + + +def _progress(chores, completions=(), settings=None, children=(MILLIE, EVIE)): + coord = _make_coord(settings={"weekend_multiplier": "1.0", **(settings or {})}, children=list(children)) + with patch.object(coord_points.dt_util, "_now", NOW): + return coord.week_progress(list(children), chores, list(completions)) + + +def test_every_day_chore_offers_seven_days(): + assert _progress([_chore(points=2)])["millie"]["available"] == 14 + + +def test_specific_days_only_count_their_days(): + chore = _chore(due_days=["monday", "wednesday", "friday"]) + assert _progress([chore])["millie"]["available"] == 3 + + +def test_daily_limit_multiplies_what_is_on_offer(): + assert _progress([_chore(points=5, daily_limit=3)])["evie"]["available"] == 105 + + +def test_weekend_multiplier_is_included(): + weekend = _chore(points=1, due_days=["saturday", "sunday"]) + assert _progress([weekend], settings={"weekend_multiplier": "2.0"})["millie"]["available"] == 4 + + +def test_only_the_childs_own_chores_count(): + chores = [_chore(points=1, assigned_to=["evie"]), _chore(points=1, disabled_for=["evie"])] + result = _progress(chores) + assert result["evie"]["available"] == 7 + assert result["millie"]["available"] == 7 + + +def test_rotation_chores_are_left_out(): + chore = _chore(points=3, assignment_mode="round_robin") + assert _progress([chore])["millie"]["available"] == 0 + + +def test_planned_vacation_days_are_left_out(): + settings = {"vacation_periods": [{"id": "v", "name": "Away", "start": "2026-09-18", "end": "2026-09-20"}]} + assert _progress([_chore()], settings=settings)["millie"]["available"] == 4 + + +def test_earned_counts_approved_points_since_monday_only(): + completions = [ + _done("millie", datetime(2026, 9, 14, 8, 0, tzinfo=UTC), 3), + _done("millie", datetime(2026, 9, 16, 8, 0, tzinfo=UTC), 2), + _done("millie", datetime(2026, 9, 13, 8, 0, tzinfo=UTC), 50), # last Sunday + _done("millie", datetime(2026, 9, 15, 8, 0, tzinfo=UTC), 9, approved=False), + _done("millie", datetime(2026, 9, 15, 8, 0, tzinfo=UTC), 4, subtask="extra"), + _done("evie", datetime(2026, 9, 15, 8, 0, tzinfo=UTC), 7), + ] + result = _progress([_chore()], completions) + assert result["millie"]["earned"] == 5 + assert result["evie"]["earned"] == 7 + + +# ── Weekly-target (#883) and open-ended chores ─────────────────────────────── + + +def test_a_weekly_target_chore_offers_its_quota_once_not_a_slot_per_day(): + # Every-day schedule, but the child picks the days: 2 times a week at 3 points. + assert _progress([_chore(points=3, weekly_target=2)])["millie"]["available"] == 6 + + +def test_a_weekly_target_chore_ignores_the_daily_limit_and_weekend_multiplier(): + chore = _chore(points=3, weekly_target=2, daily_limit=3) + assert _progress([chore], settings={"weekend_multiplier": "2.0"})["millie"]["available"] == 6 + + +def test_an_open_ended_chore_adds_nothing_to_whats_available_but_its_pay_still_counts(): + chore = _chore(points=0, open_ended=True) + done = [_done("millie", datetime(2026, 9, 15, 9, tzinfo=UTC), 7)] + out = _progress([chore], done)["millie"] + assert out["available"] == 0 + assert out["earned"] == 7 + + +def test_a_teamwork_chore_offers_each_members_share_not_the_whole_pot(): + """Copilot review on #1063: a split teamwork chore pays each child half.""" + split = _chore(points=4, team_size=2, team_points_mode="split") + assert _progress([split])["millie"]["available"] == 2 * 7 + + +def test_a_teamwork_chore_includes_its_team_bonus(): + each = _chore(points=4, team_size=2, team_points_mode="each", team_bonus=1) + assert _progress([each])["millie"]["available"] == 5 * 7 + + +BIRTHDAY_GIRL = Child(name="Millie", id="millie", birthday="2016-09-17") + + +def test_a_birthday_doubles_what_that_day_offers(): + # Thursday 17th is her birthday: 6 ordinary days plus one at 2x. + progress = _progress([_chore(points=2)], settings={"birthday_points_multiplier": "2"}, children=(BIRTHDAY_GIRL,)) + assert progress["millie"]["available"] == 16 + + +def test_a_birthday_day_off_drops_that_days_optional_chores(): + settings = {"birthday_points_multiplier": "2", "birthday_chores_off": "true"} + progress = _progress([_chore(points=2)], settings=settings, children=(BIRTHDAY_GIRL,)) + assert progress["millie"]["available"] == 12 + + +def test_a_birthday_day_off_still_counts_mandatory_chores(): + settings = {"birthday_points_multiplier": "2", "birthday_chores_off": "true"} + progress = _progress([_chore(points=2, mandatory=True)], settings=settings, children=(BIRTHDAY_GIRL,)) + assert progress["millie"]["available"] == 16