From 060192fea97af46955674a8b17bc60aee5592f6f Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Wed, 14 Oct 2026 19:20:27 +0000 Subject: [PATCH] feat(children): adult profiles for parents who log their own chores (#1061) A profile can now be marked as an adult. A grown-up's own completions are approved straight away (normal chores, bonus sub-tasks and timed sessions), so two parents no longer have to approve each other's work. Children's completions are unchanged. Family-wide cards leave adult profiles out unless the card sets show_adults: true, and then list them after the kids. The option appears in the card editor once the family has an adult. A card pinned to one child_id is unaffected, so an adult can still have a card of their own. The kiosk and chore board treat an explicitly picked adult as opted in. Closes #1061 --- custom_components/taskmate/coord_chores.py | 15 ++- custom_components/taskmate/coord_timed.py | 17 ++-- custom_components/taskmate/coordinator.py | 2 + custom_components/taskmate/models.py | 5 + custom_components/taskmate/sensor.py | 2 + custom_components/taskmate/websocket.py | 5 + .../taskmate/www/locales/da.json | 5 + .../taskmate/www/locales/de.json | 5 + .../taskmate/www/locales/en-GB.json | 5 + .../taskmate/www/locales/en.json | 5 + .../taskmate/www/locales/fr.json | 5 + .../taskmate/www/locales/nb.json | 5 + .../taskmate/www/locales/nn.json | 5 + .../taskmate/www/locales/pl.json | 5 + .../taskmate/www/locales/pt-BR.json | 5 + .../taskmate/www/locales/pt.json | 5 + .../taskmate/www/taskmate-attr-resolver.js | 28 ++++++ .../taskmate/www/taskmate-calendar-card.js | 8 +- .../taskmate/www/taskmate-chore-board-card.js | 2 +- .../taskmate/www/taskmate-graph-card.js | 8 +- .../taskmate/www/taskmate-kiosk-card.js | 4 +- .../taskmate/www/taskmate-leaderboard-card.js | 8 +- .../taskmate/www/taskmate-overview-card.js | 8 +- .../taskmate/www/taskmate-panel.js | 12 ++- .../www/taskmate-parent-dashboard-card.js | 18 +++- .../taskmate/www/taskmate-points-card.js | 6 +- .../www/taskmate-points-display-card.js | 8 +- .../www/taskmate-reward-progress-card.js | 8 +- .../www/taskmate-sticker-chart-card.js | 6 +- .../taskmate/www/taskmate-streak-card.js | 8 +- .../taskmate/www/taskmate-weekly-card.js | 8 +- .../frontend/taskmate-adult-profiles.test.cjs | 60 ++++++++++++ tests/test_adult_profiles.py | 95 +++++++++++++++++++ 33 files changed, 339 insertions(+), 52 deletions(-) create mode 100644 tests/frontend/taskmate-adult-profiles.test.cjs create mode 100644 tests/test_adult_profiles.py diff --git a/custom_components/taskmate/coord_chores.py b/custom_components/taskmate/coord_chores.py index 082f60d5..6f63c501 100644 --- a/custom_components/taskmate/coord_chores.py +++ b/custom_components/taskmate/coord_chores.py @@ -929,7 +929,10 @@ async def async_complete_chore( return await self._async_join_team_chore(chore, child, now, as_parent=as_parent, photo_url=photo_url) # Open-ended completions always go to a parent: the child suggested the # point value, so self-awarding it would let them set their own pay. - auto_approve = as_parent or (not chore.requires_approval and not requires_photo and not is_open_ended) + # A grown-up's own completion never waits for approval (#1061). + auto_approve = ( + as_parent or child.is_adult or (not chore.requires_approval and not requires_photo and not is_open_ended) + ) # Won at auction (#982): the winner is paid their winning bid for this # occurrence instead of the chore's own points — exactly what they bid. auction_price = self.auction_price_for(chore, child_id, today) @@ -1401,12 +1404,14 @@ async def async_complete_bonus_subtask( if already_done: raise ValueError(f"Bonus sub-task '{subtask.name}' already completed today.") + # A grown-up's own sub-task never waits for approval (#1061). + needs_approval = chore.requires_approval and not child.is_adult completion = ChoreCompletion( chore_id=chore_id, child_id=child_id, completed_at=now, - approved=not chore.requires_approval, - points_awarded=subtask.points if not chore.requires_approval else 0, + approved=not needs_approval, + points_awarded=subtask.points if not needs_approval else 0, submitted_points=subtask.points, bonus_subtask_id=bonus_subtask_id, child_undo_allowed=by_child and undo_window_seconds(self.storage) > 0, @@ -1417,7 +1422,7 @@ async def async_complete_bonus_subtask( # awarding can suspend. self.storage.add_completion(completion) - if not chore.requires_approval: + if not needs_approval: total_awarded = await self._award_points(child, subtask.points, skip_streak=True) completion.approved = True completion.approved_at = dt_util.now() @@ -1425,7 +1430,7 @@ async def async_complete_bonus_subtask( self.storage.update_completion(completion) await self.storage.async_save() - if chore.requires_approval: + if needs_approval: await self._async_notify_pending_approval( child.name, f"{chore.name} › {subtask.name}", subtask.points, completion_id=completion.id ) diff --git a/custom_components/taskmate/coord_timed.py b/custom_components/taskmate/coord_timed.py index 51dd281d..b75f6f11 100644 --- a/custom_components/taskmate/coord_timed.py +++ b/custom_components/taskmate/coord_timed.py @@ -123,19 +123,21 @@ async def async_stop_timed_task(self, chore_id: str, child_id: str) -> None: rate_seconds = chore.timed_rate_minutes * 60 pts = (total_seconds // rate_seconds) * chore.timed_rate_points if rate_seconds > 0 else 0 + # A grown-up's own session never waits for approval (#1061). + needs_approval = chore.requires_approval and not child.is_adult completion = ChoreCompletion( chore_id=chore_id, child_id=child_id, completed_at=now, - approved=not chore.requires_approval, - points_awarded=pts if not chore.requires_approval else 0, + approved=not needs_approval, + points_awarded=pts if not needs_approval else 0, submitted_points=pts, timed_duration_seconds=total_seconds, # The child's own stopped session: theirs to undo in the window (#918). child_undo_allowed=undo_window_seconds(self.storage) > 0, ) - if not chore.requires_approval: + if not needs_approval: total_awarded = await self._award_points(child, pts) completion.approved = True completion.approved_at = dt_util.now() @@ -147,7 +149,7 @@ async def async_stop_timed_task(self, chore_id: str, child_id: str) -> None: await self.storage.async_save() - if chore.requires_approval: + if needs_approval: await self._async_notify_pending_approval( child.name, chore.name, @@ -252,17 +254,18 @@ async def _async_stop_stale_timed_sessions(self) -> None: rate_seconds = chore.timed_rate_minutes * 60 pts = (total_seconds // rate_seconds) * chore.timed_rate_points if rate_seconds > 0 else 0 + needs_approval = chore.requires_approval and not child.is_adult if total_seconds > 0: completion = ChoreCompletion( chore_id=session.chore_id, child_id=session.child_id, completed_at=dt_util.now(), - approved=not chore.requires_approval, - points_awarded=pts if not chore.requires_approval else 0, + approved=not needs_approval, + points_awarded=pts if not needs_approval else 0, submitted_points=pts, timed_duration_seconds=total_seconds, ) - if not chore.requires_approval: + if not needs_approval: total_awarded = await self._award_points(child, pts) completion.approved = True completion.approved_at = dt_util.now() diff --git a/custom_components/taskmate/coordinator.py b/custom_components/taskmate/coordinator.py index 616b4049..9e3b6ee8 100644 --- a/custom_components/taskmate/coordinator.py +++ b/custom_components/taskmate/coordinator.py @@ -990,6 +990,7 @@ async def async_add_child( birthday: str = "", presence_entity: str = "", age_group: str = "", + is_adult: bool = False, refresh: bool = True, ) -> Child: """Add a new child. @@ -1009,6 +1010,7 @@ async def async_add_child( birthday=birthday, presence_entity=presence_entity, age_group=age_group, + is_adult=is_adult, ) self.storage.add_child(child) await self.storage.async_save() diff --git a/custom_components/taskmate/models.py b/custom_components/taskmate/models.py index 13ae9d26..202b64aa 100644 --- a/custom_components/taskmate/models.py +++ b/custom_components/taskmate/models.py @@ -254,6 +254,9 @@ class Child: # Setup wizard age group (#980): one of AGE_GROUPS, or "" when none was # picked (every child that predates the wizard). Only steers suggestions. age_group: str = "" + # A grown-up's profile (#1061): their own completions never wait for + # approval, and cards leave them out unless a card opts in (show_adults). + is_adult: bool = False id: str = field(default_factory=generate_id) @classmethod @@ -280,6 +283,7 @@ def from_dict(cls, data: dict[str, Any]) -> Child: guest_expires_on=str(data.get("guest_expires_on", "") or ""), birthday=str(data.get("birthday", "") or ""), age_group=normalize_age_group(data.get("age_group")), + is_adult=bool(data.get("is_adult", False)), availability_entity=data.get("availability_entity", ""), availability_inverted=data.get("availability_inverted", False), unavailability_entity=data.get("unavailability_entity", ""), @@ -319,6 +323,7 @@ def to_dict(self) -> dict[str, Any]: "guest_expires_on": self.guest_expires_on, "birthday": self.birthday, "age_group": self.age_group, + "is_adult": self.is_adult, "availability_entity": self.availability_entity, "availability_inverted": self.availability_inverted, "unavailability_entity": self.unavailability_entity, diff --git a/custom_components/taskmate/sensor.py b/custom_components/taskmate/sensor.py index 9b243e54..b9e08764 100644 --- a/custom_components/taskmate/sensor.py +++ b/custom_components/taskmate/sensor.py @@ -201,6 +201,8 @@ def _build_children_summary(coordinator: TaskMateCoordinator, common: dict) -> l if getattr(c, "is_guest", False) else {} ), + # Grown-ups (#1061): cards leave these out unless show_adults is set. + **({"is_adult": True} if getattr(c, "is_adult", False) else {}), # Chore roulette (#677): today's pick + spins left, so the card can # show the result and disable the button once the allowance is used. **( diff --git a/custom_components/taskmate/websocket.py b/custom_components/taskmate/websocket.py index 63c10fcc..77f7d91f 100644 --- a/custom_components/taskmate/websocket.py +++ b/custom_components/taskmate/websocket.py @@ -495,6 +495,7 @@ async def _ws_get_state(hass, connection, msg, coordinator): 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)), + vol.Optional("is_adult", default=False): bool, } ) @websocket_api.async_response @@ -517,6 +518,7 @@ async def _ws_add_child(hass, connection, msg, coordinator): birthday=birthday, presence_entity=msg.get("presence_entity", ""), age_group=msg.get("age_group", ""), + is_adult=bool(msg.get("is_adult", False)), ) connection.send_result(msg["id"], {"id": child.id}) @@ -538,6 +540,7 @@ async def _ws_add_child(hass, connection, msg, coordinator): vol.Optional("guest_expires_on"): str, vol.Optional("birthday"): vol.All(str, vol.Length(max=10)), vol.Optional("age_group"): vol.In(("", *AGE_GROUPS)), + vol.Optional("is_adult"): bool, } ) @websocket_api.async_response @@ -573,6 +576,8 @@ async def _ws_update_child(hass, connection, msg, coordinator): existing.presence_entity = msg["presence_entity"] if "age_group" in msg: existing.age_group = msg["age_group"] + if "is_adult" in msg: + existing.is_adult = bool(msg["is_adult"]) if "is_guest" in msg or "guest_expires_on" in msg: # Routed through the coordinator so the expiry is validated and an # archived guest is un-archived when promoted to a family member. diff --git a/custom_components/taskmate/www/locales/da.json b/custom_components/taskmate/www/locales/da.json index 0b081a70..831ba490 100644 --- a/custom_components/taskmate/www/locales/da.json +++ b/custom_components/taskmate/www/locales/da.json @@ -363,6 +363,8 @@ "common.editor.filter_by_child_all": "Alle børn", "common.editor.header_colour": "Overskriftsfarve", "common.editor.header_colour_helper": "Baggrundsfarve på kortets overskrift", + "common.editor.show_adults": "Vis voksne", + "common.editor.show_adults_helper": "Medtag voksenprofiler på dette kort", "common.editor.overview_entity": "Oversigtsentitet", "common.editor.overview_entity_helper": "TaskMates oversigtssensor", "common.editor.show_parent_actions": "Vis forældrehandlinger", @@ -941,6 +943,9 @@ "panel.child_age_group_label": "Aldersgruppe", "panel.child_age_group_none": "Ikke valgt", "panel.child_age_group_hint": "Bestemmer, hvilke pligter opsætningsguiden foreslår. Ændrer ikke andet.", + "panel.child_adult_label": "Voksenprofil", + "panel.child_adult_hint": "Til en forælder eller anden voksen. Pligter, de markerer som udført, tæller med det samme uden godkendelse, og kort viser dem kun, når kortets indstilling \"Vis voksne\" er slået til.", + "panel.child_adult_pill": "Voksen", "wizard.title": "Opsætningsguide", "wizard.run_btn": "Kør opsætningsguiden", "wizard.open_btn": "Åbn guiden", diff --git a/custom_components/taskmate/www/locales/de.json b/custom_components/taskmate/www/locales/de.json index e394a8a3..77199dd5 100644 --- a/custom_components/taskmate/www/locales/de.json +++ b/custom_components/taskmate/www/locales/de.json @@ -353,6 +353,8 @@ "common.editor.filter_by_child_all": "Alle Kinder", "common.editor.header_colour": "Kopfzeilenfarbe", "common.editor.header_colour_helper": "Hintergrundfarbe der Kartenkopfzeile", + "common.editor.show_adults": "Erwachsene anzeigen", + "common.editor.show_adults_helper": "Erwachsenenprofile auf dieser Karte anzeigen", "common.editor.overview_entity": "Übersichtseinheit", "common.editor.overview_entity_helper": "Die TaskMate-Übersichtssensoreinheit", "common.editor.show_parent_actions": "Elternaktionen anzeigen", @@ -889,6 +891,9 @@ "panel.child_age_group_label": "Altersgruppe", "panel.child_age_group_none": "Nicht festgelegt", "panel.child_age_group_hint": "Bestimmt, welche Aufgaben der Einrichtungsassistent vorschlägt. Ändert sonst nichts.", + "panel.child_adult_label": "Erwachsenenprofil", + "panel.child_adult_hint": "Für ein Elternteil oder einen anderen Erwachsenen. Aufgaben, die sie als erledigt markieren, zählen sofort ohne Genehmigung, und Karten zeigen sie nur, wenn die Kartenoption „Erwachsene anzeigen“ aktiviert ist.", + "panel.child_adult_pill": "Erwachsen", "wizard.title": "Einrichtungsassistent", "wizard.run_btn": "Einrichtungsassistent starten", "wizard.open_btn": "Assistent öffnen", diff --git a/custom_components/taskmate/www/locales/en-GB.json b/custom_components/taskmate/www/locales/en-GB.json index bb03b191..20b7f9ac 100644 --- a/custom_components/taskmate/www/locales/en-GB.json +++ b/custom_components/taskmate/www/locales/en-GB.json @@ -363,6 +363,8 @@ "common.editor.filter_by_child_all": "All Children", "common.editor.header_colour": "Header Colour", "common.editor.header_colour_helper": "Card header background colour", + "common.editor.show_adults": "Show adults", + "common.editor.show_adults_helper": "Include adult profiles on this card", "common.editor.overview_entity": "Overview Entity", "common.editor.overview_entity_helper": "The TaskMate overview sensor entity", "common.editor.show_parent_actions": "Show parent actions", @@ -941,6 +943,9 @@ "panel.child_age_group_label": "Age group", "panel.child_age_group_none": "Not set", "panel.child_age_group_hint": "Decides which chores the setup wizard suggests. Changes nothing else.", + "panel.child_adult_label": "Adult profile", + "panel.child_adult_hint": "For a parent or other grown-up. Chores they mark done count straight away with no approval, and cards leave them out unless the card's \"Show adults\" option is on.", + "panel.child_adult_pill": "Adult", "wizard.title": "Setup wizard", "wizard.run_btn": "Run setup wizard", "wizard.open_btn": "Open wizard", diff --git a/custom_components/taskmate/www/locales/en.json b/custom_components/taskmate/www/locales/en.json index bb03b191..20b7f9ac 100644 --- a/custom_components/taskmate/www/locales/en.json +++ b/custom_components/taskmate/www/locales/en.json @@ -363,6 +363,8 @@ "common.editor.filter_by_child_all": "All Children", "common.editor.header_colour": "Header Colour", "common.editor.header_colour_helper": "Card header background colour", + "common.editor.show_adults": "Show adults", + "common.editor.show_adults_helper": "Include adult profiles on this card", "common.editor.overview_entity": "Overview Entity", "common.editor.overview_entity_helper": "The TaskMate overview sensor entity", "common.editor.show_parent_actions": "Show parent actions", @@ -941,6 +943,9 @@ "panel.child_age_group_label": "Age group", "panel.child_age_group_none": "Not set", "panel.child_age_group_hint": "Decides which chores the setup wizard suggests. Changes nothing else.", + "panel.child_adult_label": "Adult profile", + "panel.child_adult_hint": "For a parent or other grown-up. Chores they mark done count straight away with no approval, and cards leave them out unless the card's \"Show adults\" option is on.", + "panel.child_adult_pill": "Adult", "wizard.title": "Setup wizard", "wizard.run_btn": "Run setup wizard", "wizard.open_btn": "Open wizard", diff --git a/custom_components/taskmate/www/locales/fr.json b/custom_components/taskmate/www/locales/fr.json index 853645e8..8eedac16 100644 --- a/custom_components/taskmate/www/locales/fr.json +++ b/custom_components/taskmate/www/locales/fr.json @@ -353,6 +353,8 @@ "common.editor.filter_by_child_all": "Tous les enfants", "common.editor.header_colour": "Couleur de l'en-tête", "common.editor.header_colour_helper": "Couleur d'arrière-plan de l'en-tête de la carte", + "common.editor.show_adults": "Afficher les adultes", + "common.editor.show_adults_helper": "Inclure les profils adultes sur cette carte", "common.editor.overview_entity": "Entité de vue d'ensemble", "common.editor.overview_entity_helper": "L'entité du capteur de vue d'ensemble TaskMate", "common.editor.show_parent_actions": "Afficher les actions parentales", @@ -889,6 +891,9 @@ "panel.child_age_group_label": "Tranche d'âge", "panel.child_age_group_none": "Non définie", "panel.child_age_group_hint": "Détermine les tâches suggérées par l'assistant de configuration. Ne change rien d'autre.", + "panel.child_adult_label": "Profil adulte", + "panel.child_adult_hint": "Pour un parent ou un autre adulte. Les tâches qu'il marque comme faites comptent tout de suite, sans validation, et les cartes ne l'affichent que si l'option « Afficher les adultes » de la carte est activée.", + "panel.child_adult_pill": "Adulte", "wizard.title": "Assistant de configuration", "wizard.run_btn": "Lancer l'assistant de configuration", "wizard.open_btn": "Ouvrir l'assistant", diff --git a/custom_components/taskmate/www/locales/nb.json b/custom_components/taskmate/www/locales/nb.json index 3c3a9fa2..f5cecfbf 100644 --- a/custom_components/taskmate/www/locales/nb.json +++ b/custom_components/taskmate/www/locales/nb.json @@ -353,6 +353,8 @@ "common.editor.filter_by_child_all": "Alle barn", "common.editor.header_colour": "Toppfarge", "common.editor.header_colour_helper": "Bakgrunnsfarge for korttoppen", + "common.editor.show_adults": "Vis voksne", + "common.editor.show_adults_helper": "Ta med voksenprofiler på dette kortet", "common.editor.overview_entity": "Oversiktsentitet", "common.editor.overview_entity_helper": "TaskMate oversiktssensor-entiteten", "common.editor.show_parent_actions": "Vis foreldrehandlinger", @@ -889,6 +891,9 @@ "panel.child_age_group_label": "Aldersgruppe", "panel.child_age_group_none": "Ikke valgt", "panel.child_age_group_hint": "Bestemmer hvilke oppgaver oppsettveiviseren foreslår. Endrer ikke noe annet.", + "panel.child_adult_label": "Voksenprofil", + "panel.child_adult_hint": "For en forelder eller en annen voksen. Oppgaver de markerer som gjort, teller med en gang uten godkjenning, og kort viser dem bare når kortets valg «Vis voksne» er slått på.", + "panel.child_adult_pill": "Voksen", "wizard.title": "Oppsettveiviser", "wizard.run_btn": "Kjør oppsettveiviseren", "wizard.open_btn": "Åpne veiviseren", diff --git a/custom_components/taskmate/www/locales/nn.json b/custom_components/taskmate/www/locales/nn.json index 8f90cafd..d207d552 100644 --- a/custom_components/taskmate/www/locales/nn.json +++ b/custom_components/taskmate/www/locales/nn.json @@ -353,6 +353,8 @@ "common.editor.filter_by_child_all": "Alle born", "common.editor.header_colour": "Toppfarge", "common.editor.header_colour_helper": "Bakgrunnsfarge for korttoppen", + "common.editor.show_adults": "Vis vaksne", + "common.editor.show_adults_helper": "Ta med vaksenprofilar på dette kortet", "common.editor.overview_entity": "Oversiktsentitet", "common.editor.overview_entity_helper": "TaskMate oversiktssensor-entiteten", "common.editor.show_parent_actions": "Vis foreldrehandlingar", @@ -889,6 +891,9 @@ "panel.child_age_group_label": "Aldersgruppe", "panel.child_age_group_none": "Ikkje vald", "panel.child_age_group_hint": "Avgjer kva oppgåver oppsettsvegvisaren føreslår. Endrar ikkje noko anna.", + "panel.child_adult_label": "Vaksenprofil", + "panel.child_adult_hint": "For ein forelder eller ein annan vaksen. Oppgåver dei markerer som gjort, tel med ein gong utan godkjenning, og kort viser dei berre når kortet sitt val «Vis vaksne» er slått på.", + "panel.child_adult_pill": "Vaksen", "wizard.title": "Oppsettsvegvisar", "wizard.run_btn": "Køyr oppsettsvegvisaren", "wizard.open_btn": "Opne vegvisaren", diff --git a/custom_components/taskmate/www/locales/pl.json b/custom_components/taskmate/www/locales/pl.json index d0fae026..d42b6d67 100644 --- a/custom_components/taskmate/www/locales/pl.json +++ b/custom_components/taskmate/www/locales/pl.json @@ -363,6 +363,8 @@ "common.editor.filter_by_child_all": "Wszystkie dzieci", "common.editor.header_colour": "Kolor nagłówka", "common.editor.header_colour_helper": "Kolor tła nagłówka karty", + "common.editor.show_adults": "Pokaż dorosłych", + "common.editor.show_adults_helper": "Uwzględnij profile dorosłych na tej karcie", "common.editor.overview_entity": "Encja przeglądu", "common.editor.overview_entity_helper": "Encja sensora przeglądu TaskMate", "common.editor.show_parent_actions": "Pokaż działania rodzica", @@ -941,6 +943,9 @@ "panel.child_age_group_label": "Grupa wiekowa", "panel.child_age_group_none": "Nie ustawiono", "panel.child_age_group_hint": "Decyduje, jakie obowiązki proponuje kreator. Nie zmienia niczego innego.", + "panel.child_adult_label": "Profil dorosłego", + "panel.child_adult_hint": "Dla rodzica lub innej dorosłej osoby. Obowiązki, które oznaczy jako wykonane, liczą się od razu bez zatwierdzania, a karty pokazują tę osobę tylko wtedy, gdy w karcie włączona jest opcja „Pokaż dorosłych”.", + "panel.child_adult_pill": "Dorosły", "wizard.title": "Kreator konfiguracji", "wizard.run_btn": "Uruchom kreator konfiguracji", "wizard.open_btn": "Otwórz kreator", diff --git a/custom_components/taskmate/www/locales/pt-BR.json b/custom_components/taskmate/www/locales/pt-BR.json index 02e5d0bb..e69003c5 100644 --- a/custom_components/taskmate/www/locales/pt-BR.json +++ b/custom_components/taskmate/www/locales/pt-BR.json @@ -353,6 +353,8 @@ "common.editor.filter_by_child_all": "Todas as crianças", "common.editor.header_colour": "Cor do Cabeçalho", "common.editor.header_colour_helper": "Cor de fundo do cabeçalho do cartão", + "common.editor.show_adults": "Mostrar adultos", + "common.editor.show_adults_helper": "Incluir perfis de adultos neste cartão", "common.editor.overview_entity": "Entidade de Visão Geral", "common.editor.overview_entity_helper": "A entidade sensor de visão geral do TaskMate", "common.editor.show_parent_actions": "Mostrar ações dos pais", @@ -889,6 +891,9 @@ "panel.child_age_group_label": "Faixa etária", "panel.child_age_group_none": "Não definida", "panel.child_age_group_hint": "Define quais tarefas o assistente de configuração sugere. Não muda mais nada.", + "panel.child_adult_label": "Perfil de adulto", + "panel.child_adult_hint": "Para um pai, mãe ou outro adulto. As tarefas que ele marcar como feitas contam na hora, sem aprovação, e os cartões só o mostram quando a opção \"Mostrar adultos\" do cartão está ativada.", + "panel.child_adult_pill": "Adulto", "wizard.title": "Assistente de configuração", "wizard.run_btn": "Executar assistente de configuração", "wizard.open_btn": "Abrir assistente", diff --git a/custom_components/taskmate/www/locales/pt.json b/custom_components/taskmate/www/locales/pt.json index 8c28357b..ccaf2824 100644 --- a/custom_components/taskmate/www/locales/pt.json +++ b/custom_components/taskmate/www/locales/pt.json @@ -357,6 +357,8 @@ "common.editor.filter_by_child_all": "Todas as crianças", "common.editor.header_colour": "Cor do Cabeçalho", "common.editor.header_colour_helper": "Cor de fundo do cabeçalho do cartão", + "common.editor.show_adults": "Mostrar adultos", + "common.editor.show_adults_helper": "Incluir perfis de adultos neste cartão", "common.editor.overview_entity": "Entidade de Visão Geral", "common.editor.overview_entity_helper": "A entidade sensor de visão geral do TaskMate", "common.editor.show_parent_actions": "Mostrar ações dos pais", @@ -894,6 +896,9 @@ "panel.child_age_group_label": "Faixa etária", "panel.child_age_group_none": "Não definida", "panel.child_age_group_hint": "Decide que tarefas o assistente de configuração sugere. Não altera mais nada.", + "panel.child_adult_label": "Perfil de adulto", + "panel.child_adult_hint": "Para um pai, mãe ou outro adulto. As tarefas que marcar como feitas contam logo, sem aprovação, e os cartões só o mostram quando a opção \"Mostrar adultos\" do cartão está ativa.", + "panel.child_adult_pill": "Adulto", "wizard.title": "Assistente de configuração", "wizard.run_btn": "Executar assistente de configuração", "wizard.open_btn": "Abrir assistente", diff --git a/custom_components/taskmate/www/taskmate-attr-resolver.js b/custom_components/taskmate/www/taskmate-attr-resolver.js index ea5d7d0f..82bd8534 100644 --- a/custom_components/taskmate/www/taskmate-attr-resolver.js +++ b/custom_components/taskmate/www/taskmate-attr-resolver.js @@ -143,6 +143,34 @@ window.__taskmate_attrs = resolveAttrs; + /** + * The children a family-wide card lists (#1061). Grown-ups' profiles stay + * out unless the card sets show_adults, and when shown they follow the + * kids. A card pinned to one child_id gets the full list back, so an adult + * can still have a card of their own. + */ + function familyChildren(children, config) { + const list = Array.isArray(children) ? children : []; + if (!list.some(c => c && c.is_adult) || (config && config.child_id)) return list; + const kids = list.filter(c => !c.is_adult); + return config && config.show_adults === true ? kids.concat(list.filter(c => c.is_adult)) : kids; + } + + window.__taskmate_family_children = familyChildren; + + /** + * The editor's "Show grown-ups" toggle, as ha-form schema entries. Only + * offered once the family has an adult profile (or the card already has + * the option set), so everyone else's editor stays as it was. + */ + function adultsEditorField(hass, config) { + const attrs = (config && config.entity && resolveAttrs(hass, config.entity)) || {}; + const hasAdult = (attrs.children || []).some(c => c && c.is_adult); + return hasAdult || (config && config.show_adults) ? [{ name: "show_adults", selector: { boolean: {} } }] : []; + } + + window.__taskmate_adults_field = adultsEditorField; + /** * A child's badges sensor state, by child id, from the overview's entity * map (#1018). Null when the map doesn't list one — callers fall back to diff --git a/custom_components/taskmate/www/taskmate-calendar-card.js b/custom_components/taskmate/www/taskmate-calendar-card.js index 372514fd..81991aa0 100644 --- a/custom_components/taskmate/www/taskmate-calendar-card.js +++ b/custom_components/taskmate/www/taskmate-calendar-card.js @@ -470,7 +470,7 @@ class TaskMateCalendarCard extends LitElement { const tz = this.hass?.config?.time_zone || Intl.DateTimeFormat().resolvedOptions().timeZone; const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) || entity.attributes || {}; - let children = attrs.children || []; + let children = (window.__taskmate_family_children ? window.__taskmate_family_children(attrs.children || [], this.config) : attrs.children || []); const chores = attrs.chores || []; const pointsIcon = attrs.points_icon || "mdi:star"; @@ -663,7 +663,7 @@ class TaskMateCalendarCard extends LitElement { } const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) || entity.attributes || {}; - let children = attrs.children || []; + let children = (window.__taskmate_family_children ? window.__taskmate_family_children(attrs.children || [], this.config) : attrs.children || []); const chores = attrs.chores || []; const pointsIcon = attrs.points_icon || "mdi:star"; if (this.config.child_id) children = children.filter((c) => c.id === this.config.child_id); @@ -861,6 +861,7 @@ class TaskMateCalendarCardEditor extends LitElement { _computeLabel = (entry) => { const labels = { + show_adults: this._t("common.editor.show_adults"), entity: this._t("common.editor.overview_entity"), title: this._t("calendar.editor.title"), card_design: this._t("common.design.field_label"), @@ -871,6 +872,7 @@ class TaskMateCalendarCardEditor extends LitElement { _computeHelper = (entry) => { const helpers = { + show_adults: this._t("common.editor.show_adults_helper"), entity: this._t("common.editor.overview_entity_helper"), child_id: this._t("calendar.editor.child_helper"), }; @@ -889,7 +891,7 @@ class TaskMateCalendarCardEditor extends LitElement { picked.includes(String(c.id))) : all; + return picked.length ? all.filter(c => picked.includes(String(c.id))) : (window.__taskmate_family_children ? window.__taskmate_family_children(all, this.config) : all); } _kidIndex(attrs, id) { diff --git a/custom_components/taskmate/www/taskmate-graph-card.js b/custom_components/taskmate/www/taskmate-graph-card.js index 05ffa078..996d4efe 100644 --- a/custom_components/taskmate/www/taskmate-graph-card.js +++ b/custom_components/taskmate/www/taskmate-graph-card.js @@ -277,7 +277,7 @@ class TaskMateGraphCard extends LitElement { const tz = this.hass?.config?.time_zone || Intl.DateTimeFormat().resolvedOptions().timeZone; const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) || entity.attributes || {}; - let children = attrs.children || []; + let children = (window.__taskmate_family_children ? window.__taskmate_family_children(attrs.children || [], this.config) : attrs.children || []); const pointsName = attrs.points_name || this._t('common.points'); const days = Math.max(3, Math.min(90, this.config.days || 14)); @@ -394,7 +394,7 @@ class TaskMateGraphCard extends LitElement { const entity = this.hass.states[this.config.entity]; const tz = this.hass?.config?.time_zone || Intl.DateTimeFormat().resolvedOptions().timeZone; const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) || entity.attributes || {}; - let children = attrs.children || []; + let children = (window.__taskmate_family_children ? window.__taskmate_family_children(attrs.children || [], this.config) : attrs.children || []); const pointsName = attrs.points_name || this._t('common.points'); const days = Math.max(3, Math.min(90, this.config.days || 14)); @@ -986,6 +986,7 @@ class TaskMateGraphCardEditor extends LitElement { _computeLabel = (entry) => { const labels = { + show_adults: this._t('common.editor.show_adults'), entity: this._t('common.editor.overview_entity'), title: this._t('common.editor.card_title'), card_design: this._t('common.design.field_label'), @@ -997,6 +998,7 @@ class TaskMateGraphCardEditor extends LitElement { _computeHelper = (entry) => { const helpers = { + show_adults: this._t('common.editor.show_adults_helper'), entity: this._t('common.editor.overview_entity_helper'), days: this._t('graph.editor.days_helper'), child_id: this._t('graph.editor.child_filter_helper'), @@ -1017,7 +1019,7 @@ class TaskMateGraphCardEditor extends LitElement { !c.is_guest).map(c => ({ child: c, requirePin: true, tone: tone(c) })); + return (window.__taskmate_family_children ? window.__taskmate_family_children(all, this.config) : all).filter(c => !c.is_guest).map(c => ({ child: c, requirePin: true, tone: tone(c) })); } const out = []; for (const entry of configured) { @@ -1735,7 +1735,7 @@ class TaskMateKioskCardEditor extends LitElement { const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity || "sensor.taskmate_overview")) || {}; const all = attrs.children || []; const configured = Array.isArray(this.config.children) ? this.config.children : null; - if (!configured) return all.map(c => ({ child: c, shown: !c.is_guest, requirePin: true })); + if (!configured) return all.map(c => ({ child: c, shown: !c.is_guest && (!c.is_adult || this.config.show_adults === true), requirePin: true })); const rows = []; for (const entry of configured) { const child = all.find(c => String(c.id) === String(entry && (entry.child_id ?? entry))); diff --git a/custom_components/taskmate/www/taskmate-leaderboard-card.js b/custom_components/taskmate/www/taskmate-leaderboard-card.js index e715c458..7a72169e 100644 --- a/custom_components/taskmate/www/taskmate-leaderboard-card.js +++ b/custom_components/taskmate/www/taskmate-leaderboard-card.js @@ -328,7 +328,7 @@ class TaskMateLeaderboardCard extends LitElement { if (entity.state === "unavailable" || entity.state === "unknown") return html`
${this._t('common.unavailable')}
`; const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) || entity.attributes || {}; - const children = [...(attrs.children || [])].filter(c => !c.is_guest); + const children = [...(window.__taskmate_family_children ? window.__taskmate_family_children(attrs.children || [], this.config) : attrs.children || [])].filter(c => !c.is_guest); const pointsIcon = attrs.points_icon || "mdi:star"; const pointsName = attrs.points_name || this._t('common.points'); @@ -601,7 +601,7 @@ class TaskMateLeaderboardCard extends LitElement { } const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) || entity.attributes || {}; - const children = [...(attrs.children || [])].filter(c => !c.is_guest); + const children = [...(window.__taskmate_family_children ? window.__taskmate_family_children(attrs.children || [], this.config) : attrs.children || [])].filter(c => !c.is_guest); const pointsName = attrs.points_name || this._t('common.points'); const pointsIcon = attrs.points_icon || "mdi:star"; @@ -841,6 +841,7 @@ class TaskMateLeaderboardCardEditor extends LitElement { _computeLabel = (entry) => { const labels = { + show_adults: this._t('common.editor.show_adults'), entity: this._t('leaderboard.editor.entity_label'), title: this._t('leaderboard.editor.title_label'), sort_by: this._t('leaderboard.editor.rank_by_label'), @@ -854,6 +855,7 @@ class TaskMateLeaderboardCardEditor extends LitElement { _computeHelper = (entry) => { const helpers = { + show_adults: this._t('common.editor.show_adults_helper'), entity: this._t('leaderboard.editor.entity_helper'), sort_by: this._t('leaderboard.editor.rank_by_helper'), }; @@ -875,7 +877,7 @@ class TaskMateLeaderboardCardEditor extends LitElement { { const labels = { + show_adults: this._t('common.editor.show_adults'), entity: this._t('overview.editor.entity_label'), title: this._t('overview.editor.title_label'), approvals_entity: this._t('overview.editor.approvals_entity_label'), @@ -947,6 +948,7 @@ class TaskMateOverviewCardEditor extends LitElement { _computeHelper = (entry) => { const helpers = { + show_adults: this._t('common.editor.show_adults_helper'), entity: this._t('overview.editor.entity_helper'), approvals_entity: this._t('overview.editor.approvals_entity_helper'), show_parent_actions: this._t('common.editor.show_parent_actions_helper'), @@ -967,7 +969,7 @@ class TaskMateOverviewCardEditor extends LitElement {
${this._mdi(child.avatar)}
-

${this._esc(child.name || this._t("panel.child_unnamed"))}

+

${this._esc(child.name || this._t("panel.child_unnamed"))}${child.is_adult ? ` ${this._t("panel.child_adult_pill")}` : ""}

${this._idBadge(child.id)}
${child.availability_entity ? `${this._esc(child.availability_entity)}${child.availability_inverted ? ` ${this._t("panel.child_inverted")}` : ""}` : `${this._t("panel.child_no_availability")}`}${child.unavailability_entity ? ` · ${this._t("panel.child_busy_label")} ${this._esc(child.unavailability_entity)}` : ""}
@@ -10071,6 +10072,9 @@ class TaskMatePanel extends HTMLElement { [ this._field(this._t("panel.child_name_label"), "name", d.name, "text", this._t("panel.child_name_placeholder")), this._iconPickerField(this._t("panel.child_avatar_label"), "avatar", d.avatar), + // A grown-up's profile (#1061): auto-approved, hidden from cards by default. + this._switch(this._t("panel.child_adult_label"), "is_adult", !!d.is_adult, + this._esc(this._t("panel.child_adult_hint"))), this._field(this._t("panel.child_birthday_label"), "birthday", d.birthday, "text", this._esc(this._t("panel.child_birthday_hint"))), // Setup wizard age group (#980): only steers chore suggestions. diff --git a/custom_components/taskmate/www/taskmate-parent-dashboard-card.js b/custom_components/taskmate/www/taskmate-parent-dashboard-card.js index 282776dc..fb147ca3 100644 --- a/custom_components/taskmate/www/taskmate-parent-dashboard-card.js +++ b/custom_components/taskmate/www/taskmate-parent-dashboard-card.js @@ -595,6 +595,9 @@ class TaskMateParentDashboardCard extends LitElement { const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) || entity.attributes || {}; const children = attrs.children || []; + // Grown-ups (#1061) only drop out of the per-child lists; approvals and + // rotation still need every profile to name whoever did the job. + const family = (window.__taskmate_family_children ? window.__taskmate_family_children(children, this.config) : children); const chores = attrs.chores || []; const completions = attrs.todays_completions || []; // Pending approvals come from the full pending list (not filtered to @@ -654,11 +657,11 @@ class TaskMateParentDashboardCard extends LitElement {
- ${this._activeSection === "overview" ? this._renderOverview(children, chores, completions, pointsIcon, pointsName) : ''} + ${this._activeSection === "overview" ? this._renderOverview(family, chores, completions, pointsIcon, pointsName) : ''} ${this._activeSection === "rotation" ? this._renderRotation(rotationChores, children, attrs) : ''} ${this._activeSection === "approvals" ? this._renderApprovals(pendingCompletions, children, chores, pointsIcon) : ''} ${this._activeSection === "claims" ? this._renderClaims(pendingRewardClaims, pointsIcon) : ''} - ${this._activeSection === "points" ? this._renderPoints(children, pointsIcon, pointsName) : ''} + ${this._activeSection === "points" ? this._renderPoints(family, pointsIcon, pointsName) : ''}
`; @@ -738,6 +741,9 @@ class TaskMateParentDashboardCard extends LitElement { const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) || entity.attributes || {}; const children = attrs.children || []; + // Grown-ups (#1061) only drop out of the per-child lists; approvals and + // rotation still need every profile to name whoever did the job. + const family = (window.__taskmate_family_children ? window.__taskmate_family_children(children, this.config) : children); const chores = attrs.chores || []; const completions = attrs.todays_completions || []; const pendingCompletions = attrs.chore_completions || completions.filter(c => !c.approved); @@ -772,11 +778,11 @@ class TaskMateParentDashboardCard extends LitElement { let body; const sec = this._activeSection; - if (sec === "overview") body = this._dOverview(design, children, chores, completions, attrs, pointsIcon); + if (sec === "overview") body = this._dOverview(design, family, chores, completions, attrs, pointsIcon); else if (sec === "rotation") body = this._dRotation(design, rotationChores, children, attrs); else if (sec === "approvals") body = this._dApprovals(design, pendingCompletions, children, chores, pointsIcon); else if (sec === "claims") body = this._dClaims(design, pendingRewardClaims, pointsIcon); - else if (sec === "points") body = this._dPoints(design, children, pointsIcon, pointsName); + else if (sec === "points") body = this._dPoints(design, family, pointsIcon, pointsName); return html` ${this._dHeader(hd, totalPending)} @@ -1500,6 +1506,7 @@ class TaskMateParentDashboardCardEditor extends LitElement { _computeLabel = (entry) => { const labels = { + show_adults: this._t('common.editor.show_adults'), entity: this._t('dashboard.editor.entity_label'), title: this._t('dashboard.editor.title_label'), card_design: this._t('common.design.field_label'), @@ -1511,6 +1518,7 @@ class TaskMateParentDashboardCardEditor extends LitElement { _computeHelper = (entry) => { const helpers = { + show_adults: this._t('common.editor.show_adults_helper'), entity: this._t('dashboard.editor.entity_helper'), quick_points_amount: this._t('dashboard.editor.quick_points_helper'), }; @@ -1530,7 +1538,7 @@ class TaskMateParentDashboardCardEditor extends LitElement { { const labels = { + show_adults: this._t('common.editor.show_adults'), entity: this._t('points_card.editor.entity_label'), title: this._t('points_card.editor.title_label'), card_design: this._t('common.design.field_label'), @@ -1290,6 +1291,7 @@ class TaskMatePointsCardEditor extends LitElement { _computeHelper = (entry) => { const helpers = { + show_adults: this._t('common.editor.show_adults_helper'), entity: this._t('points_card.editor.entity_helper'), quick_add_amounts: this._t('points_card.editor.add_buttons_helper'), quick_remove_amounts: this._t('points_card.editor.remove_buttons_helper'), @@ -1316,7 +1318,7 @@ class TaskMatePointsCardEditor extends LitElement { { const labels = { + show_adults: this._t('common.editor.show_adults'), entity: this._t('points_display.editor.entity_label'), title: this._t('common.editor.card_title'), mode: this._t('points_display.editor.mode_label'), @@ -1254,7 +1256,7 @@ class TaskMatePointsDisplayCardEditor extends LitElement { return labels[entry.name] ?? entry.name; }; - _computeHelper = () => ''; + _computeHelper = (entry) => (entry.name === 'show_adults' ? this._t('common.editor.show_adults_helper') : ''); render() { if (!this.config) return html``; @@ -1274,7 +1276,7 @@ class TaskMatePointsDisplayCardEditor extends LitElement { { const labels = { + show_adults: this._t('common.editor.show_adults'), entity: this._t('common.editor.overview_entity'), title: this._t('common.editor.card_title'), reward_id: this._t('reward_progress.editor.reward_to_display'), @@ -949,6 +950,7 @@ class TaskMateRewardProgressCardEditor extends LitElement { _computeHelper = (entry) => { const helpers = { + show_adults: this._t('common.editor.show_adults_helper'), entity: this._t('common.editor.overview_entity_helper'), reward_id: this._t('reward_progress.editor.reward_helper'), child_id: this._t('reward_progress.editor.child_helper'), @@ -969,7 +971,7 @@ class TaskMateRewardProgressCardEditor extends LitElement { c.id === wanted || c.name === wanted); @@ -1172,6 +1172,7 @@ class TaskMateStickerChartCardEditor extends LitElement { _computeLabel = (entry) => { const labels = { + show_adults: this._t("common.editor.show_adults"), entity: this._t("common.editor.overview_entity"), child_id: this._t("sticker_chart.editor.child"), reward_id: this._t("sticker_chart.editor.reward"), @@ -1189,6 +1190,7 @@ class TaskMateStickerChartCardEditor extends LitElement { _computeHelper = (entry) => { const helpers = { + show_adults: this._t("common.editor.show_adults_helper"), entity: this._t("common.editor.overview_entity_helper"), reward_id: this._t("sticker_chart.editor.reward_helper"), goal: this._t("sticker_chart.editor.goal_helper"), @@ -1226,7 +1228,7 @@ class TaskMateStickerChartCardEditor extends LitElement { { const labels = { + show_adults: this._t('common.editor.show_adults'), entity: this._t('common.editor.overview_entity'), title: this._t('streak.editor.title'), child_id: this._t('common.editor.filter_by_child'), @@ -748,6 +749,7 @@ class TaskMateStreakCardEditor extends LitElement { _computeHelper = (entry) => { const helpers = { + show_adults: this._t('common.editor.show_adults_helper'), entity: this._t('common.editor.overview_entity_helper'), child_id: this._t('streak.editor.child_helper'), streak_days_shown: this._t('streak.editor.days_shown_helper'), @@ -768,7 +770,7 @@ class TaskMateStreakCardEditor extends LitElement { { const labels = { + show_adults: this._t('common.editor.show_adults'), entity: this._t('common.editor.overview_entity'), title: this._t('weekly.editor.title'), child_id: this._t('common.editor.filter_by_child'), @@ -811,6 +812,7 @@ class TaskMateWeeklyCardEditor extends LitElement { _computeHelper = (entry) => { const helpers = { + show_adults: this._t('common.editor.show_adults_helper'), entity: this._t('common.editor.overview_entity_helper'), child_id: this._t('weekly.editor.child_helper'), }; @@ -829,7 +831,7 @@ class TaskMateWeeklyCardEditor extends LitElement { {} } }).window; +const family = resolver.__taskmate_family_children; +const ids = (list) => list.map((c) => c.id); + +test("adults are left out by default", () => { + assert.deepEqual(ids(family(CHILDREN, {})), ["k1", "k2"]); +}); + +test("show_adults brings them back after the kids", () => { + assert.deepEqual(ids(family(CHILDREN, { show_adults: true })), ["k1", "k2", "mum"]); +}); + +test("a card pinned to one child gets the full list to pick from", () => { + assert.deepEqual(ids(family(CHILDREN, { child_id: "mum" })), ["mum", "k1", "k2"]); +}); + +test("a family with no adults keeps its list as it was", () => { + const kids = CHILDREN.slice(1); + assert.equal(family(kids, {}), kids); +}); + +test("the editor only offers the toggle once the family has an adult", () => { + const hass = (children) => ({ states: { [OVERVIEW]: { state: "1", attributes: { children } } } }); + assert.equal(resolver.__taskmate_adults_field(hass(CHILDREN.slice(1)), { entity: OVERVIEW }).length, 0); + assert.equal(resolver.__taskmate_adults_field(hass(CHILDREN), { entity: OVERVIEW })[0].name, "show_adults"); +}); + +function leaderboard(config) { + const Card = loadCard("taskmate-leaderboard-card.js", { + window: { __taskmate_attrs: resolver.__taskmate_attrs, __taskmate_family_children: family }, + }).get("taskmate-leaderboard-card"); + const card = new Card(); + card.setConfig({ entity: OVERVIEW, ...config }); + card.hass = { states: { [OVERVIEW]: { state: "1", attributes: { children: CHILDREN } } } }; + return render(card.render()).markup; +} + +test("the leaderboard hides an adult unless show_adults is set", () => { + assert.ok(!leaderboard({}).includes("Mum")); + assert.ok(leaderboard({ show_adults: true }).includes("Mum")); +}); diff --git a/tests/test_adult_profiles.py b/tests/test_adult_profiles.py new file mode 100644 index 00000000..36040f46 --- /dev/null +++ b/tests/test_adult_profiles.py @@ -0,0 +1,95 @@ +"""Adult profiles (#1061). + +Parents who log their own chores were added as children and then had to +approve each other's work. A profile marked as an adult skips the approval +queue for its own completions; children's completions are unchanged. +""" + +from __future__ import annotations + +import datetime as dt +from unittest.mock import patch + +from custom_components.taskmate.models import BonusSubTask, Child + +from .test_completion_eligibility import _make_system, _now, run + + +def _family(coord): + kid = run(coord.async_add_child("Alice")) + mum = run(coord.async_add_child("Mum", is_adult=True)) + return kid, mum + + +def _chore(coord, **over): + return run( + coord.async_add_chore( + "Wash the car", + points=20, + requires_approval=True, + schedule_mode="specific_days", + **over, + ) + ) + + +class TestModel: + def test_round_trips(self): + child = Child.from_dict(Child(name="Dad", is_adult=True).to_dict()) + assert child.is_adult is True + + def test_existing_profiles_are_children(self): + assert Child.from_dict({"name": "Old"}).is_adult is False + + +class TestCompletion: + def test_adult_completion_is_approved_straight_away(self): + coord, storage, _mod = _make_system() + with patch.object(_mod.dt_util, "now", return_value=_now()): + _kid, mum = _family(coord) + chore = _chore(coord) + completion = run(coord.async_complete_chore(chore.id, mum.id)) + + assert completion.approved is True + assert storage.get_child(mum.id).points == 20 + coord._async_notify_pending_approval.assert_not_called() + + def test_child_completion_still_waits_for_approval(self): + coord, storage, _mod = _make_system() + with patch.object(_mod.dt_util, "now", return_value=_now()): + kid, _mum = _family(coord) + chore = _chore(coord) + completion = run(coord.async_complete_chore(chore.id, kid.id)) + + assert completion.approved is False + assert storage.get_child(kid.id).points == 0 + + def test_adult_bonus_subtask_is_approved_straight_away(self): + coord, storage, _mod = _make_system() + with patch.object(_mod.dt_util, "now", return_value=_now()): + _kid, mum = _family(coord) + chore = _chore(coord) + chore.bonus_subtasks = [BonusSubTask(id="b1", name="Polish too", points=5)] + storage.update_chore(chore) + run(coord.async_complete_chore(chore.id, mum.id)) + completion = run(coord.async_complete_bonus_subtask(chore.id, "b1", mum.id)) + + assert completion.approved is True + assert storage.get_child(mum.id).points == 25 + + def test_adult_timed_session_is_approved_straight_away(self): + coord, storage, _mod = _make_system() + with patch.object(_mod.dt_util, "now", return_value=_now()): + _kid, mum = _family(coord) + chore = _chore(coord, assigned_to=[mum.id]) + chore.task_type = "timed" + chore.timed_rate_points = 5 + chore.timed_rate_minutes = 10 + storage.update_chore(chore) + run(coord.async_start_timed_task(chore.id, mum.id)) + with patch.object(_mod.dt_util, "now", return_value=_now() + dt.timedelta(minutes=30)): + run(coord.async_stop_timed_task(chore.id, mum.id)) + + completion = next(c for c in storage.get_completions() if c.chore_id == chore.id) + assert completion.approved is True + assert storage.get_child(mum.id).points == 15