331 files

This commit is contained in:
Home Assistant Version Control
2026-10-04 05:26:42 +00:00
parent 22ba8b4648
commit c4d601bfba
331 changed files with 10227 additions and 50159 deletions
+7 -3
View File
@@ -2060,9 +2060,13 @@ class ChoresMixin:
self.storage.upsert_daily_progress(entry["child_id"], board["date"], entry["due"], entry["done"], cutoff)
await self.storage.async_save()
def daily_progress_state(self, days: int = 7) -> dict:
"""Last ``days`` days of stored done/total per child, today included live."""
board = self.get_today_board()
def daily_progress_state(self, days: int = 7, board: dict | None = None) -> dict:
"""Last ``days`` days of stored done/total per child, today included live.
``board`` is today's ``get_today_board()`` when the caller already has it.
"""
if board is None:
board = self.get_today_board()
today = date.fromisoformat(board["date"])
dates = [(today - timedelta(days=n)).isoformat() for n in range(days - 1, -1, -1)]
out = {}
@@ -161,6 +161,32 @@ NOTIFICATION_TYPES: list[NotificationTypeMeta] = [
NOTIFICATION_TYPES_BY_ID: dict[str, NotificationTypeMeta] = {t.id: t for t in NOTIFICATION_TYPES}
# Types whose feature fires its own ``taskmate_<type>`` event (#1014). Their
# notification-side event is ``taskmate_<type>_notification`` instead, so an
# automation on the feature event runs once, not twice.
_OWN_EVENT_TYPES: frozenset[str] = frozenset(
{
NOTIF_TYPE_AUCTION_OPENED,
NOTIF_TYPE_BADGE_EARNED,
NOTIF_TYPE_BOUNTY_POSTED,
NOTIF_TYPE_CELEBRATION,
NOTIF_TYPE_FAMILY_GOAL_REACHED,
NOTIF_TYPE_INSPECTION_PASSED,
NOTIF_TYPE_INSPECTION_STARTED,
NOTIF_TYPE_LEVEL_UP,
NOTIF_TYPE_SEASON_CHAMPION,
NOTIF_TYPE_STREAK_FREEZE_USED,
NOTIF_TYPE_WISH_PLEDGED,
}
)
def notification_event_name(type_id: str) -> str:
"""The bus event fired when a notification of ``type_id`` goes out."""
if type_id in _OWN_EVENT_TYPES:
return f"taskmate_{type_id}_notification"
return f"taskmate_{type_id}"
def _validate_nav_url(value: str) -> str:
"""Normalise and vet a notification tap target.
@@ -686,7 +712,7 @@ class NotificationCoordinator:
def _fire_bus_event(self, type_id: str, context: dict[str, Any], recipients: list[str]) -> None:
payload = dict(context)
payload["recipients"] = recipients
self.hass.bus.async_fire(f"taskmate_{type_id}", payload)
self.hass.bus.async_fire(notification_event_name(type_id), payload)
async def handle_mobile_action(self, event) -> None:
"""Route TASKMATE_APPROVE_<id> / TASKMATE_REJECT_<id> mobile actions."""
+1
View File
@@ -54,6 +54,7 @@ CARDS: Final = [
"taskmate-bounty-card.js",
"taskmate-auction-card.js",
"taskmate-recap-card.js",
"taskmate-chore-board-card.js",
]
# Cards that USED to ship but were removed. Their files no longer exist, so any
+1 -1
View File
@@ -17,5 +17,5 @@
"iot_class": "calculated",
"issue_tracker": "https://github.com/tempus2016/taskmate/issues",
"requirements": [],
"version": "6.0.0"
"version": "6.1.0"
}
+109 -3
View File
@@ -11,7 +11,8 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -24,7 +25,7 @@ from .coord_rewards import reward_is_time_locked
from .coordinator import TaskMateCoordinator
from .entity import taskmate_device_info
from .models import Child
from .watch import DEFAULT_STATE_STRINGS, watch_state, watch_summary
from .watch import DEFAULT_STATE_STRINGS, today_board, watch_state, watch_summary
_LOGGER = logging.getLogger(__name__)
@@ -922,6 +923,7 @@ async def async_setup_entry(
entities.append(TaskMateIncentivesSensor(coordinator, entry))
entities.append(TaskMateBountiesSensor(coordinator, entry))
entities.append(TaskMateAuctionsSensor(coordinator, entry))
entities.append(TaskMateChoreBoardSensor(coordinator, entry))
# Add sensors for each child
for child in coordinator.data.get("children", []):
@@ -1002,6 +1004,22 @@ class _CachedAttrsSensor(TaskMateBaseSensor):
raise NotImplementedError
# The singleton sensors the cards merge into the overview's attributes, by
# unique_id suffix. The suffix doubles as the key in `companion_entities` and
# matches the default entity id (sensor.taskmate_<suffix>) the cards fall back to.
COMPANION_SUFFIXES = (
"chores",
"chore_availability",
"rewards",
"activity",
"incentives",
"pending_approvals",
"bounties",
"auctions",
"chore_board",
)
class TaskMateOverallStatsSensor(_CachedAttrsSensor):
"""Overview sensor — scalars plus the compact per-child summary.
@@ -1011,7 +1029,9 @@ class TaskMateOverallStatsSensor(_CachedAttrsSensor):
# The per-child summary grows with the family; the scalars stay recorded
# so history/statistics on them keep working (#817).
_unrecorded_attributes = frozenset({"children", "vacation_periods", "season_champions"})
_unrecorded_attributes = frozenset(
{"children", "vacation_periods", "season_champions", "companion_entities", "badge_entities"}
)
def __init__(
self,
@@ -1021,6 +1041,44 @@ class TaskMateOverallStatsSensor(_CachedAttrsSensor):
super().__init__(coordinator, entry)
self._attr_unique_id = f"{entry.entry_id}_overall_stats"
self._attr_name = "TaskMate Overview"
self._published_entity_map: dict | None = None
async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
self.async_on_remove(
self.hass.bus.async_listen(er.EVENT_ENTITY_REGISTRY_UPDATED, self._async_entity_registry_updated)
)
@callback
def _async_entity_registry_updated(self, _event: Event) -> None:
"""Republish when a companion sensor is created, removed or renamed."""
if self._entity_map() != self._published_entity_map:
self.async_write_ha_state()
def _entity_map(self) -> dict:
"""The entity ids the companion and per-child badges sensors really
have (#1018). Home Assistant puts the device's area in front of a new
entity's id (2026.6+), and users rename entities, so the cards can't
rely on the sensor.taskmate_<suffix> defaults."""
if getattr(self, "hass", None) is None:
return {"companion_entities": {}, "badge_entities": {}}
registry = er.async_get(self.hass)
prefix = self._entry.entry_id
def lookup(suffix: str) -> str | None:
return registry.async_get_entity_id("sensor", DOMAIN, f"{prefix}_{suffix}")
companions = {s: eid for s in COMPANION_SUFFIXES if (eid := lookup(s))}
badges = {c.id: eid for c in self.coordinator.storage.get_children() if (eid := lookup(f"{c.id}_badges"))}
return {"companion_entities": companions, "badge_entities": badges}
@property
def extra_state_attributes(self) -> dict:
# Kept out of the cached build: a rename doesn't touch the coordinator
# data the cache is keyed on.
entity_map = self._entity_map()
self._published_entity_map = entity_map
return {**super().extra_state_attributes, **entity_map}
@property
def native_value(self) -> int:
@@ -1423,6 +1481,54 @@ class TaskMateAuctionsSensor(_CachedAttrsSensor):
return {"auctions": self.coordinator.auctions_public_state()}
class TaskMateChoreBoardSensor(TaskMateBaseSensor):
"""The Today page's chore board, for the chore board card (#1017).
The state is how many chores are still to do today across every child.
The ``chore_board`` attribute is what the admin panel's Today page draws:
today's status of each child's chores, plus the last seven days of
done/total per child for the week view. The panel reads it over the
admin-only WebSocket; this puts it where a non-admin dashboard can.
Keyed on the date as well as the coordinator snapshot, like the watch
sensors, so a quiet midnight still rolls the board over.
"""
_unrecorded_attributes = frozenset({"chore_board"})
_attr_icon = "mdi:view-grid-outline"
def __init__(
self,
coordinator: TaskMateCoordinator,
entry: ConfigEntry,
) -> None:
super().__init__(coordinator, entry)
self._attr_unique_id = f"{entry.entry_id}_chore_board"
self._attr_name = "TaskMate Chore Board"
self._board_key: tuple | None = None
self._board: dict | None = None
def _current_board(self) -> dict:
key = (
id(self.coordinator.data),
getattr(self.coordinator, "external_state_version", 0),
dt_util.as_local(dt_util.now()).date(),
)
if key != self._board_key or self._board is None:
self._board = self.coordinator.daily_progress_state(board=today_board(self.coordinator))
self._board_key = key
return self._board
@property
def native_value(self) -> int:
children = self._current_board()["board"]["children"]
return sum(1 for e in children for c in e["chores"] if c["status"] in ("todo", "missed"))
@property
def extra_state_attributes(self) -> dict:
return {"chore_board": self._current_board()}
class ChildPointsSensor(TaskMateBaseSensor):
"""Sensor for a child's points."""
File diff suppressed because it is too large Load Diff
+29 -1
View File
@@ -2855,5 +2855,33 @@
"panel.recaps_preview_btn": "Forhåndsvis",
"panel.recaps_preview_done": "Forhåndsvisning lavet — åbn tilbageblikskortet for at se den",
"notification.recap_ready.name": "Tilbageblik klar",
"notification.recap_ready.description": "Fortæller hvert barn, og forældrene i én samlet besked, at et nyt tilbageblik er klar. Sendes på tidspunktet fra Indstillinger → Tilbageblik."
"notification.recap_ready.description": "Fortæller hvert barn, og forældrene i én samlet besked, at et nyt tilbageblik er klar. Sendes på tidspunktet fra Indstillinger → Tilbageblik.",
"chore_board.title": "Pligttavle",
"chore_board.today": "I dag",
"chore_board.this_week": "Denne uge",
"chore_board.empty": "Ingen pligter i dag.",
"chore_board.status_done": "Udført",
"chore_board.status_pending": "Afventer godkendelse",
"chore_board.status_todo": "Mangler",
"chore_board.status_missed": "Misset",
"chore_board.not_recorded": "Ikke registreret",
"chore_board.unavailable": "Pligttavlen er ikke tilgængelig. Genstart Home Assistant efter opdatering af TaskMate.",
"chore_board.no_children": "Ingen børn at vise. Tjek de valgte børn i kortets indstillinger.",
"chore_board.all_done": "Alt er klaret for i dag!",
"chore_board.finish_on_card": "Gør \"{chore}\" færdig på {name}s kort",
"chore_board.marked_done": "{name}: {chore} klaret",
"chore_board.sent_for_approval": "{name}: {chore} sendt til godkendelse",
"chore_board.undo": "Fortryd",
"chore_board.undo_failed": "Det kunne ikke fortrydes. Prøv igen på barnets kort.",
"chore_board.error": "Noget gik galt: {message}",
"chore_board.week_hint": "Grå dage blev ikke registreret. TaskMate gemmer hver dags fremskridt kl. 23:59.",
"chore_board.editor.children": "Børn",
"chore_board.editor.children_helper": "Lad stå tomt for at vise alle børn. Vælg ét barn for en enkel liste.",
"chore_board.editor.default_view": "Åbner på",
"chore_board.editor.show_week": "Vis knappen Denne uge",
"chore_board.editor.tap_to_complete": "Tryk på en pligt for at klare den",
"chore_board.editor.tap_to_complete_helper": "Slå fra for en tavle, der kun kan ses",
"chore_board.editor.show_legend": "Vis forklaring",
"chore_board.editor.hide_done_periods": "Skjul færdige tidspunkter",
"chore_board.editor.hide_done_periods_helper": "Fjern et tidspunkt på dagen, når alle børn har klaret deres pligter"
}
+29 -1
View File
@@ -2855,5 +2855,33 @@
"panel.recaps_preview_btn": "Vorschau",
"panel.recaps_preview_done": "Vorschau erstellt — öffne die Rückblick-Karte, um sie anzusehen",
"notification.recap_ready.name": "Rückblick fertig",
"notification.recap_ready.description": "Sagt jedem Kind und den Eltern in einer gebündelten Nachricht, dass ein neuer Rückblick bereitsteht. Gesendet zur Uhrzeit aus Einstellungen → Rückblicke."
"notification.recap_ready.description": "Sagt jedem Kind und den Eltern in einer gebündelten Nachricht, dass ein neuer Rückblick bereitsteht. Gesendet zur Uhrzeit aus Einstellungen → Rückblicke.",
"chore_board.title": "Aufgabenübersicht",
"chore_board.today": "Heute",
"chore_board.this_week": "Diese Woche",
"chore_board.empty": "Heute keine Aufgaben.",
"chore_board.status_done": "Erledigt",
"chore_board.status_pending": "Wartet auf Freigabe",
"chore_board.status_todo": "Offen",
"chore_board.status_missed": "Verpasst",
"chore_board.not_recorded": "Nicht erfasst",
"chore_board.unavailable": "Die Aufgabenübersicht ist nicht verfügbar. Starte Home Assistant nach dem TaskMate-Update neu.",
"chore_board.no_children": "Keine Kinder anzuzeigen. Prüfe die ausgewählten Kinder in den Karteneinstellungen.",
"chore_board.all_done": "Für heute ist alles erledigt!",
"chore_board.finish_on_card": "Schließe „{chore}“ auf der Karte von {name} ab",
"chore_board.marked_done": "{name}: {chore} erledigt",
"chore_board.sent_for_approval": "{name}: {chore} zur Freigabe gesendet",
"chore_board.undo": "Rückgängig",
"chore_board.undo_failed": "Das konnte nicht rückgängig gemacht werden. Versuche es auf der Karte des Kindes.",
"chore_board.error": "Etwas ist schiefgelaufen: {message}",
"chore_board.week_hint": "Graue Tage wurden nicht erfasst. TaskMate speichert den Fortschritt jedes Tages um 23:59.",
"chore_board.editor.children": "Kinder",
"chore_board.editor.children_helper": "Leer lassen, um alle Kinder zu zeigen. Ein Kind wählen für eine einfache Liste.",
"chore_board.editor.default_view": "Öffnet mit",
"chore_board.editor.show_week": "Umschalter „Diese Woche“ anzeigen",
"chore_board.editor.tap_to_complete": "Aufgabe durch Antippen erledigen",
"chore_board.editor.tap_to_complete_helper": "Ausschalten für eine reine Anzeige",
"chore_board.editor.show_legend": "Legende anzeigen",
"chore_board.editor.hide_done_periods": "Erledigte Tageszeiten ausblenden",
"chore_board.editor.hide_done_periods_helper": "Eine Tageszeit ausblenden, sobald alle Kinder ihre Aufgaben erledigt haben"
}
@@ -2855,5 +2855,33 @@
"panel.recaps_preview_btn": "Preview",
"panel.recaps_preview_done": "Preview built — open the recap card to watch it",
"notification.recap_ready.name": "Recap ready",
"notification.recap_ready.description": "Tells each child, and parents in one grouped message, that a new recap is ready to watch. Sent at the recap send time set in Settings → Recaps."
"notification.recap_ready.description": "Tells each child, and parents in one grouped message, that a new recap is ready to watch. Sent at the recap send time set in Settings → Recaps.",
"chore_board.title": "Chore board",
"chore_board.today": "Today",
"chore_board.this_week": "This week",
"chore_board.empty": "No chores today.",
"chore_board.status_done": "Done",
"chore_board.status_pending": "Awaiting approval",
"chore_board.status_todo": "To do",
"chore_board.status_missed": "Missed",
"chore_board.not_recorded": "Not recorded",
"chore_board.unavailable": "The chore board isn't available. Restart Home Assistant after updating TaskMate.",
"chore_board.no_children": "No children to show. Check the children picked in the card settings.",
"chore_board.all_done": "Everything's done for today!",
"chore_board.finish_on_card": "Finish \"{chore}\" on {name}'s card",
"chore_board.marked_done": "{name}: {chore} done",
"chore_board.sent_for_approval": "{name}: {chore} sent for approval",
"chore_board.undo": "Undo",
"chore_board.undo_failed": "Couldn't undo that. Try again on the child's card.",
"chore_board.error": "Something went wrong: {message}",
"chore_board.week_hint": "Grey days weren't recorded. TaskMate saves each day's progress at 23:59.",
"chore_board.editor.children": "Children",
"chore_board.editor.children_helper": "Leave empty to show every child. Pick one child for a simple list.",
"chore_board.editor.default_view": "Opens on",
"chore_board.editor.show_week": "Show the This week toggle",
"chore_board.editor.tap_to_complete": "Tap a chore to complete it",
"chore_board.editor.tap_to_complete_helper": "Turn off for a view-only board",
"chore_board.editor.show_legend": "Show the legend",
"chore_board.editor.hide_done_periods": "Hide finished time periods",
"chore_board.editor.hide_done_periods_helper": "Drop a time of day once every child has finished its chores"
}
+29 -1
View File
@@ -2855,5 +2855,33 @@
"panel.recaps_preview_btn": "Preview",
"panel.recaps_preview_done": "Preview built — open the recap card to watch it",
"notification.recap_ready.name": "Recap ready",
"notification.recap_ready.description": "Tells each child, and parents in one grouped message, that a new recap is ready to watch. Sent at the recap send time set in Settings → Recaps."
"notification.recap_ready.description": "Tells each child, and parents in one grouped message, that a new recap is ready to watch. Sent at the recap send time set in Settings → Recaps.",
"chore_board.title": "Chore board",
"chore_board.today": "Today",
"chore_board.this_week": "This week",
"chore_board.empty": "No chores today.",
"chore_board.status_done": "Done",
"chore_board.status_pending": "Awaiting approval",
"chore_board.status_todo": "To do",
"chore_board.status_missed": "Missed",
"chore_board.not_recorded": "Not recorded",
"chore_board.unavailable": "The chore board isn't available. Restart Home Assistant after updating TaskMate.",
"chore_board.no_children": "No children to show. Check the children picked in the card settings.",
"chore_board.all_done": "Everything's done for today!",
"chore_board.finish_on_card": "Finish \"{chore}\" on {name}'s card",
"chore_board.marked_done": "{name}: {chore} done",
"chore_board.sent_for_approval": "{name}: {chore} sent for approval",
"chore_board.undo": "Undo",
"chore_board.undo_failed": "Couldn't undo that. Try again on the child's card.",
"chore_board.error": "Something went wrong: {message}",
"chore_board.week_hint": "Grey days weren't recorded. TaskMate saves each day's progress at 23:59.",
"chore_board.editor.children": "Children",
"chore_board.editor.children_helper": "Leave empty to show every child. Pick one child for a simple list.",
"chore_board.editor.default_view": "Opens on",
"chore_board.editor.show_week": "Show the This week toggle",
"chore_board.editor.tap_to_complete": "Tap a chore to complete it",
"chore_board.editor.tap_to_complete_helper": "Turn off for a view-only board",
"chore_board.editor.show_legend": "Show the legend",
"chore_board.editor.hide_done_periods": "Hide finished time periods",
"chore_board.editor.hide_done_periods_helper": "Drop a time of day once every child has finished its chores"
}
+29 -1
View File
@@ -2855,5 +2855,33 @@
"panel.recaps_preview_btn": "Aperçu",
"panel.recaps_preview_done": "Aperçu créé — ouvrez la carte Récap pour le regarder",
"notification.recap_ready.name": "Récap prêt",
"notification.recap_ready.description": "Prévient chaque enfant, et les parents en un seul message groupé, qu'un nouveau récap est prêt. Envoyé à l'heure définie dans Paramètres → Récaps."
"notification.recap_ready.description": "Prévient chaque enfant, et les parents en un seul message groupé, qu'un nouveau récap est prêt. Envoyé à l'heure définie dans Paramètres → Récaps.",
"chore_board.title": "Tableau des tâches",
"chore_board.today": "Aujourd'hui",
"chore_board.this_week": "Cette semaine",
"chore_board.empty": "Aucune tâche aujourd'hui.",
"chore_board.status_done": "Faite",
"chore_board.status_pending": "En attente de validation",
"chore_board.status_todo": "À faire",
"chore_board.status_missed": "Manquée",
"chore_board.not_recorded": "Non enregistré",
"chore_board.unavailable": "Le tableau des tâches n'est pas disponible. Redémarrez Home Assistant après la mise à jour de TaskMate.",
"chore_board.no_children": "Aucun enfant à afficher. Vérifiez les enfants choisis dans les réglages de la carte.",
"chore_board.all_done": "Tout est fait pour aujourd'hui !",
"chore_board.finish_on_card": "Terminez « {chore} » sur la carte de {name}",
"chore_board.marked_done": "{name} : {chore} fait",
"chore_board.sent_for_approval": "{name} : {chore} envoyé pour validation",
"chore_board.undo": "Annuler",
"chore_board.undo_failed": "Impossible d'annuler. Réessayez sur la carte de l'enfant.",
"chore_board.error": "Une erreur s'est produite : {message}",
"chore_board.week_hint": "Les jours grisés n'ont pas été enregistrés. TaskMate enregistre la progression de chaque jour à 23:59.",
"chore_board.editor.children": "Enfants",
"chore_board.editor.children_helper": "Laissez vide pour afficher tous les enfants. Choisissez un enfant pour une liste simple.",
"chore_board.editor.default_view": "S'ouvre sur",
"chore_board.editor.show_week": "Afficher le bouton Cette semaine",
"chore_board.editor.tap_to_complete": "Toucher une tâche pour la terminer",
"chore_board.editor.tap_to_complete_helper": "Désactivez pour un tableau en lecture seule",
"chore_board.editor.show_legend": "Afficher la légende",
"chore_board.editor.hide_done_periods": "Masquer les moments terminés",
"chore_board.editor.hide_done_periods_helper": "Retirer un moment de la journée dès que tous les enfants ont fini leurs tâches"
}
+29 -1
View File
@@ -2855,5 +2855,33 @@
"panel.recaps_preview_btn": "Forhåndsvis",
"panel.recaps_preview_done": "Forhåndsvisning laget — åpne tilbakeblikkortet for å se den",
"notification.recap_ready.name": "Tilbakeblikk klart",
"notification.recap_ready.description": "Forteller hvert barn, og foreldrene i én samlet melding, at et nytt tilbakeblikk er klart. Sendes på klokkeslettet fra Innstillinger → Tilbakeblikk."
"notification.recap_ready.description": "Forteller hvert barn, og foreldrene i én samlet melding, at et nytt tilbakeblikk er klart. Sendes på klokkeslettet fra Innstillinger → Tilbakeblikk.",
"chore_board.title": "Oppgavetavle",
"chore_board.today": "I dag",
"chore_board.this_week": "Denne uka",
"chore_board.empty": "Ingen oppgaver i dag.",
"chore_board.status_done": "Ferdig",
"chore_board.status_pending": "Venter på godkjenning",
"chore_board.status_todo": "Gjenstår",
"chore_board.status_missed": "Glemt",
"chore_board.not_recorded": "Ikke registrert",
"chore_board.unavailable": "Oppgavetavlen er ikke tilgjengelig. Start Home Assistant på nytt etter at TaskMate er oppdatert.",
"chore_board.no_children": "Ingen barn å vise. Sjekk de valgte barna i kortinnstillingene.",
"chore_board.all_done": "Alt er gjort for i dag!",
"chore_board.finish_on_card": "Fullfør «{chore}» på kortet til {name}",
"chore_board.marked_done": "{name}: {chore} gjort",
"chore_board.sent_for_approval": "{name}: {chore} sendt til godkjenning",
"chore_board.undo": "Angre",
"chore_board.undo_failed": "Kunne ikke angre. Prøv igjen på barnets kort.",
"chore_board.error": "Noe gikk galt: {message}",
"chore_board.week_hint": "Grå dager ble ikke registrert. TaskMate lagrer hver dags framgang kl. 23:59.",
"chore_board.editor.children": "Barn",
"chore_board.editor.children_helper": "La stå tomt for å vise alle barn. Velg ett barn for en enkel liste.",
"chore_board.editor.default_view": "Åpner på",
"chore_board.editor.show_week": "Vis bryteren Denne uken",
"chore_board.editor.tap_to_complete": "Trykk på en oppgave for å fullføre den",
"chore_board.editor.tap_to_complete_helper": "Slå av for en tavle som bare kan vises",
"chore_board.editor.show_legend": "Vis forklaring",
"chore_board.editor.hide_done_periods": "Skjul ferdige tidspunkter",
"chore_board.editor.hide_done_periods_helper": "Fjern en tid på dagen når alle barna har gjort oppgavene sine"
}
+29 -1
View File
@@ -2855,5 +2855,33 @@
"panel.recaps_preview_btn": "Førehandsvis",
"panel.recaps_preview_done": "Førehandsvising laga — opne tilbakeblikkortet for å sjå ho",
"notification.recap_ready.name": "Tilbakeblikk klart",
"notification.recap_ready.description": "Fortel kvart barn, og foreldra i éi samla melding, at eit nytt tilbakeblikk er klart. Vert sendt på klokkeslettet frå Innstillingar → Tilbakeblikk."
"notification.recap_ready.description": "Fortel kvart barn, og foreldra i éi samla melding, at eit nytt tilbakeblikk er klart. Vert sendt på klokkeslettet frå Innstillingar → Tilbakeblikk.",
"chore_board.title": "Oppgåvetavle",
"chore_board.today": "I dag",
"chore_board.this_week": "Denne veka",
"chore_board.empty": "Ingen oppgåver i dag.",
"chore_board.status_done": "Ferdig",
"chore_board.status_pending": "Ventar på godkjenning",
"chore_board.status_todo": "Står att",
"chore_board.status_missed": "Gløymd",
"chore_board.not_recorded": "Ikkje registrert",
"chore_board.unavailable": "Oppgåvetavla er ikkje tilgjengeleg. Start Home Assistant på nytt etter at TaskMate er oppdatert.",
"chore_board.no_children": "Ingen born å vise. Sjekk dei valde borna i kortinnstillingane.",
"chore_board.all_done": "Alt er gjort for i dag!",
"chore_board.finish_on_card": "Fullfør «{chore}» på kortet til {name}",
"chore_board.marked_done": "{name}: {chore} gjort",
"chore_board.sent_for_approval": "{name}: {chore} sendt til godkjenning",
"chore_board.undo": "Angre",
"chore_board.undo_failed": "Kunne ikkje angre. Prøv igjen på kortet til barnet.",
"chore_board.error": "Noko gjekk gale: {message}",
"chore_board.week_hint": "Grå dagar vart ikkje registrerte. TaskMate lagrar framgangen kvar dag kl. 23:59.",
"chore_board.editor.children": "Born",
"chore_board.editor.children_helper": "La stå tomt for å vise alle born. Vel eitt barn for ei enkel liste.",
"chore_board.editor.default_view": "Opnar på",
"chore_board.editor.show_week": "Vis brytaren Denne veka",
"chore_board.editor.tap_to_complete": "Trykk på ei oppgåve for å fullføre ho",
"chore_board.editor.tap_to_complete_helper": "Slå av for ei tavle som berre kan visast",
"chore_board.editor.show_legend": "Vis forklaring",
"chore_board.editor.hide_done_periods": "Skjul ferdige tidspunkt",
"chore_board.editor.hide_done_periods_helper": "Fjern ei tid på dagen når alle borna har gjort oppgåvene sine"
}
File diff suppressed because it is too large Load Diff
@@ -2855,5 +2855,33 @@
"panel.recaps_preview_btn": "Visualizar",
"panel.recaps_preview_done": "Prévia criada — abra o cartão Resumo para assisti-la",
"notification.recap_ready.name": "Resumo pronto",
"notification.recap_ready.description": "Avisa cada criança, e os responsáveis em uma só mensagem agrupada, que há um novo resumo para assistir. Enviado no horário definido em Configurações → Resumos."
"notification.recap_ready.description": "Avisa cada criança, e os responsáveis em uma só mensagem agrupada, que há um novo resumo para assistir. Enviado no horário definido em Configurações → Resumos.",
"chore_board.title": "Quadro de tarefas",
"chore_board.today": "Hoje",
"chore_board.this_week": "Esta semana",
"chore_board.empty": "Nenhuma tarefa hoje.",
"chore_board.status_done": "Feita",
"chore_board.status_pending": "Aguardando aprovação",
"chore_board.status_todo": "A fazer",
"chore_board.status_missed": "Perdida",
"chore_board.not_recorded": "Não registrado",
"chore_board.unavailable": "O quadro de tarefas não está disponível. Reinicie o Home Assistant depois de atualizar o TaskMate.",
"chore_board.no_children": "Nenhuma criança para mostrar. Verifique as crianças escolhidas nas configurações do cartão.",
"chore_board.all_done": "Está tudo feito por hoje!",
"chore_board.finish_on_card": "Termine \"{chore}\" no cartão de {name}",
"chore_board.marked_done": "{name}: {chore} feita",
"chore_board.sent_for_approval": "{name}: {chore} enviada para aprovação",
"chore_board.undo": "Desfazer",
"chore_board.undo_failed": "Não foi possível desfazer. Tente de novo no cartão da criança.",
"chore_board.error": "Algo deu errado: {message}",
"chore_board.week_hint": "Os dias em cinza não foram registrados. O TaskMate salva o progresso de cada dia às 23:59.",
"chore_board.editor.children": "Crianças",
"chore_board.editor.children_helper": "Deixe vazio para mostrar todas as crianças. Escolha uma criança para uma lista simples.",
"chore_board.editor.default_view": "Abre em",
"chore_board.editor.show_week": "Mostrar o botão Esta semana",
"chore_board.editor.tap_to_complete": "Tocar em uma tarefa para concluí-la",
"chore_board.editor.tap_to_complete_helper": "Desative para um quadro só de visualização",
"chore_board.editor.show_legend": "Mostrar a legenda",
"chore_board.editor.hide_done_periods": "Ocultar períodos concluídos",
"chore_board.editor.hide_done_periods_helper": "Remover um período do dia quando todas as crianças terminarem as tarefas"
}
+29 -1
View File
@@ -2855,5 +2855,33 @@
"panel.recaps_preview_btn": "Pré-visualizar",
"panel.recaps_preview_done": "Pré-visualização criada — abra o cartão Resumo para a ver",
"notification.recap_ready.name": "Resumo pronto",
"notification.recap_ready.description": "Avisa cada criança, e os pais numa só mensagem agrupada, de que há um novo resumo para ver. Enviado à hora definida em Definições → Resumos."
"notification.recap_ready.description": "Avisa cada criança, e os pais numa só mensagem agrupada, de que há um novo resumo para ver. Enviado à hora definida em Definições → Resumos.",
"chore_board.title": "Quadro de tarefas",
"chore_board.today": "Hoje",
"chore_board.this_week": "Esta semana",
"chore_board.empty": "Sem tarefas hoje.",
"chore_board.status_done": "Feita",
"chore_board.status_pending": "A aguardar aprovação",
"chore_board.status_todo": "Por fazer",
"chore_board.status_missed": "Falhada",
"chore_board.not_recorded": "Não registado",
"chore_board.unavailable": "O quadro de tarefas não está disponível. Reinicie o Home Assistant depois de atualizar o TaskMate.",
"chore_board.no_children": "Nenhuma criança para mostrar. Verifique as crianças escolhidas nas definições do cartão.",
"chore_board.all_done": "Está tudo feito por hoje!",
"chore_board.finish_on_card": "Termine \"{chore}\" no cartão de {name}",
"chore_board.marked_done": "{name}: {chore} feita",
"chore_board.sent_for_approval": "{name}: {chore} enviada para aprovação",
"chore_board.undo": "Anular",
"chore_board.undo_failed": "Não foi possível anular. Tente novamente no cartão da criança.",
"chore_board.error": "Algo correu mal: {message}",
"chore_board.week_hint": "Os dias a cinzento não foram registados. O TaskMate guarda o progresso de cada dia às 23:59.",
"chore_board.editor.children": "Crianças",
"chore_board.editor.children_helper": "Deixe vazio para mostrar todas as crianças. Escolha uma criança para uma lista simples.",
"chore_board.editor.default_view": "Abre em",
"chore_board.editor.show_week": "Mostrar o botão Esta semana",
"chore_board.editor.tap_to_complete": "Tocar numa tarefa para a concluir",
"chore_board.editor.tap_to_complete_helper": "Desative para um quadro só de visualização",
"chore_board.editor.show_legend": "Mostrar a legenda",
"chore_board.editor.hide_done_periods": "Ocultar períodos concluídos",
"chore_board.editor.hide_done_periods_helper": "Retirar um período do dia quando todas as crianças terminaram as tarefas"
}
@@ -19,8 +19,13 @@
(function () {
"use strict";
// Fixed companion entity ids. TaskMate is a single-instance integration
// (config_flow enforces unique_id == DOMAIN), so these ids are stable.
// Default companion entity ids. TaskMate is a single-instance integration
// (config_flow enforces unique_id == DOMAIN), but these are only the ids
// Home Assistant generates by default: since 2026.6 a new entity on a device
// with an area gets the area in front ("sensor.familie_taskmate_bounties"),
// and users rename entities. The overview sensor publishes the real ids as
// `companion_entities`, keyed by the part after "sensor.taskmate_" (#1018);
// these defaults are the fallback when that map is missing.
const COMPANIONS = [
"sensor.taskmate_chores",
"sensor.taskmate_chore_availability",
@@ -40,6 +45,8 @@
// Chore auctions (#982): `auctions`, a digest with no bid amounts. The
// auction card re-fetches its own view over the WebSocket when it moves.
"sensor.taskmate_auctions",
// The Today page's chore board (#1017): `chore_board` for the board card.
"sensor.taskmate_chore_board",
];
// Attributes a companion must NOT contribute to the merge, because another
@@ -66,6 +73,37 @@
],
};
const DEFAULT_PREFIX = "sensor.taskmate_";
const OVERVIEW_ENTITY = "sensor.taskmate_overview";
// The overview's entity map (#1018). Looked up under its default id first,
// then on whichever state carries it, in case the overview was renamed too.
function entityMap(hass) {
const ov = hass.states[OVERVIEW_ENTITY];
if (ov && ov.attributes && ov.attributes.companion_entities) return ov.attributes;
for (const id in hass.states) {
const st = hass.states[id];
if (st && st.attributes && st.attributes.companion_entities) return st.attributes;
}
return null;
}
// [defaultId, realId] for every companion, cached per hass.states object.
let _idCache = { stateRef: null, pairs: null, badges: null };
function resolvedIds(hass) {
if (_idCache.stateRef !== hass.states) {
const map = entityMap(hass);
const real = (map && map.companion_entities) || {};
_idCache = {
stateRef: hass.states,
pairs: COMPANIONS.map((id) => [id, real[id.slice(DEFAULT_PREFIX.length)] || id]),
badges: (map && map.badge_entities) || {},
};
}
return _idCache;
}
function mergedAttributes(hass, primaryEntityId) {
if (!hass || !hass.states) return {};
const merged = {};
@@ -73,10 +111,10 @@
if (primary && primary.attributes) {
Object.assign(merged, primary.attributes);
}
for (const id of COMPANIONS) {
for (const [defaultId, id] of resolvedIds(hass).pairs) {
const s = hass.states[id];
if (!s || !s.attributes) continue;
const skip = COMPANION_SKIP_KEYS[id];
const skip = COMPANION_SKIP_KEYS[defaultId];
if (!skip) {
Object.assign(merged, s.attributes);
continue;
@@ -105,6 +143,19 @@
window.__taskmate_attrs = resolveAttrs;
/**
* 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
* guessing from the default entity id.
*/
function badgesState(hass, childId) {
if (!hass || !hass.states || childId == null) return null;
const id = resolvedIds(hass).badges[String(childId)];
return (id && hass.states[id]) || null;
}
window.__taskmate_badges_state = badgesState;
/**
* Non-admin parent role (#661). True for Home Assistant admins and for users
* the admin has designated as TaskMate parents (published as the
@@ -188,7 +239,7 @@
if (oldHass.states[primaryEntityId] !== newHass.states[primaryEntityId]) return true;
}
for (const id of COMPANIONS) {
for (const [, id] of resolvedIds(newHass).pairs) {
if (oldHass.states[id] !== newHass.states[id]) return true;
}
@@ -2443,6 +2443,10 @@ class TaskMateChildCard extends LitElement {
// child_id — so resolve it robustly or the badge strip never finds it.
_resolveBadgesEntity(child) {
if (!this.hass || !child) return null;
// The real id from the overview's entity map (#1018) — the guesses below
// miss an area-prefixed or renamed sensor.
const mapped = window.__taskmate_badges_state && window.__taskmate_badges_state(this.hass, child.id);
if (mapped) return mapped;
const slug = (s) => String(s || "").toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
const nameSlug = slug(child.name);
const candidates = [
@@ -0,0 +1,807 @@
/**
* TaskMate Chore Board Card (#1017)
*
* The admin panel's Today-page chore board as a dashboard card: rows are the
* family's time periods, columns are the children, and each cell lists that
* child's chores for the period with where they stand (done, awaiting
* approval, to do, missed). A This week toggle swaps in done/total per child
* for the last seven days.
*
* Tapping a chore that is still to do completes it for that child — the same
* button press / complete_chore call the child card makes — and offers Undo
* on a short toast. Chores that need the child card's own flow (a photo, a
* timer, a note or a team join) say so instead of completing.
*
* Reads `chore_board` from sensor.taskmate_chore_board, and `children`,
* `chores`, `time_periods` and `todays_completions` from the overview entity
* and its companions, all through the attribute resolver.
*
* Single layout, so it consumes the design tokens directly (like the bounty
* card): every --tmd-* reference carries the classic value as its fallback.
* Layout classes carry a cb- prefix — the shared design kit defines its own
* .btn / .bar / .av / .chip.
*/
const LitElement = customElements.get("hui-masonry-view")
? Object.getPrototypeOf(customElements.get("hui-masonry-view"))
: Object.getPrototypeOf(customElements.get("hui-view"));
const html = LitElement.prototype.html;
const css = LitElement.prototype.css;
const _safeColor = (c, d) => (typeof c === "string" && /^#[0-9a-fA-F]{3,8}$/.test(c) ? c : d);
const _ltrNums = (s) => (window.__taskmate_design && window.__taskmate_design.ltrNums ? window.__taskmate_design.ltrNums(s) : s);
const DEFAULT_ACCENT = "#3f51b5";
// Child colours by position; a design's own palette wins when it has one.
const CHILD_COLOURS = ["#ff6b9d", "#3498db", "#1abc9c", "#e67e22", "#9b59b6", "#2ecc71"];
const BUILTIN_PERIODS = ["morning", "afternoon", "evening", "night"];
const STATUSES = ["done", "pending", "todo", "missed"];
const TOAST_MS = 5000;
class TaskMateChoreBoardCard extends LitElement {
static get properties() {
return {
hass: { type: Object },
config: { type: Object },
_view: { type: String },
_toast: { type: Object },
_busy: { type: Object },
};
}
constructor() {
super();
this._view = null;
this._toast = null;
this._busy = {};
// `${chore}|${child}` -> { status, count, board } — a tap's result shown
// until the board the tap was made on is replaced by a newer one.
this._optimistic = {};
this._toastTimer = null;
}
setConfig(config) {
if (!config || !config.entity) throw new Error("You need to define an entity");
this.config = config;
}
static getStubConfig() {
return { entity: "sensor.taskmate_overview" };
}
static getConfigElement() {
return document.createElement("taskmate-chore-board-card-editor");
}
getCardSize() {
return 6;
}
disconnectedCallback() {
super.disconnectedCallback();
clearTimeout(this._toastTimer);
}
shouldUpdate(changedProps) {
if (changedProps.has("hass") && changedProps.size === 1) {
return window.__taskmate_hasChanged
? window.__taskmate_hasChanged(changedProps.get("hass"), this.hass, this.config?.entity)
: true;
}
return true;
}
_t(key, params) {
const fn = window.__taskmate_localize;
return fn ? fn(this.hass, key, params) : key;
}
_attrs() {
return (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity))
|| this.hass?.states?.[this.config.entity]?.attributes || {};
}
/** The children to show, in the family's order; none picked means everyone. */
_kids(attrs) {
const all = attrs.children || [];
const picked = Array.isArray(this.config.children) ? this.config.children.map(String) : [];
return picked.length ? all.filter(c => picked.includes(String(c.id))) : all;
}
_kidIndex(attrs, id) {
return Math.max(0, (attrs.children || []).findIndex(c => String(c.id) === String(id)));
}
_kidColour(attrs, id) {
const i = this._kidIndex(attrs, id);
return `var(--tmd-c${(i % 6) + 1}, ${CHILD_COLOURS[i % CHILD_COLOURS.length]})`;
}
/** Time periods in the order of the day, then Anytime for everything else. */
_periods(attrs) {
const hhmm = (s) => {
const [h, m] = String(s || "").split(":").map(Number);
return Number.isFinite(h) ? h + (m || 0) / 60 : 0;
};
let periods;
if (Array.isArray(attrs.time_periods) && attrs.time_periods.length) {
periods = attrs.time_periods
.filter(p => p && p.id)
.map(p => ({ id: p.id, label: p.label || "", icon: p.icon || "mdi:clock-outline", start: hhmm(p.start) }))
.sort((a, b) => a.start - b.start);
} else {
periods = [
{ id: "morning", icon: "mdi:weather-sunset-up" },
{ id: "afternoon", icon: "mdi:weather-sunny" },
{ id: "evening", icon: "mdi:weather-sunset-down" },
{ id: "night", icon: "mdi:weather-night" },
];
}
const label = p => p.label || (BUILTIN_PERIODS.includes(p.id) ? this._t(`common.${p.id}`) : p.id);
return [
...periods.filter(p => p.id !== "anytime").map(p => ({ id: p.id, icon: p.icon, label: label(p) })),
{ id: "anytime", icon: "mdi:clock-outline", label: this._t("common.anytime") },
];
}
/** The board, with any tap still waiting for the server laid over it. */
_boardEntries(board) {
const entries = (board && board.board && board.board.children) || [];
return entries.map(e => ({
...e,
chores: (e.chores || []).map(item => {
const key = `${item.chore_id}|${e.child_id}`;
const opt = this._optimistic[key];
if (!opt) return item;
if (opt.board !== board) {
delete this._optimistic[key];
return item;
}
return { ...item, status: opt.status, count: opt.count };
}),
}));
}
/** A chore whose completion needs the child card: a photo, timer, note or team. */
_needsChildCard(chore) {
return !!(chore.require_photo || chore.task_type === "timed" || chore.open_ended || chore.team);
}
_applyDesign() {
const design = window.__taskmate_design
? window.__taskmate_design.apply(this, this.hass, this.config, this.config.entity)
: "classic";
// Inline on the host: a <style> in the template is ordered before
// adoptedStyleSheets and would lose to the :host default.
const configured = this.config.header_color;
if (typeof configured === "string" && /^#[0-9a-fA-F]{3,8}$/.test(configured)) {
this.style.setProperty("--cb-accent", _safeColor(configured, DEFAULT_ACCENT));
} else {
this.style.removeProperty("--cb-accent");
}
return design;
}
// ── actions ─────────────────────────────────────────────────────────────
_playSound(name) {
const engine = window.__taskmate_sounds;
if (engine) engine.play(name, this._attrs().custom_sounds || []);
}
_showToast(message, undo = null) {
clearTimeout(this._toastTimer);
this._toast = { message, undo };
this._toastTimer = setTimeout(() => { this._toast = null; }, TOAST_MS);
}
async _tap(item, chore, kid, board) {
if (this.config.tap_to_complete === false) return;
if (item.status !== "todo" && item.status !== "missed") return;
if (this._needsChildCard(chore)) {
this._showToast(this._t("chore_board.finish_on_card", { chore: chore.name, name: kid.name }));
return;
}
const key = `${chore.id}|${kid.id}`;
if (this._busy[key]) return;
this._busy = { ...this._busy, [key]: true };
const count = (Number(item.count) || 0) + 1;
const limit = Math.max(1, Number(item.limit) || 1);
const finished = count >= limit;
const status = !finished ? "todo" : chore.requires_approval === false ? "done" : "pending";
this._optimistic = { ...this._optimistic, [key]: { status, count, board } };
try {
// The button entity first, so automations triggered by it fire — the
// same order the child card uses.
const button = window.__taskmate_find_button && window.__taskmate_find_button(this.hass, kid.id, "complete", chore.id);
if (button) {
await this.hass.callService("button", "press", { entity_id: button });
} else {
await this.hass.callService("taskmate", "complete_chore", { chore_id: chore.id, child_id: kid.id });
}
this._playSound(chore.completion_sound || "coin");
const msg = status === "pending" ? "chore_board.sent_for_approval" : "chore_board.marked_done";
this._showToast(this._t(msg, { chore: chore.name, name: kid.name }), { chore_id: chore.id, child_id: kid.id });
} catch (err) {
const next = { ...this._optimistic };
delete next[key];
this._optimistic = next;
this._showToast(this._t("chore_board.error", { message: String(err?.message || err) }));
} finally {
this._busy = { ...this._busy, [key]: false };
}
}
/** Take back the completion the last tap made. */
async _undo(target) {
clearTimeout(this._toastTimer);
this._toast = null;
const latest = (this._attrs().todays_completions || [])
.filter(c => c.chore_id === target.chore_id && c.child_id === target.child_id && !c.bonus_subtask_id)
.sort((a, b) => new Date(b.completed_at || 0) - new Date(a.completed_at || 0))[0];
const completionId = latest && (latest.completion_id || latest.id);
if (!completionId) {
this._showToast(this._t("chore_board.undo_failed"));
return;
}
let service;
if (window.__taskmate_is_parent && window.__taskmate_is_parent(this.hass)) {
service = "reject_chore";
} else {
// A child's own tick, inside the undo window the server publishes (#918).
const until = Date.parse(latest.child_undo_until || "");
if (latest.child_undo_pending === true || (Number.isFinite(until) && until > Date.now())) {
service = "undo_chore";
} else {
this._showToast(this._t("child.undo_not_allowed"));
return;
}
}
try {
await this.hass.callService("taskmate", service, { completion_id: completionId });
const next = { ...this._optimistic };
delete next[`${target.chore_id}|${target.child_id}`];
this._optimistic = next;
this._playSound("undo");
} catch (err) {
this._showToast(this._t("chore_board.error", { message: String(err?.message || err) }));
}
}
// ── render ──────────────────────────────────────────────────────────────
render() {
if (!this.hass || !this.config) return html``;
this._applyDesign();
const attrs = this._attrs();
const title = this.config.title || this._t("chore_board.title");
const showWeek = this.config.show_week !== false;
const view = showWeek ? (this._view || (this.config.default_view === "week" ? "week" : "today")) : "today";
const kids = this._kids(attrs);
const board = attrs.chore_board;
let body;
if (!this.hass.states[this.config.entity]) {
body = html`<div class="cb-empty">${this._t("common.entity_not_found", { entity: this.config.entity })}</div>`;
} else if (!board) {
body = html`<div class="cb-empty">${this._t("chore_board.unavailable")}</div>`;
} else if (!kids.length) {
body = html`<div class="cb-empty">${this._t("chore_board.no_children")}</div>`;
} else if (view === "week") {
body = this._renderWeek(attrs, kids, board);
} else {
body = this._renderToday(attrs, kids, board);
}
return html`
<ha-card>
<div class="cb-hd">
<ha-icon class="cb-hd-icon" icon="mdi:view-grid-outline"></ha-icon>
<div class="cb-hd-title">${title}</div>
${showWeek ? html`
<div class="cb-seg" role="tablist">
${["today", "week"].map(v => html`
<button type="button" role="tab" class="${view === v ? "on" : ""}" aria-selected="${view === v ? "true" : "false"}"
@click=${() => { this._view = v; }}>${this._t(v === "week" ? "chore_board.this_week" : "chore_board.today")}</button>`)}
</div>` : ""}
</div>
<div class="cb-body ${this._toast ? "cb-has-toast" : ""}">${body}</div>
${this._toast ? html`
<div class="cb-toast" role="status">
<span>${this._toast.message}</span>
${this._toast.undo ? html`<button type="button" @click=${() => this._undo(this._toast.undo)}>${this._t("chore_board.undo")}</button>` : ""}
</div>` : ""}
</ha-card>
`;
}
_avatar(attrs, kid) {
const a = kid.avatar || "";
const inner = a.startsWith("mdi:")
? html`<ha-icon icon="${a}"></ha-icon>`
: a ? html`<img src="${a}" alt="">` : (kid.name || "?").charAt(0).toUpperCase();
return html`<span class="cb-av" style="--cb-kc:${this._kidColour(attrs, kid.id)}">${inner}</span>`;
}
/** Each shown child's chores, grouped by time period. */
_cells(attrs, kids, board, periods) {
const choreById = Object.fromEntries((attrs.chores || []).map(c => [c.id, c]));
const known = new Set(periods.map(p => p.id));
const entries = this._boardEntries(board);
const cells = {};
const totals = {};
for (const kid of kids) {
const entry = entries.find(e => String(e.child_id) === String(kid.id));
let done = 0;
let due = 0;
for (const item of (entry && entry.chores) || []) {
const chore = choreById[item.chore_id];
if (!chore) continue;
due += 1;
if (item.status === "done" || item.status === "pending") done += 1;
const slot = known.has(chore.time_category) ? chore.time_category : "anytime";
(cells[`${slot}|${kid.id}`] = cells[`${slot}|${kid.id}`] || []).push({ item, chore });
}
totals[kid.id] = { done, due };
}
return { cells, totals };
}
_visibleRows(kids, periods, cells) {
const finished = list => list.every(({ item }) => item.status === "done" || item.status === "pending");
return periods.filter(p => {
const lists = kids.map(k => cells[`${p.id}|${k.id}`] || []);
if (!lists.some(l => l.length)) return false;
return !(this.config.hide_done_periods && lists.every(finished));
});
}
_chip(item, chore, kid, board) {
const statusLabel = this._t(`chore_board.status_${item.status}`);
const limit = Math.max(1, Number(item.limit) || 1);
const count = limit > 1 ? html` <small>${_ltrNums(`${item.count || 0}/${limit}`)}</small>` : "";
const extra = this._needsChildCard(chore)
? html`<ha-icon class="cb-x" icon="${chore.require_photo ? "mdi:camera-outline" : chore.task_type === "timed" ? "mdi:timer-sand" : chore.team ? "mdi:account-group-outline" : "mdi:pencil-outline"}"></ha-icon>`
: "";
const inner = html`<i class="cb-dot" aria-hidden="true"></i><span class="cb-n">${chore.name}${count}</span>${extra}<span class="cb-sr">${statusLabel}</span>`;
const tappable = this.config.tap_to_complete !== false && (item.status === "todo" || item.status === "missed");
const key = `${chore.id}|${kid.id}`;
return tappable
? html`<button type="button" class="cb-chip ${item.status}" title="${chore.name} · ${statusLabel}"
?disabled=${!!this._busy[key]} @click=${() => this._tap(item, chore, kid, board)}>${inner}</button>`
: html`<span class="cb-chip ${item.status}" title="${chore.name} · ${statusLabel}">${inner}</span>`;
}
_legend() {
if (this.config.show_legend === false) return "";
return html`<div class="cb-legend">${STATUSES.map(s => html`
<span class="cb-chip ${s}"><i class="cb-dot" aria-hidden="true"></i><span class="cb-n">${this._t(`chore_board.status_${s}`)}</span></span>`)}</div>`;
}
_renderToday(attrs, kids, board) {
const periods = this._periods(attrs);
const { cells, totals } = this._cells(attrs, kids, board, periods);
const rows = this._visibleRows(kids, periods, cells);
const anything = kids.some(k => totals[k.id].due);
if (!anything) return html`<div class="cb-empty">${this._t("chore_board.empty")}</div>`;
if (!rows.length) return html`<div class="cb-empty">${this._t("chore_board.all_done")}</div>${this._legend()}`;
if (kids.length === 1) {
const kid = kids[0];
const { done, due } = totals[kid.id];
return html`
<div class="cb-list">
<div class="cb-prog-row">
${this._avatar(attrs, kid)}<span class="cb-prog-name">${kid.name}</span>
<span class="cb-prog-n">${_ltrNums(`${done}/${due}`)}</span>
</div>
<div class="cb-prog"><i style="width:${due ? Math.round((done / due) * 100) : 100}%;background:${this._kidColour(attrs, kid.id)}"></i></div>
${rows.map(p => html`
<div class="cb-grp"><ha-icon icon="${p.icon}"></ha-icon>${p.label}</div>
${(cells[`${p.id}|${kid.id}`] || []).map(({ item, chore }) => this._chip(item, chore, kid, board))}`)}
</div>
${this._legend()}`;
}
return html`
<div class="cb-wrap">
<div class="cb-board" style="--cb-cols:${kids.length}">
<div class="cb-sticky"></div>
${kids.map(k => html`
<div class="cb-kid">${this._avatar(attrs, k)}<span>${k.name}</span>
<small>${_ltrNums(`${totals[k.id].done}/${totals[k.id].due}`)}</small></div>`)}
${rows.map(p => html`
<div class="cb-slot cb-sticky"><ha-icon icon="${p.icon}"></ha-icon><span>${p.label}</span></div>
${kids.map(k => {
const items = cells[`${p.id}|${k.id}`] || [];
return html`<div class="cb-cell">${items.length
? items.map(({ item, chore }) => this._chip(item, chore, k, board))
: html`<span class="cb-none">–</span>`}</div>`;
})}`)}
</div>
</div>
${this._legend()}`;
}
_renderWeek(attrs, kids, board) {
const history = board.history || {};
const today = board.board && board.board.date;
const first = kids.map(k => history[k.id]).find(Boolean) || [];
const lang = this.hass?.locale?.language || this.hass?.language || undefined;
const dayName = iso => {
if (iso === today) return this._t("chore_board.today");
try {
return new Date(`${iso}T12:00:00`).toLocaleDateString(lang, { weekday: "short" });
} catch (_e) {
return iso;
}
};
const gaps = kids.some(k => (history[k.id] || []).some(d => d.due == null));
return html`
<div class="cb-wrap">
<div class="cb-week" style="--cb-days:${first.length || 7}">
<div></div>
${first.map(d => html`<div class="cb-wh ${d.date === today ? "today" : ""}">${dayName(d.date)}</div>`)}
${kids.map(k => html`
<div class="cb-wk">${this._avatar(attrs, k)}<span>${k.name}</span></div>
${(history[k.id] || []).map(d => {
if (d.due == null) return html`<div class="cb-wc none" title="${this._t("chore_board.not_recorded")}">–</div>`;
const pct = d.due ? Math.round((d.done / d.due) * 100) : 100;
return html`<div class="cb-wc" style="--cb-kc:${this._kidColour(attrs, k.id)};--cb-p:${pct}"
title="${dayName(d.date)} · ${d.done}/${d.due}">${d.due ? _ltrNums(`${d.done}/${d.due}`) : "–"}</div>`;
})}`)}
</div>
</div>
${gaps ? html`<p class="cb-hint">${this._t("chore_board.week_hint")}</p>` : ""}`;
}
static get styles() {
const base = css`
:host { display: block; --cb-accent: var(--tmd-accent, #3f51b5); }
ha-card {
position: relative;
overflow: hidden;
background: var(--tmd-surface, var(--ha-card-background, var(--card-background-color, #fff)));
color: var(--tmd-text, var(--primary-text-color));
border-radius: var(--tmd-radius, var(--ha-card-border-radius, 12px));
font-family: var(--tmd-font-body, inherit);
}
.cb-hd {
display: flex; align-items: center; gap: 10px;
padding: 12px 16px;
background: var(--cb-accent);
color: var(--tmd-hd-text, #fff);
}
:host([data-tm-design="playroom"]) .cb-hd {
background: linear-gradient(135deg, var(--cb-accent), color-mix(in srgb, var(--cb-accent) 72%, #fff));
}
.cb-hd-icon { --mdc-icon-size: 26px; flex: none; opacity: .95; }
.cb-hd-title {
flex: 1; min-width: 0;
font-family: var(--tmd-font-display, inherit);
font-size: 1.2rem; font-weight: 600;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.cb-seg { display: inline-flex; flex: none; background: rgba(255, 255, 255, .18); border-radius: 9px; padding: 2px; gap: 2px; }
.cb-seg button {
background: none; border: 0; cursor: pointer;
color: inherit; opacity: .85;
font: inherit; font-size: .8rem; font-weight: 500;
padding: 5px 11px; border-radius: 7px; min-height: 30px;
}
.cb-seg button.on { background: #fff; color: var(--cb-accent); opacity: 1; }
:host([data-tm-design="accessible"]) .cb-seg button.on { color: #111; outline: 2px solid #111; }
.cb-body { padding: 10px 16px 14px; }
/* Room for the toast, so it never sits over the last row. */
.cb-body.cb-has-toast { padding-bottom: 66px; }
.cb-empty { text-align: center; padding: 28px 12px; color: var(--tmd-dim, var(--secondary-text-color)); }
.cb-wrap { overflow-x: auto; margin: 0 -4px; padding: 0 4px; }
.cb-board {
display: grid; grid-template-columns: 110px repeat(var(--cb-cols), minmax(140px, 1fr));
font-size: 13px;
}
.cb-board > div {
padding: 8px 10px; min-width: 0;
border-bottom: 1px solid var(--tmd-border, var(--divider-color, rgba(127, 127, 127, .2)));
}
.cb-sticky {
position: sticky; inset-inline-start: 0; z-index: 1;
background: var(--tmd-surface, var(--ha-card-background, var(--card-background-color, #fff)));
}
.cb-kid { display: flex; align-items: center; gap: 7px; font-weight: 600; font-size: 12.5px; }
.cb-kid span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cb-kid small {
margin-inline-start: auto; font-weight: 500; font-variant-numeric: tabular-nums;
color: var(--tmd-dim, var(--secondary-text-color));
}
.cb-slot { display: flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--tmd-dim, var(--secondary-text-color)); }
.cb-slot ha-icon { --mdc-icon-size: 16px; flex: none; }
.cb-cell { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; }
.cb-none { color: var(--tmd-dim, var(--secondary-text-color)); opacity: .5; }
.cb-av {
width: 24px; height: 24px; flex: none; border-radius: 50%; overflow: hidden;
display: inline-grid; place-items: center;
background: var(--cb-kc, #9b59b6); color: #fff;
font-size: 11px; font-weight: 700;
}
.cb-av ha-icon { --mdc-icon-size: 16px; }
.cb-av img { width: 100%; height: 100%; object-fit: cover; }
.cb-chip {
display: inline-flex; align-items: center; gap: 7px; max-width: 100%;
background: none; border: 0; padding: 4px 5px; margin-inline-start: -5px;
border-radius: 6px; min-height: 28px;
font: inherit; font-size: 12.5px; color: inherit; text-align: start;
}
button.cb-chip { cursor: pointer; }
button.cb-chip:hover { background: var(--tmd-surface-2, rgba(127, 127, 127, .12)); }
button.cb-chip[disabled] { opacity: .6; cursor: default; }
.cb-n { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cb-n small { color: var(--tmd-dim, var(--secondary-text-color)); }
.cb-x { --mdc-icon-size: 14px; flex: none; color: var(--tmd-dim, var(--secondary-text-color)); }
.cb-dot {
width: 14px; height: 14px; flex: none; border-radius: 50%; box-sizing: border-box;
border: 2px solid var(--tmd-dim, var(--secondary-text-color));
}
.cb-chip.done .cb-dot { background: var(--tmd-good, #2ecc71); border-color: var(--tmd-good, #2ecc71); }
.cb-chip.done .cb-n { color: var(--tmd-dim, var(--secondary-text-color)); text-decoration: line-through; }
.cb-chip.pending .cb-dot {
border-color: var(--tmd-warn, #f39c12);
background: color-mix(in srgb, var(--tmd-warn, #f39c12) 30%, transparent);
}
.cb-chip.missed .cb-dot { border-color: var(--tmd-bad, #e74c3c); }
.cb-chip.missed .cb-n { color: var(--tmd-bad, #e74c3c); }
.cb-sr {
position: absolute; width: 1px; height: 1px; overflow: hidden;
clip: rect(0 0 0 0); white-space: nowrap;
}
.cb-legend {
display: flex; flex-wrap: wrap; gap: 4px 14px; margin-top: 10px;
font-size: 12px; color: var(--tmd-dim, var(--secondary-text-color));
}
.cb-legend .cb-chip { padding: 0; margin: 0; min-height: 0; font-size: 12px; }
.cb-legend .cb-chip .cb-n { color: inherit; text-decoration: none; }
.cb-list { display: flex; flex-direction: column; align-items: stretch; }
.cb-list .cb-chip { font-size: 14px; min-height: 36px; margin-inline-start: 0; }
.cb-prog-row { display: flex; align-items: center; gap: 8px; font-weight: 600; }
.cb-prog-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cb-prog-n { font-variant-numeric: tabular-nums; color: var(--tmd-dim, var(--secondary-text-color)); }
.cb-prog {
height: 6px; border-radius: 3px; overflow: hidden; margin: 8px 0 4px;
background: var(--tmd-surface-2, var(--divider-color, rgba(127, 127, 127, .2)));
}
.cb-prog > i { display: block; height: 100%; }
.cb-grp {
display: flex; align-items: center; gap: 6px; margin: 10px 0 2px;
font-size: 12px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase;
color: var(--tmd-dim, var(--secondary-text-color));
}
.cb-grp ha-icon { --mdc-icon-size: 15px; }
.cb-week {
display: grid; grid-template-columns: 100px repeat(var(--cb-days), minmax(38px, 1fr));
gap: 6px; align-items: center; font-size: 12px;
}
.cb-wh { text-align: center; font-weight: 500; color: var(--tmd-dim, var(--secondary-text-color)); }
.cb-wh.today { color: var(--tmd-text, var(--primary-text-color)); font-weight: 600; }
.cb-wk { display: flex; align-items: center; gap: 6px; min-width: 0; font-size: 13px; }
.cb-wk span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cb-wc {
height: 32px; border-radius: 7px;
display: grid; place-items: center;
font-weight: 600; font-variant-numeric: tabular-nums;
background: color-mix(in srgb, var(--cb-kc) calc(15% + var(--cb-p) * .7%), var(--tmd-surface-2, rgba(127, 127, 127, .12)));
}
.cb-wc.none {
background: var(--tmd-surface-2, rgba(127, 127, 127, .12));
color: var(--tmd-dim, var(--secondary-text-color)); font-weight: 400;
}
.cb-hint { margin: 10px 0 0; font-size: 12px; color: var(--tmd-dim, var(--secondary-text-color)); }
.cb-toast {
position: absolute; inset-inline: 12px; bottom: 12px; z-index: 3;
display: flex; align-items: center; gap: 12px;
padding-block: 8px; padding-inline: 14px 8px; border-radius: 8px;
background: #323232; color: #fff; font-size: 13px;
box-shadow: 0 4px 12px rgba(0, 0, 0, .4);
}
.cb-toast span { flex: 1; min-width: 0; }
.cb-toast button {
flex: none; background: none; border: 0; cursor: pointer;
color: var(--primary-color, #03a9f4); font: inherit; font-weight: 600;
padding: 6px 10px; min-height: 32px;
}
:host([data-tm-design="accessible"]) .cb-toast button { color: #ffd54f; text-decoration: underline; }
`;
// The token block is :host-scoped, so it only takes effect when included
// in THIS card's styles — a document-level rule cannot cross into the
// shadow root.
const tokens = window.__taskmate_design && window.__taskmate_design.styles
? window.__taskmate_design.styles() : null;
return tokens ? [tokens, base] : base;
}
}
class TaskMateChoreBoardCardEditor extends LitElement {
static get properties() {
return { hass: { type: Object }, config: { type: Object } };
}
setConfig(config) { this.config = config; }
_t(key, params) {
const fn = window.__taskmate_localize;
return fn ? fn(this.hass, key, params) : key;
}
_buildSchema() {
const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config?.entity))
|| this.hass?.states?.[this.config?.entity]?.attributes || {};
const children = attrs.children || [];
return [
{ name: "entity", selector: { entity: { domain: "sensor" } } },
{ name: "title", selector: { text: {} } },
{
name: "children",
selector: { select: { multiple: true, mode: "list", options: children.map(c => ({ value: c.id, label: c.name })) } },
},
{
name: "default_view",
selector: {
select: {
mode: "dropdown",
options: [
{ value: "today", label: this._t("chore_board.today") },
{ value: "week", label: this._t("chore_board.this_week") },
],
},
},
},
{ name: "show_week", selector: { boolean: {} } },
{ name: "tap_to_complete", selector: { boolean: {} } },
{ name: "show_legend", selector: { boolean: {} } },
{ name: "hide_done_periods", selector: { boolean: {} } },
{
name: "card_design",
selector: {
select: {
options: window.__taskmate_design
? window.__taskmate_design.editorOptions(this._t.bind(this))
: [{ value: "global", label: "Use global default" }],
mode: "dropdown",
},
},
},
];
}
_computeLabel = (entry) => ({
entity: this._t("common.editor.overview_entity"),
title: this._t("common.editor.card_title"),
children: this._t("chore_board.editor.children"),
default_view: this._t("chore_board.editor.default_view"),
show_week: this._t("chore_board.editor.show_week"),
tap_to_complete: this._t("chore_board.editor.tap_to_complete"),
show_legend: this._t("chore_board.editor.show_legend"),
hide_done_periods: this._t("chore_board.editor.hide_done_periods"),
card_design: this._t("common.design.field_label"),
}[entry.name] ?? entry.name);
_computeHelper = (entry) => ({
entity: this._t("common.editor.overview_entity_helper"),
children: this._t("chore_board.editor.children_helper"),
tap_to_complete: this._t("chore_board.editor.tap_to_complete_helper"),
hide_done_periods: this._t("chore_board.editor.hide_done_periods_helper"),
}[entry.name] ?? "");
render() {
if (!this.hass || !this.config) return html``;
const data = {
entity: this.config.entity || "",
title: this.config.title || "",
children: Array.isArray(this.config.children) ? this.config.children : [],
default_view: this.config.default_view || "today",
show_week: this.config.show_week !== false,
tap_to_complete: this.config.tap_to_complete !== false,
show_legend: this.config.show_legend !== false,
hide_done_periods: this.config.hide_done_periods === true,
card_design: this.config.card_design || "global",
};
return html`
<ha-form
.hass=${this.hass}
.data=${data}
.schema=${this._buildSchema()}
.computeLabel=${this._computeLabel}
.computeHelper=${this._computeHelper}
@value-changed=${this._formChanged}
></ha-form>
${this._renderColourPicker("header_color", DEFAULT_ACCENT)}
`;
}
_renderColourPicker(key, defaultValue) {
const d = window.__taskmate_design;
const current = this.config[key] || defaultValue;
if (!d || !d.colourPicker) return html``;
return d.colourPicker({
defaultValue, current,
label: this._t("common.editor.header_colour"),
helper: this._t("common.editor.header_colour_helper"),
resetLabel: this._t("common.reset"),
onInput: (v) => this._updateConfig(key, v),
onPreset: (v) => this._updateConfig(key, v),
onReset: () => this._updateConfig(key, defaultValue),
});
}
// Defaults are the absence of a key, so a saved card only carries what was
// changed from them.
static _isDefault(key, value) {
if (value === "" || value === null || value === undefined) return true;
if (key === "card_design") return value === "global";
if (key === "default_view") return value === "today";
if (key === "children") return Array.isArray(value) && value.length === 0;
if (["show_week", "tap_to_complete", "show_legend"].includes(key)) return value === true;
if (key === "hide_done_periods") return value === false;
return false;
}
_formChanged(e) {
const newValues = e.detail.value || {};
const newConfig = { ...this.config };
for (const [key, value] of Object.entries(newValues)) {
if (TaskMateChoreBoardCardEditor._isDefault(key, value)) delete newConfig[key];
else newConfig[key] = value;
}
this._fire(newConfig);
}
_updateConfig(key, value) {
const newConfig = { ...this.config, [key]: value };
if (value === null || value === "" || value === undefined) delete newConfig[key];
this._fire(newConfig);
}
_fire(config) {
this.dispatchEvent(new CustomEvent("config-changed", {
detail: { config }, bubbles: true, composed: true,
}));
}
static get styles() {
return css`
:host { display: block; }
ha-form { display: block; margin-bottom: 16px; }
.colour-field { display: flex; flex-direction: column; gap: 8px; padding: 12px 16px; border: 1px solid var(--outline-color, var(--divider-color, #e0e0e0)); border-radius: 4px; background: var(--mdc-text-field-fill-color, var(--card-background-color)); }
.colour-field-label { font-size: 0.82rem; color: var(--primary-color); font-weight: 500; }
.colour-field-body { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.colour-swatch-wrapper { position: relative; width: 36px; height: 36px; border-radius: 50%; overflow: hidden; cursor: pointer; border: 2px solid var(--divider-color, #e0e0e0); flex-shrink: 0; }
.colour-swatch-wrapper input[type="color"] { position: absolute; inset: 0; opacity: 0; cursor: pointer; border: 0; padding: 0; }
.colour-swatch-preview { position: absolute; inset: 0; pointer-events: none; }
.colour-hex { font-family: var(--code-font-family, monospace); font-size: 0.85rem; color: var(--secondary-text-color); min-width: 70px; }
.colour-presets { display: flex; gap: 6px; flex-wrap: wrap; }
.preset-swatch { width: 22px; height: 22px; border-radius: 50%; cursor: pointer; border: 2px solid var(--divider-color, #e0e0e0); transition: transform 0.1s; padding: 0; }
.preset-swatch:hover { transform: scale(1.15); }
.preset-swatch.active { border-color: var(--primary-text-color); box-shadow: 0 0 0 2px var(--primary-color); }
.colour-reset { font-size: 0.78rem; color: var(--secondary-text-color); background: none; border: 1px solid var(--divider-color, #e0e0e0); border-radius: 4px; padding: 4px 10px; cursor: pointer; margin-inline-start: auto; }
.colour-helper { color: var(--secondary-text-color); font-size: 0.82rem; line-height: 1.3; }
`;
}
}
customElements.define("taskmate-chore-board-card", TaskMateChoreBoardCard);
customElements.define("taskmate-chore-board-card-editor", TaskMateChoreBoardCardEditor);
window.customCards = window.customCards || [];
window.customCards.push({
type: "taskmate-chore-board-card",
name: "TaskMate Chore Board",
description: "Every child's chores for today by time of day — tap one to complete it",
preview: true,
getEntitySuggestion: (hass, entityId) =>
window.__taskmate_suggest(hass, entityId, "taskmate-chore-board-card", "overview"),
});