331 files
This commit is contained in:
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -17,5 +17,5 @@
|
||||
"iot_class": "calculated",
|
||||
"issue_tracker": "https://github.com/tempus2016/taskmate/issues",
|
||||
"requirements": [],
|
||||
"version": "6.0.0"
|
||||
"version": "6.1.0"
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
});
|
||||
Reference in New Issue
Block a user