From a321d6e7577c14a360a2454b904e39493d591bcd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 14:52:57 +0000 Subject: [PATCH 1/6] Show a child's photo from their person entity Adds an optional per-child picture entity (normally the child's person.*). Its picture is read on every update and exposed as avatar_image, and a shared __taskmate_child_visual helper makes every card prefer the photo and fall back to the MDI avatar. The panel's child dialog gets a "Photo from" picker. --- custom_components/taskmate/coord_avatars.py | 19 +++ custom_components/taskmate/coordinator.py | 2 + custom_components/taskmate/models.py | 8 ++ custom_components/taskmate/sensor.py | 22 ++++ custom_components/taskmate/websocket.py | 5 + .../taskmate/www/locales/da.json | 2 + .../taskmate/www/locales/de.json | 2 + .../taskmate/www/locales/en-GB.json | 2 + .../taskmate/www/locales/en.json | 2 + .../taskmate/www/locales/fr.json | 2 + .../taskmate/www/locales/nb.json | 2 + .../taskmate/www/locales/nn.json | 2 + .../taskmate/www/locales/pl.json | 2 + .../taskmate/www/locales/pt-BR.json | 2 + .../taskmate/www/locales/pt.json | 2 + .../taskmate/www/taskmate-approvals-card.js | 21 +-- .../taskmate/www/taskmate-calendar-card.js | 17 ++- .../taskmate/www/taskmate-child-card.js | 29 +++-- .../taskmate/www/taskmate-design.js | 31 ++++- .../taskmate/www/taskmate-incentive-card.js | 20 ++- .../taskmate/www/taskmate-leaderboard-card.js | 22 +++- .../taskmate/www/taskmate-overview-card.js | 23 ++-- .../taskmate/www/taskmate-panel.js | 11 +- .../www/taskmate-parent-dashboard-card.js | 42 +++--- .../taskmate/www/taskmate-points-card.js | 28 ++-- .../www/taskmate-points-display-card.js | 21 ++- .../taskmate/www/taskmate-reorder-card.js | 10 +- .../www/taskmate-reward-progress-card.js | 22 +++- .../taskmate/www/taskmate-rewards-card.js | 20 ++- .../taskmate/www/taskmate-streak-card.js | 23 ++-- .../taskmate/www/taskmate-weekly-card.js | 23 ++-- tests/frontend/harness.cjs | 8 ++ tests/test_child_picture_entity.py | 123 ++++++++++++++++++ 33 files changed, 443 insertions(+), 127 deletions(-) create mode 100644 tests/test_child_picture_entity.py diff --git a/custom_components/taskmate/coord_avatars.py b/custom_components/taskmate/coord_avatars.py index ff0be77b..06024f6e 100644 --- a/custom_components/taskmate/coord_avatars.py +++ b/custom_components/taskmate/coord_avatars.py @@ -36,6 +36,25 @@ 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. + """ + entity_id = (getattr(child, "picture_entity", "") or "").strip() + if not entity_id: + 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/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/models.py b/custom_components/taskmate/models.py index b620ccb9..01d01f43 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, diff --git a/custom_components/taskmate/sensor.py b/custom_components/taskmate/sensor.py index 40d51760..b75645dd 100644 --- a/custom_components/taskmate/sensor.py +++ b/custom_components/taskmate/sensor.py @@ -142,6 +142,9 @@ 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}, # Sent back to redo by an inspection (#981): not "done" on the card. "inspection_redo_ids": _inspection_redo_ids(coordinator), } @@ -217,6 +220,8 @@ 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, ""), "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 +703,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 +1583,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 +1624,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 +1671,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, diff --git a/custom_components/taskmate/websocket.py b/custom_components/taskmate/websocket.py index c591a7b7..897aa296 100644 --- a/custom_components/taskmate/websocket.py +++ b/custom_components/taskmate/websocket.py @@ -490,6 +490,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 +512,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 +531,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 +560,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"]) diff --git a/custom_components/taskmate/www/locales/da.json b/custom_components/taskmate/www/locales/da.json index 5f54fb73..dd164c76 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.", diff --git a/custom_components/taskmate/www/locales/de.json b/custom_components/taskmate/www/locales/de.json index 9445097b..5549e071 100644 --- a/custom_components/taskmate/www/locales/de.json +++ b/custom_components/taskmate/www/locales/de.json @@ -1980,6 +1980,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.", diff --git a/custom_components/taskmate/www/locales/en-GB.json b/custom_components/taskmate/www/locales/en-GB.json index 4fd7dc37..4ea97d9e 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.", diff --git a/custom_components/taskmate/www/locales/en.json b/custom_components/taskmate/www/locales/en.json index 4fd7dc37..4ea97d9e 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 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.", diff --git a/custom_components/taskmate/www/locales/fr.json b/custom_components/taskmate/www/locales/fr.json index 8d8af266..41edcb47 100644 --- a/custom_components/taskmate/www/locales/fr.json +++ b/custom_components/taskmate/www/locales/fr.json @@ -1980,6 +1980,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.", diff --git a/custom_components/taskmate/www/locales/nb.json b/custom_components/taskmate/www/locales/nb.json index e739d6f8..2adf998c 100644 --- a/custom_components/taskmate/www/locales/nb.json +++ b/custom_components/taskmate/www/locales/nb.json @@ -1980,6 +1980,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.", diff --git a/custom_components/taskmate/www/locales/nn.json b/custom_components/taskmate/www/locales/nn.json index 56a1b693..2a900994 100644 --- a/custom_components/taskmate/www/locales/nn.json +++ b/custom_components/taskmate/www/locales/nn.json @@ -1980,6 +1980,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.", diff --git a/custom_components/taskmate/www/locales/pl.json b/custom_components/taskmate/www/locales/pl.json index 8ef97537..f8eca63e 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.", diff --git a/custom_components/taskmate/www/locales/pt-BR.json b/custom_components/taskmate/www/locales/pt-BR.json index 8053106b..93fa3269 100644 --- a/custom_components/taskmate/www/locales/pt-BR.json +++ b/custom_components/taskmate/www/locales/pt-BR.json @@ -1980,6 +1980,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.", diff --git a/custom_components/taskmate/www/locales/pt.json b/custom_components/taskmate/www/locales/pt.json index 8be54547..b9326ed8 100644 --- a/custom_components/taskmate/www/locales/pt.json +++ b/custom_components/taskmate/www/locales/pt.json @@ -1980,6 +1980,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.", 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-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..bb479adb 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}
`; } 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-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..d48e435e 100644 --- a/custom_components/taskmate/www/taskmate-panel.js +++ b/custom_components/taskmate/www/taskmate-panel.js @@ -1973,6 +1973,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 +1986,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 +2037,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 @@ -9846,6 +9849,8 @@ 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")), + this._entityPickerField(this._t("panel.child_picture_entity_label"), "picture_entity", d.picture_entity, + ["person"], this._t("panel.child_picture_entity_hint")), this._kioskPinField(d), ].join(""), ` 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-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-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..2b88218f 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() {}, diff --git a/tests/test_child_picture_entity.py b/tests/test_child_picture_entity.py new file mode 100644 index 00000000..36af4144 --- /dev/null +++ b/tests/test_child_picture_entity.py @@ -0,0 +1,123 @@ +"""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 + +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" From e9fe6ba23d55903347826801ab653693d5ad09d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 06:16:50 +0000 Subject: [PATCH 2/6] Show a changed child photo straight away A child's photo is read from their picture entity, but the sensors' shared context was cached per coordinator update, so a photo changed on the person entity stayed stale until the next TaskMate refresh. Track each child's picture entity and refresh the sensors when its picture really changes, and make the shared cache aware of external entity changes. --- .../taskmate/coord_assignments.py | 25 +++++++++ custom_components/taskmate/sensor.py | 7 ++- tests/test_child_picture_entity.py | 54 +++++++++++++++++++ tests/test_sensor_attributes.py | 10 ++++ 4 files changed, 94 insertions(+), 2 deletions(-) diff --git a/custom_components/taskmate/coord_assignments.py b/custom_components/taskmate/coord_assignments.py index fe304e78..adb0374a 100644 --- a/custom_components/taskmate/coord_assignments.py +++ b/custom_components/taskmate/coord_assignments.py @@ -53,6 +53,12 @@ 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. + self._tracked_picture_entities = { + c.picture_entity for c in self.storage.get_children() if getattr(c, "picture_entity", "") + } + @callback def _availability_state_changed(self, event: Any) -> None: """Cheap bus-filter: react only when a tracked entity changes. @@ -66,6 +72,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 +88,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/sensor.py b/custom_components/taskmate/sensor.py index b75645dd..95b5d52f 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", []) @@ -148,7 +151,7 @@ def _compute_common(coordinator: TaskMateCoordinator) -> dict: # 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 diff --git a/tests/test_child_picture_entity.py b/tests/test_child_picture_entity.py index 36af4144..cf79d142 100644 --- a/tests/test_child_picture_entity.py +++ b/tests/test_child_picture_entity.py @@ -121,3 +121,57 @@ def test_empty_when_none_chosen(self): 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 diff --git a/tests/test_sensor_attributes.py b/tests/test_sensor_attributes.py index 47b05eba..2f09503c 100644 --- a/tests/test_sensor_attributes.py +++ b/tests/test_sensor_attributes.py @@ -734,3 +734,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 From d651211e40aa8074b24554e8d48a20b7296e9ecc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 02:15:20 +0000 Subject: [PATCH 3/6] Show the photo on every card that draws a child, and track trimmed ids Addresses the Copilot review on #1062: - The chore board, kiosk and recap cards now draw a child through the shared face helper, so a child's photo shows there too and a child without one keeps their avatar. (The badges card has no per-child avatar to draw: its sensor sends none, so it always shows the generic icon.) - The set of tracked picture entities trims each id the same way the photo lookup reads it, so a stray space can't leave a photo untracked. --- .../taskmate/coord_assignments.py | 4 +- .../taskmate/www/taskmate-chore-board-card.js | 9 ++-- .../taskmate/www/taskmate-kiosk-card.js | 8 +++- .../taskmate/www/taskmate-recap-card.js | 11 +++-- .../taskmate-child-photo-cards.test.cjs | 47 +++++++++++++++++++ tests/test_child_picture_entity.py | 8 ++++ 6 files changed, 76 insertions(+), 11 deletions(-) create mode 100644 tests/frontend/taskmate-child-photo-cards.test.cjs diff --git a/custom_components/taskmate/coord_assignments.py b/custom_components/taskmate/coord_assignments.py index adb0374a..893289db 100644 --- a/custom_components/taskmate/coord_assignments.py +++ b/custom_components/taskmate/coord_assignments.py @@ -55,8 +55,10 @@ def _refresh_tracked_availability_entities(self) -> None: # 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 = { - c.picture_entity for c in self.storage.get_children() if getattr(c, "picture_entity", "") + entity for c in self.storage.get_children() if (entity := (getattr(c, "picture_entity", "") or "").strip()) } @callback 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-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-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/tests/frontend/taskmate-child-photo-cards.test.cjs b/tests/frontend/taskmate-child-photo-cards.test.cjs new file mode 100644 index 00000000..fc381098 --- /dev/null +++ b/tests/frontend/taskmate-child-photo-cards.test.cjs @@ -0,0 +1,47 @@ +// 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(`/); +}); diff --git a/tests/test_child_picture_entity.py b/tests/test_child_picture_entity.py index cf79d142..c4360abc 100644 --- a/tests/test_child_picture_entity.py +++ b/tests/test_child_picture_entity.py @@ -175,3 +175,11 @@ 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 From 8f90c082c023e49e67178f66d74f590dbb3deddb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 08:34:54 +0000 Subject: [PATCH 4/6] Show the photo on the bounty, auction and teamwork faces too Addresses the second Copilot review on #1062: the bounty board and auction cards drew every child as an initial, and the child card's teamwork row read the avatar directly. All three now use the shared face helper, so a child's photo shows there; the bounty and auction cards keep the initial for a child without one. --- .../taskmate/www/taskmate-auction-card.js | 9 ++++-- .../taskmate/www/taskmate-bounty-card.js | 9 ++++-- .../taskmate/www/taskmate-child-card.js | 9 +++--- .../taskmate-child-photo-cards.test.cjs | 28 +++++++++++++++++++ 4 files changed, 47 insertions(+), 8 deletions(-) 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-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-child-card.js b/custom_components/taskmate/www/taskmate-child-card.js index bb479adb..00ab57c0 100644 --- a/custom_components/taskmate/www/taskmate-child-card.js +++ b/custom_components/taskmate/www/taskmate-child-card.js @@ -2565,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/tests/frontend/taskmate-child-photo-cards.test.cjs b/tests/frontend/taskmate-child-photo-cards.test.cjs index fc381098..9ad0be2e 100644 --- a/tests/frontend/taskmate-child-photo-cards.test.cjs +++ b/tests/frontend/taskmate-child-photo-cards.test.cjs @@ -45,3 +45,31 @@ test("the recap shows a child's photo, and their avatar without one", () => { 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(`/); +}); From ca8e3ec802c8ba771a18cd072b04275597da34c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 09:42:11 +0000 Subject: [PATCH 5/6] Badges card shows the child's photo; the photo picker offers any entity with a picture Addresses the third Copilot review on #1062: - The badges sensor now says whose badges they are (child_name, child_avatar, avatar_image), and the card draws that face through the shared helper. Before, the sensor sent neither, so the card always showed a generic icon. - "Photo from" lists any entity that has a picture (person, image, camera...) instead of person entities only, matching what the backend accepts. --- custom_components/taskmate/sensor.py | 4 ++++ .../taskmate/www/taskmate-badges-card.js | 17 ++++++++++++++--- .../taskmate/www/taskmate-panel.js | 10 +++++++--- .../taskmate-child-photo-cards.test.cjs | 16 ++++++++++++++++ tests/test_sensor_attributes.py | 12 ++++++++++++ 5 files changed, 53 insertions(+), 6 deletions(-) diff --git a/custom_components/taskmate/sensor.py b/custom_components/taskmate/sensor.py index 95b5d52f..d40ae026 100644 --- a/custom_components/taskmate/sensor.py +++ b/custom_components/taskmate/sensor.py @@ -1894,6 +1894,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/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-panel.js b/custom_components/taskmate/www/taskmate-panel.js index d48e435e..0cf6b8e9 100644 --- a/custom_components/taskmate/www/taskmate-panel.js +++ b/custom_components/taskmate/www/taskmate-panel.js @@ -9849,8 +9849,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, - ["person"], this._t("panel.child_picture_entity_hint")), + [], this._t("panel.child_picture_entity_hint"), { withPicture: true }), this._kioskPinField(d), ].join(""), ` @@ -10921,13 +10923,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 ? `
@@ -10954,6 +10956,8 @@ 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. + if (picker.dataset.epPicture && !st.attributes?.entity_picture) return false; if (!query) return true; const fn = st.attributes?.friendly_name || ""; return id.toLowerCase().includes(query) || fn.toLowerCase().includes(query); diff --git a/tests/frontend/taskmate-child-photo-cards.test.cjs b/tests/frontend/taskmate-child-photo-cards.test.cjs index 9ad0be2e..5d49f542 100644 --- a/tests/frontend/taskmate-child-photo-cards.test.cjs +++ b/tests/frontend/taskmate-child-photo-cards.test.cjs @@ -73,3 +73,19 @@ test("a teamwork chore's joined children show their photos on the child card", ( 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 \}/); + assert.match(src, /if \(picker\.dataset\.epPicture && !st\.attributes\?\.entity_picture\) return false;/); +}); diff --git a/tests/test_sensor_attributes.py b/tests/test_sensor_attributes.py index 2f09503c..97d5384a 100644 --- a/tests/test_sensor_attributes.py +++ b/tests/test_sensor_attributes.py @@ -384,6 +384,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" From e439053079c37249f6d36f64f84e4541667fe8d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 10:10:06 +0000 Subject: [PATCH 6/6] Photo picker: leave out TaskMate's own child sensors TaskMate's child sensors show the photo chosen for the child, so picking one as the source made the face refer to itself and go stale. The picker no longer lists them and the backend ignores one if it was already saved. The hint now says any entity with a picture can be used, the child's person being the usual choice. --- custom_components/taskmate/coord_avatars.py | 10 +++++++ .../taskmate/www/locales/en.json | 2 +- .../taskmate/www/taskmate-panel.js | 9 +++++-- .../taskmate-child-photo-cards.test.cjs | 10 ++++++- tests/test_child_picture_entity.py | 26 ++++++++++++++++++- 5 files changed, 52 insertions(+), 5 deletions(-) diff --git a/custom_components/taskmate/coord_avatars.py b/custom_components/taskmate/coord_avatars.py index 06024f6e..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 @@ -46,10 +50,16 @@ def child_avatar_image(self, child) -> str: 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 "" diff --git a/custom_components/taskmate/www/locales/en.json b/custom_components/taskmate/www/locales/en.json index 4ea97d9e..ec557728 100644 --- a/custom_components/taskmate/www/locales/en.json +++ b/custom_components/taskmate/www/locales/en.json @@ -779,7 +779,7 @@ "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_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.", diff --git a/custom_components/taskmate/www/taskmate-panel.js b/custom_components/taskmate/www/taskmate-panel.js index 0cf6b8e9..1767b184 100644 --- a/custom_components/taskmate/www/taskmate-panel.js +++ b/custom_components/taskmate/www/taskmate-panel.js @@ -10956,8 +10956,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. - if (picker.dataset.epPicture && !st.attributes?.entity_picture) 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/tests/frontend/taskmate-child-photo-cards.test.cjs b/tests/frontend/taskmate-child-photo-cards.test.cjs index 5d49f542..5d417874 100644 --- a/tests/frontend/taskmate-child-photo-cards.test.cjs +++ b/tests/frontend/taskmate-child-photo-cards.test.cjs @@ -87,5 +87,13 @@ test("the photo picker offers any entity with a picture, not only people", () => 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 \}/); - assert.match(src, /if \(picker\.dataset\.epPicture && !st\.attributes\?\.entity_picture\) return false;/); + 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/test_child_picture_entity.py b/tests/test_child_picture_entity.py index c4360abc..95c4580a 100644 --- a/tests/test_child_picture_entity.py +++ b/tests/test_child_picture_entity.py @@ -12,8 +12,9 @@ from __future__ import annotations -from unittest.mock import MagicMock +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 @@ -183,3 +184,26 @@ def test_a_stray_space_in_the_stored_id_is_still_tracked(self): 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