348 files

This commit is contained in:
Home Assistant Version Control
2026-07-23 19:02:12 +00:00
parent 0ed1687503
commit 016af2e013
348 changed files with 12368 additions and 11689 deletions
+70 -1
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import copy
import logging
from datetime import timedelta
from functools import wraps
from pathlib import Path
@@ -14,6 +15,7 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import ServiceValidationError, Unauthorized
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.service import async_set_service_schema
from homeassistant.util import dt as dt_util
from .const import (
ATTR_AS_PARENT,
@@ -39,12 +41,14 @@ from .const import (
ATTR_CHILD_ID,
ATTR_CHORE_ASSIGNED_TO,
ATTR_CHORE_DESCRIPTION,
ATTR_CHORE_EXPIRES_IN_MINUTES,
ATTR_CHORE_ID,
ATTR_CHORE_NAME,
ATTR_CHORE_ONE_SHOT,
ATTR_CHORE_ORDER,
ATTR_CHORE_POINTS,
ATTR_CHORE_REQUIRES_APPROVAL,
ATTR_CHORE_SPEED_BONUS_POINTS,
ATTR_CHORE_TIME_CATEGORY,
ATTR_PENALTY_ASSIGNED_TO,
ATTR_PENALTY_DESCRIPTION,
@@ -85,6 +89,7 @@ from .const import (
SERVICE_PAUSE_TIMED_TASK,
SERVICE_POSTPONE_MANDATORY_CHORE,
SERVICE_PREVIEW_SOUND,
SERVICE_READ_ALOUD,
SERVICE_RECORD_ALLOWANCE_PAYOUT,
SERVICE_REJECT_CHORE,
SERVICE_REJECT_REWARD,
@@ -96,6 +101,7 @@ from .const import (
SERVICE_SET_CHORE_MANUAL_START,
SERVICE_SET_CHORE_ORDER,
SERVICE_SKIP_CHORE,
SERVICE_SPIN_ROULETTE,
SERVICE_START_TIMED_TASK,
SERVICE_STOP_TIMED_TASK,
SERVICE_TEST_NOTIFICATION,
@@ -619,6 +625,37 @@ async def _async_register_services(hass: HomeAssistant) -> None:
await _async_require_linked_child(hass, call, coordinator, requester_id)
await coordinator.async_request_swap(call.data["chore_id"], requester_id)
async def handle_read_aloud(call: ServiceCall) -> None:
"""Speak a child's outstanding chores to a media player (#684)."""
coordinator = _get_coordinator(hass)
if not coordinator:
_LOGGER.error("No TaskMate coordinator available")
return
try:
await coordinator.async_read_aloud(
child_id=call.data[ATTR_CHILD_ID],
media_player=call.data.get("media_player", ""),
tts_entity=call.data.get("tts_entity", ""),
message=call.data.get("message", ""),
)
except ValueError as err:
raise ServiceValidationError(str(err)) from err
async def handle_spin_roulette(call: ServiceCall) -> None:
"""A child spins for a random chore at a multiplier (#677)."""
coordinator = _get_coordinator(hass)
if not coordinator:
_LOGGER.error("No TaskMate coordinator available")
return
child_id = call.data[ATTR_CHILD_ID]
await _async_require_linked_child(hass, call, coordinator, child_id)
try:
await coordinator.async_spin_roulette(child_id)
except ValueError as err:
# Roulette off, no spins left, nothing to spin for — all normal
# states the child should be told about, not server errors.
raise ServiceValidationError(str(err)) from err
async def handle_choose_avatar(call: ServiceCall) -> None:
"""A child switches to an avatar they've unlocked."""
coordinator = _get_coordinator(hass)
@@ -882,7 +919,14 @@ async def _async_register_services(hass: HomeAssistant) -> None:
if not coordinator:
_LOGGER.error("No TaskMate coordinator available")
return
schedule_mode = "one_shot" if call.data.get(ATTR_CHORE_ONE_SHOT, False) else "specific_days"
expires_in = call.data.get(ATTR_CHORE_EXPIRES_IN_MINUTES, 0) or 0
# A chore with a deadline is inherently a one-off — it exists to be done
# now, so it should never linger into tomorrow.
one_shot = call.data.get(ATTR_CHORE_ONE_SHOT, False) or bool(expires_in)
schedule_mode = "one_shot" if one_shot else "specific_days"
deadline_at = ""
if expires_in:
deadline_at = (dt_util.now() + timedelta(minutes=expires_in)).isoformat()
await coordinator.async_add_chore(
name=call.data[ATTR_CHORE_NAME],
description=call.data.get(ATTR_CHORE_DESCRIPTION, ""),
@@ -892,6 +936,8 @@ async def _async_register_services(hass: HomeAssistant) -> None:
requires_approval=call.data.get(ATTR_CHORE_REQUIRES_APPROVAL, True),
difficulty=call.data.get("difficulty", "medium"),
schedule_mode=schedule_mode,
deadline_at=deadline_at,
speed_bonus_points=call.data.get(ATTR_CHORE_SPEED_BONUS_POINTS, 0),
)
async def handle_add_badge(call: ServiceCall) -> None:
@@ -1188,6 +1234,25 @@ async def _async_register_services(hass: HomeAssistant) -> None:
),
)
hass.services.async_register(
DOMAIN,
SERVICE_READ_ALOUD,
_safe(handle_read_aloud),
schema=vol.Schema({
vol.Required(ATTR_CHILD_ID): cv.string,
vol.Optional("media_player", default=""): cv.string,
vol.Optional("tts_entity", default=""): cv.string,
vol.Optional("message", default=""): cv.string,
}),
)
hass.services.async_register(
DOMAIN,
SERVICE_SPIN_ROULETTE,
_safe(handle_spin_roulette),
schema=vol.Schema({vol.Required(ATTR_CHILD_ID): cv.string}),
)
hass.services.async_register(
DOMAIN,
SERVICE_CHOOSE_AVATAR,
@@ -1398,6 +1463,10 @@ async def _async_register_services(hass: HomeAssistant) -> None:
vol.Optional("difficulty", default=DEFAULT_DIFFICULTY): vol.In(DIFFICULTY_TIERS),
vol.Optional(ATTR_CHORE_ONE_SHOT, default=False): cv.boolean,
vol.Optional(ATTR_CHORE_REQUIRES_APPROVAL, default=True): cv.boolean,
vol.Optional(ATTR_CHORE_EXPIRES_IN_MINUTES, default=0): vol.All(
cv.positive_int, vol.Range(max=10080)
),
vol.Optional(ATTR_CHORE_SPEED_BONUS_POINTS, default=0): cv.positive_int,
}),
)
+4 -1
View File
@@ -139,7 +139,10 @@ class CompleteChoreButton(TaskMateBaseButton):
def icon(self) -> str:
"""Return the icon."""
chore = self.coordinator.get_chore(self.chore_id)
return getattr(chore, 'icon', "mdi:check-circle") if chore else "mdi:check-circle"
# Chores gained an optional icon in #683, defaulting to "". Fall back on
# falsiness, not on the attribute being absent — otherwise every chore
# without a picture gets a blank button icon.
return (getattr(chore, 'icon', "") or "mdi:check-circle") if chore else "mdi:check-circle"
@property
def extra_state_attributes(self) -> dict:
+4
View File
@@ -256,6 +256,8 @@ SERVICE_TEST_NOTIFICATION: Final = "test_notification"
SERVICE_GIFT_POINTS: Final = "gift_points"
SERVICE_RECORD_ALLOWANCE_PAYOUT: Final = "record_allowance_payout"
SERVICE_REQUEST_SWAP: Final = "request_swap"
SERVICE_SPIN_ROULETTE: Final = "spin_roulette"
SERVICE_READ_ALOUD: Final = "read_aloud"
SERVICE_CHOOSE_AVATAR: Final = "choose_avatar"
SERVICE_SET_CHORE_ORDER: Final = "set_chore_order"
SERVICE_PREVIEW_SOUND: Final = "preview_sound"
@@ -310,6 +312,8 @@ ATTR_CHORE_ASSIGNED_TO: Final = "assigned_to"
ATTR_CHORE_TIME_CATEGORY: Final = "time_category"
ATTR_CHORE_ONE_SHOT: Final = "one_shot"
ATTR_CHORE_REQUIRES_APPROVAL: Final = "requires_approval"
ATTR_CHORE_EXPIRES_IN_MINUTES: Final = "expires_in_minutes"
ATTR_CHORE_SPEED_BONUS_POINTS: Final = "speed_bonus_points"
ATTR_BONUS_SUBTASK_ID: Final = "bonus_subtask_id"
# Badge attributes
+94 -13
View File
@@ -10,7 +10,7 @@ from typing import TYPE_CHECKING, Any
from homeassistant.core import callback
from homeassistant.util import dt as dt_util
from .models import Chore
from .models import Chore, optional_float
if TYPE_CHECKING:
pass
@@ -22,35 +22,61 @@ class AssignmentsMixin:
"""Mixin providing assignment rotation, availability, and visibility logic."""
def _refresh_tracked_availability_entities(self) -> None:
"""Rebuild the cached set of entities that gate child availability.
"""Rebuild the cached set of external entities TaskMate reacts to.
Children only change via mutations (which trigger a coordinator
refresh), so this is rebuilt on refresh rather than on every bus
state_changed event — which fires for every entity in HA (PERF-5).
Two groups, kept apart because they trigger different work:
* child availability/unavailability entities — a change here can
re-assign a rotation chore, so it schedules a re-evaluation;
* chore visibility and weather entities — a change here only alters
what's *visible*, so it just invalidates the sensor attribute cache.
Children and chores only change via mutations (which trigger a
coordinator refresh), so this is rebuilt on refresh rather than on
every bus state_changed event — which fires for every entity in HA
(PERF-5).
"""
tracked: set[str] = set()
availability: set[str] = set()
for c in self.storage.get_children():
if getattr(c, "availability_entity", ""):
tracked.add(c.availability_entity)
availability.add(c.availability_entity)
if getattr(c, "unavailability_entity", ""):
tracked.add(c.unavailability_entity)
self._tracked_availability_entities = tracked
availability.add(c.unavailability_entity)
self._tracked_availability_entities = availability
visibility: set[str] = set()
for chore in self.storage.get_chores():
if getattr(chore, "visibility_entity", ""):
visibility.add(chore.visibility_entity)
if getattr(chore, "weather_entity", ""):
visibility.add(chore.weather_entity)
self._tracked_visibility_entities = visibility
@callback
def _availability_state_changed(self, event: Any) -> None:
"""Cheap bus-filter: dispatch a re-eval only when a tracked entity changes.
"""Cheap bus-filter: react only when a tracked entity changes.
Decorated @callback so it runs on the event loop (a plain listener runs
in the executor, where async_create_task raises) and reads the cached
tracked-entity set rather than rescanning all children per event.
tracked-entity sets rather than rescanning all children per event.
"""
data = getattr(event, "data", None) or {}
entity_id = data.get("entity_id")
if not entity_id:
return
if entity_id not in getattr(self, "_tracked_availability_entities", set()):
is_availability = entity_id in getattr(self, "_tracked_availability_entities", set())
is_visibility = entity_id in getattr(self, "_tracked_visibility_entities", set())
if not (is_availability or is_visibility):
return
self.hass.async_create_task(self._async_reevaluate_availability())
# The data snapshot is reused while storage.data_version is unchanged,
# so without this the chores/availability sensors would keep serving
# attributes computed against the old entity state.
self.external_state_version += 1
if is_availability:
self.hass.async_create_task(self._async_reevaluate_availability())
async def _async_reevaluate_availability(self) -> None:
"""Re-run assignment for require_availability chores when availability flips.
@@ -182,6 +208,61 @@ class AssignmentsMixin:
return False
# Reasons a weather-gated chore is currently hidden. Returned by
# _weather_block_reason so the panel and sensor can explain the gap
# rather than silently dropping the chore off the list.
WEATHER_REASON_CONDITION = "condition"
WEATHER_REASON_TEMP_LOW = "temp_low"
WEATHER_REASON_TEMP_HIGH = "temp_high"
WEATHER_REASON_WIND = "wind"
def weather_block_reason(self, chore) -> str | None:
"""Return why the weather blocks this chore right now, else ``None``.
Fail-open at every step: no entity configured, an entity that doesn't
exist, an unavailable/unknown state, or a missing attribute all mean
"not blocked". A weather integration going offline must never silently
hide the family's chores.
"""
entity_id = (getattr(chore, "weather_entity", "") or "").strip()
if not entity_id:
return None
state_obj = self.hass.states.get(entity_id)
if state_obj is None or state_obj.state in ("unavailable", "unknown", None, ""):
_LOGGER.debug(
"Weather entity '%s' unavailable, not blocking chore '%s'",
entity_id, getattr(chore, "name", ""),
)
return None
blocked = [str(c).lower() for c in (getattr(chore, "weather_block_conditions", []) or [])]
if blocked and state_obj.state.lower() in blocked:
return self.WEATHER_REASON_CONDITION
attrs = getattr(state_obj, "attributes", {}) or {}
temp = optional_float(attrs.get("temperature"))
if temp is not None:
temp_min = optional_float(getattr(chore, "weather_temp_min", None))
if temp_min is not None and temp < temp_min:
return self.WEATHER_REASON_TEMP_LOW
temp_max = optional_float(getattr(chore, "weather_temp_max", None))
if temp_max is not None and temp > temp_max:
return self.WEATHER_REASON_TEMP_HIGH
wind = optional_float(attrs.get("wind_speed"))
if wind is not None:
wind_max = optional_float(getattr(chore, "weather_wind_max", None))
if wind_max is not None and wind > wind_max:
return self.WEATHER_REASON_WIND
return None
def _is_weather_ok_for_chore(self, chore) -> bool:
"""True when current weather permits this chore (or no gate is set)."""
return self.weather_block_reason(chore) is None
def _is_child_available(self, child_id: str) -> bool:
"""Return True when the child should receive chores today.
+106 -3
View File
@@ -63,6 +63,8 @@ class ChoresMixin:
publish_calendar_entities: list[str] | None = None,
require_availability: bool = False,
manual_start_child_id: str = "",
deadline_at: str = "",
speed_bonus_points: int = 0,
) -> Chore:
"""Add a new chore."""
# One-shot chores: force daily_limit=1, set created_date to today
@@ -112,6 +114,8 @@ class ChoresMixin:
assignment_rotation_anchor=assignment_rotation_anchor,
publish_calendar_entities=list(publish_calendar_entities or []),
require_availability=require_availability,
deadline_at=deadline_at,
speed_bonus_points=max(0, int(speed_bonus_points or 0)),
)
# Cache today's active child so the card can show it immediately
active = self._compute_active_children(chore, today)
@@ -183,6 +187,82 @@ class ChoresMixin:
await self.storage.async_save()
await self.async_refresh()
def chore_deadline(self, chore) -> datetime | None:
"""A reactive chore's deadline as an aware datetime, or None if unset."""
raw = (getattr(chore, "deadline_at", "") or "").strip()
if not raw:
return None
try:
parsed = datetime.fromisoformat(raw)
except (ValueError, TypeError):
_LOGGER.warning(
"Chore '%s' has an unparseable deadline_at %r — ignoring it",
getattr(chore, "name", ""), raw,
)
return None
# A naive value came from hand-edited storage; treat it as local time
# rather than silently comparing across timezones.
if parsed.tzinfo is None:
return dt_util.as_local(parsed.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE))
return dt_util.as_local(parsed)
def chore_deadline_passed(self, chore, now: datetime | None = None) -> bool:
"""True when this chore had a deadline and it has already gone."""
deadline = self.chore_deadline(chore)
if deadline is None:
return False
return dt_util.as_local(now or dt_util.now()) > deadline
async def _async_expire_deadline_chores(self, refresh: bool = True) -> None:
"""Soft-disable reactive chores whose deadline has passed.
Runs on the 30s poll as well as at midnight — a 30-minute "empty the
washing machine" chore must not sit on the card until tomorrow.
``refresh=False`` is required when calling this from inside
``_async_update_data``: that *is* the refresh, and asking the
coordinator to refresh re-entrantly deadlocks it. The caller there
builds its snapshot after us anyway, and our save has already bumped
``data_version``, so the disabled chore lands in the same tick.
"""
now = dt_util.now()
changed = False
for chore in self.storage.get_chores():
if not getattr(chore, "enabled", True):
continue
if not self.chore_deadline_passed(chore, now):
continue
chore.enabled = False
self.storage.update_chore(chore)
changed = True
_LOGGER.info(
"Reactive chore '%s' expired (deadline %s)",
chore.name, getattr(chore, "deadline_at", ""),
)
self.hass.bus.async_fire(
"taskmate_chore_expired",
{
"chore_id": chore.id,
"chore_name": chore.name,
"deadline_at": getattr(chore, "deadline_at", ""),
"timestamp": now.isoformat(),
},
)
if changed:
await self.storage.async_save()
if refresh:
await self.async_refresh()
def _apply_speed_bonus(self, chore, base: int, completed_at) -> int:
"""Add a reactive chore's speed bonus when its deadline was beaten."""
bonus = int(getattr(chore, "speed_bonus_points", 0) or 0)
if bonus <= 0:
return base
deadline = self.chore_deadline(chore)
if deadline is None or dt_util.as_local(completed_at) > deadline:
return base
return base + bonus
def _apply_time_adjustment(self, chore, base: int, completed_at) -> int:
"""Apply a chore's early-bonus / late-penalty based on completion time."""
due = getattr(chore, "due_time", "") or ""
@@ -389,6 +469,8 @@ class ChoresMixin:
self.storage.remove_last_completed_for_chore(chore_id)
# Strip chore from any task group it belonged to.
self.storage.remove_chore_from_task_groups(chore_id)
# Drop queued scheduled changes (#675) — nothing left to apply them to.
self.storage.remove_scheduled_changes_for_chore(chore_id)
# Remove chore from children's chore_order lists
for child in self.storage.get_children():
if chore_id in child.chore_order:
@@ -623,8 +705,14 @@ class ChoresMixin:
if requires_photo and not as_parent and not (photo_url or "").strip():
raise ValueError("This chore requires a photo as evidence.")
auto_approve = as_parent or (not chore.requires_approval and not requires_photo)
effective_points = self._apply_time_adjustment(
chore, self.effective_chore_points(chore), now
effective_points = self._apply_roulette_multiplier(
chore,
child_id,
self._apply_speed_bonus(
chore,
self._apply_time_adjustment(chore, self.effective_chore_points(chore), now),
now,
),
)
completion = ChoreCompletion(
@@ -671,7 +759,10 @@ class ChoresMixin:
# Fire approval notification only if it stays pending
if not auto_approve:
await self._async_notify_pending_approval(child.name, chore.name, chore.points, completion_id=completion.id)
await self._async_notify_pending_approval(
child.name, chore.name, chore.points,
completion_id=completion.id, photo_url=completion.photo_url,
)
await self.async_refresh()
@@ -1181,6 +1272,18 @@ class ChoresMixin:
if not self._is_visibility_entity_active(visibility_entity, visibility_state, visibility_operator):
return False
# Weather gate (#673): an outdoor chore stays hidden while the chosen
# weather entity reports unsuitable conditions. Because this sits in the
# availability path, a rained-off chore also stops counting as a
# mandatory miss and can't break a streak.
if not self._is_weather_ok_for_chore(chore):
return False
# Reactive chores (#674): once the deadline passes the chore is gone,
# even before the next sweep soft-disables it.
if self.chore_deadline_passed(chore):
return False
# Chore dependencies (FEAT-1): this chore unlocks only once every chore
# it depends on has an approved completion today by this same child.
depends_on = getattr(chore, 'depends_on', []) or []
@@ -159,7 +159,14 @@ class NotificationCoordinator:
recipients_fired: list[str] = []
message = self._render_template(meta, context)
# Multi-parent routing (#687): thin the PARENT recipients down per the
# configured policy. Child routes are never touched — a reminder for a
# child must always reach that child.
allowed_parents = self._route_parents(type_id, cfg, only_recipients)
for recipient_id, route in cfg.routes.items():
if recipient_id.startswith("parent:") and recipient_id not in allowed_parents:
continue
if not route.enabled:
continue
if only_recipients is not None and recipient_id not in only_recipients:
@@ -184,6 +191,68 @@ class NotificationCoordinator:
# notification, flowing through _send_to like any other channel.
self._fire_bus_event(type_id, context, recipients_fired)
# ── Multi-parent routing (#687) ──────────────────────────────────────
PARENT_ROUTING_MODES = ("all", "home", "round_robin")
def parent_routing_mode(self) -> str:
mode = str(self.storage.get_setting("parent_routing", "all") or "all")
return mode if mode in self.PARENT_ROUTING_MODES else "all"
def _parent_is_home(self, recipient_id: str) -> bool:
"""True when this parent's presence entity says they're here.
No entity configured means "always available" — a parent who hasn't
set one up shouldn't be silently excluded from every approval.
"""
parent = next(
(p for p in self.storage.get_parent_recipients() if p.id == recipient_id), None,
)
entity_id = (getattr(parent, "presence_entity", "") or "").strip() if parent else ""
if not entity_id:
return True
state = self.hass.states.get(entity_id)
if state is None or state.state in ("unavailable", "unknown", None, ""):
# Fail open: a broken presence sensor must not stop approvals.
return True
return str(state.state).lower() in ("home", "on", "true", "present")
def _route_parents(
self, type_id: str, cfg, only_recipients: set[str] | None,
) -> set[str]:
"""Which parent recipient ids should receive this notification."""
candidates = [
rid for rid, route in cfg.routes.items()
if rid.startswith("parent:") and route.enabled
and (only_recipients is None or rid in only_recipients)
]
if not candidates:
return set()
mode = self.parent_routing_mode()
if mode == "all":
return set(candidates)
if mode == "home":
at_home = [rid for rid in candidates if self._parent_is_home(rid)]
# Nobody home: tell everyone rather than nobody. An unseen approval
# is worse than a redundant buzz.
return set(at_home or candidates)
# round_robin — one parent per notification, rotating.
ordered = sorted(candidates)
state = self.storage.get_setting("parent_routing_state", {})
state = dict(state) if isinstance(state, dict) else {}
last = state.get(type_id)
try:
start = ordered.index(last) + 1 if last in ordered else 0
except ValueError:
start = 0
chosen = ordered[start % len(ordered)]
state[type_id] = chosen
self.storage.set_setting("parent_routing_state", state)
return {chosen}
async def send_test(self, type_id: str) -> list[str]:
"""Send a sample notification of ``type_id`` to its enabled routes.
@@ -291,6 +360,18 @@ class NotificationCoordinator:
return
data: dict[str, Any] = {"title": "TaskMate", "message": message}
# Evidence photo (#686): attach it so a parent can approve from the
# lock screen while actually looking at the tidied room. The URL is
# pre-signed by the caller, because the companion app fetches
# attachments without the user's bearer token.
photo_url = context.get("photo_url") or ""
attach_photo = bool(photo_url) and service.startswith("mobile_app")
# Build the mobile-app payload once: the action buttons and the photo
# are independent, so a completion with no entry id still gets its
# picture, and a photo-less approval still gets its buttons.
push: dict[str, Any] = {}
if meta.actionable:
entry_id = context.get("entry_id")
# Tap actions only render on the HA mobile app. Other backends
@@ -300,15 +381,23 @@ class NotificationCoordinator:
if entry_id:
# `tag` lets us dismiss this push later (clear_approval) once
# the item is reviewed — see _approval_tag.
data["data"] = {
"tag": _approval_tag(entry_id),
"actions": [
{"action": f"TASKMATE_APPROVE_{entry_id}", "title": "Approve"},
{"action": f"TASKMATE_REJECT_{entry_id}", "title": "Reject"},
]
}
push["tag"] = _approval_tag(entry_id)
push["actions"] = [
{"action": f"TASKMATE_APPROVE_{entry_id}", "title": "Approve"},
{"action": f"TASKMATE_REJECT_{entry_id}", "title": "Reject"},
]
else:
data["message"] = f"{message} {_APPROVE_IN_PANEL_HINT}"
if attach_photo:
# "image" renders inline on Android; iOS needs it under
# attachment.url. Sending both keeps one payload working on either.
push["image"] = photo_url
push["attachment"] = {"url": photo_url, "content-type": "jpeg"}
if push:
data["data"] = push
try:
await self.hass.services.async_call(domain, service, data, blocking=False)
except Exception as err: # noqa: BLE001
+4 -1
View File
@@ -1013,7 +1013,7 @@ class PointsMixin:
async def _async_notify_pending_approval(
self, child_name: str, chore_name: str, points: int,
completion_id: str | None = None,
completion_id: str | None = None, photo_url: str = "",
) -> None:
await self.notifications.fire(
"pending_chore_approval",
@@ -1023,6 +1023,9 @@ class PointsMixin:
"chore_name": chore_name,
"points": points,
"points_name": self.storage.get_points_name(),
# Evidence photo (#686). Signed here rather than in the
# notifier so the notifier stays free of photo specifics.
"photo_url": photos.sign_photo_url(self.hass, photo_url) if photo_url else "",
},
)
@@ -409,6 +409,9 @@ class RewardsMixin:
"pending_reward_claim", claim_id
)
# Timed unlock (#678): allowlisted entity on, auto-off later.
await self.async_start_unlock(reward, child)
self.hass.bus.async_fire("taskmate_reward_approved", {
"child_id": child.id, "child_name": child.name,
"reward_id": reward.id, "reward_name": reward.name,
+135 -1
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from .models import Chore, generate_id
from .models import Chore, generate_id, optional_float
from .templates import BUILT_IN_IDS, BUILT_IN_TEMPLATES, TEMPLATE_CHORE_FIELDS
if TYPE_CHECKING:
@@ -54,6 +54,11 @@ class TemplatesMixin:
visibility_entity=chore_def.get("visibility_entity", ""),
visibility_state=chore_def.get("visibility_state", "on"),
visibility_operator=chore_def.get("visibility_operator", "equals"),
weather_entity=chore_def.get("weather_entity", ""),
weather_block_conditions=list(chore_def.get("weather_block_conditions", [])),
weather_temp_min=optional_float(chore_def.get("weather_temp_min")),
weather_temp_max=optional_float(chore_def.get("weather_temp_max")),
weather_wind_max=optional_float(chore_def.get("weather_wind_max")),
task_type=chore_def.get("task_type", "standard"),
timed_rate_points=chore_def.get("timed_rate_points", 10),
timed_rate_minutes=chore_def.get("timed_rate_minutes", 5),
@@ -132,3 +137,132 @@ class TemplatesMixin:
raise ValueError(f"Cannot delete built-in template '{template_id}'")
self.storage.remove_custom_template(template_id)
await self.storage.async_save()
# ── Pack import / export (#688) ──────────────────────────────────────
PACK_FORMAT = "taskmate.template-pack"
PACK_VERSION = 1
MAX_PACK_TEMPLATES = 50
MAX_PACK_CHORES = 200
def export_templates(self, template_ids: list[str] | None = None) -> dict:
"""Build a shareable pack from custom templates.
Built-ins are excluded: they ship with TaskMate, so exporting them
would just create duplicates on the other end.
"""
wanted = set(template_ids or [])
packed = []
for tpl in self.storage.get_custom_templates():
if wanted and tpl.get("id") not in wanted:
continue
packed.append({
"name": tpl.get("name", ""),
"icon": tpl.get("icon", "mdi:clipboard-list-outline"),
"chores": [
{k: v for k, v in chore.items() if k in TEMPLATE_CHORE_FIELDS}
for chore in tpl.get("chores", [])
],
})
from homeassistant.util import dt as dt_util
return {
"format": self.PACK_FORMAT,
"version": self.PACK_VERSION,
"exported_at": dt_util.now().isoformat(),
"templates": packed,
}
def _validate_pack(self, pack: dict) -> list[dict]:
"""Validate an untrusted pack, returning clean template dicts.
A pack is arbitrary user-supplied JSON, so everything is checked and
every unknown chore field is dropped rather than trusted — a shared
pack must not be able to set runtime state or fields the panel
wouldn't let you set by hand.
"""
if not isinstance(pack, dict):
raise ValueError("That doesn't look like a TaskMate pack")
if pack.get("format") != self.PACK_FORMAT:
raise ValueError("Not a TaskMate template pack")
try:
version = int(pack.get("version", 0))
except (TypeError, ValueError) as err:
raise ValueError("Pack version is not a number") from err
if version > self.PACK_VERSION:
raise ValueError(
f"This pack needs a newer TaskMate (pack version {version}, "
f"this one understands {self.PACK_VERSION})"
)
templates = pack.get("templates")
if not isinstance(templates, list) or not templates:
raise ValueError("Pack contains no templates")
if len(templates) > self.MAX_PACK_TEMPLATES:
raise ValueError(f"Pack has too many templates (max {self.MAX_PACK_TEMPLATES})")
clean: list[dict] = []
for entry in templates:
if not isinstance(entry, dict):
raise ValueError("Pack contains a malformed template")
name = str(entry.get("name", "")).strip()
if not name:
raise ValueError("A template in the pack has no name")
raw_chores = entry.get("chores")
if not isinstance(raw_chores, list) or not raw_chores:
raise ValueError(f"Template '{name}' has no chores")
if len(raw_chores) > self.MAX_PACK_CHORES:
raise ValueError(f"Template '{name}' has too many chores")
chores = []
for raw in raw_chores:
if not isinstance(raw, dict):
raise ValueError(f"Template '{name}' has a malformed chore")
chore_name = str(raw.get("name", "")).strip()
if not chore_name:
raise ValueError(f"A chore in '{name}' has no name")
# Whitelist: unknown keys are dropped, never carried through.
cleaned = {k: v for k, v in raw.items() if k in TEMPLATE_CHORE_FIELDS}
cleaned["name"] = chore_name[:200]
chores.append(cleaned)
clean.append({
"name": name[:120],
"icon": str(entry.get("icon", "") or "mdi:clipboard-list-outline"),
"chores": chores,
})
return clean
async def async_import_pack(self, pack: dict) -> dict:
"""Import a validated pack as custom templates. Returns a summary.
Names that already exist are suffixed rather than overwritten — an
import should never silently replace something the family built.
"""
clean = self._validate_pack(pack)
existing = {t.get("name", "") for t in self.storage.get_custom_templates()}
created = []
for tpl in clean:
name = tpl["name"]
if name in existing:
suffix = 2
while f"{name} ({suffix})" in existing:
suffix += 1
name = f"{name} ({suffix})"
existing.add(name)
record = {
"id": generate_id(),
"name": name,
"icon": tpl["icon"],
"builtin": False,
"chores": tpl["chores"],
}
self.storage.add_custom_template(record)
created.append({"id": record["id"], "name": name, "chores": len(tpl["chores"])})
await self.storage.async_save()
await self.async_refresh()
return {"imported": len(created), "templates": created}
+35
View File
@@ -21,13 +21,19 @@ from .coord_badges import BadgeCoordinator
from .coord_calendar import CalendarMixin
from .coord_challenges import ChallengesMixin
from .coord_chores import ChoresMixin
from .coord_guests import GuestsMixin
from .coord_mandatory import MandatoryMixin
from .coord_notifications import NotificationCoordinator
from .coord_points import PointsMixin
from .coord_quests import QuestsMixin
from .coord_reports import ReportsMixin
from .coord_rewards import RewardsMixin
from .coord_roulette import RouletteMixin
from .coord_scheduled import ScheduledChangesMixin
from .coord_templates import TemplatesMixin
from .coord_timed import TimedMixin
from .coord_tts import ReadAloudMixin
from .coord_unlocks import UnlocksMixin
from .models import Child
from .storage import TaskMateStorage
@@ -46,6 +52,12 @@ class TaskMateCoordinator(
TimedMixin,
CalendarMixin,
TemplatesMixin,
ScheduledChangesMixin,
ReportsMixin,
RouletteMixin,
ReadAloudMixin,
GuestsMixin,
UnlocksMixin,
DataUpdateCoordinator,
):
"""Coordinator to manage TaskMate data."""
@@ -66,6 +78,12 @@ class TaskMateCoordinator(
self._unsub_prune: Callable[[], None] | None = None
self._unsub_availability: Callable[[], None] | None = None
self._tracked_availability_entities: set[str] = set()
self._tracked_visibility_entities: set[str] = set()
# Bumped whenever a tracked external entity (child availability, chore
# visibility, chore weather) changes state. The data snapshot is reused
# while storage.data_version is unchanged, so sensors that derive
# attributes from live entity state need this to invalidate their cache.
self.external_state_version: int = 0
self._unsub_surprise: Callable[[], None] | None = None
self._unsub_weekly: Callable[[], None] | None = None
self._unsub_mandatory: list[Callable[[], None]] = []
@@ -74,6 +92,8 @@ class TaskMateCoordinator(
self._avail_cache: dict | None = None
# (data_version, snapshot) cache for _async_update_data (PERF-2).
self._data_snapshot_cache: tuple[int, dict[str, Any]] | None = None
# Scheduled reverts for timed unlock rewards (#678).
self._unlock_timers: list = []
def difficulty_multiplier(self, tier: str) -> float:
"""Return the points multiplier for a difficulty tier.
@@ -265,6 +285,11 @@ class TaskMateCoordinator(
await self.storage.async_save()
await self._async_backfill_career_history()
await self._async_stop_stale_timed_sessions()
# Catch up on scheduled changes (#675) that came due while HA was off —
# the parent still expects "from 1 September" to have happened.
await self.async_apply_due_scheduled_changes(refresh=False)
# A restart mid-unlock must never strand the TV on (#678).
await self.async_resume_unlocks()
await self.async_refresh()
# Schedule midnight streak check at 00:00:05
self._unsub_midnight = async_track_time_change(
@@ -607,6 +632,7 @@ class TaskMateCoordinator(
async def async_shutdown(self) -> None:
"""Shutdown the coordinator and clean up listeners."""
self.cancel_unlock_timers()
if self._unsub_midnight:
self._unsub_midnight()
self._unsub_midnight = None
@@ -640,9 +666,13 @@ class TaskMateCoordinator(
saves. One step failing must not stop the rest.
"""
steps = [
self.async_apply_due_scheduled_changes,
self.async_prune_roulette_state,
self.async_archive_expired_guests,
self._async_check_streaks,
self._async_expire_one_shot_chores,
self._async_expire_dated_chores,
self._async_expire_deadline_chores,
self._async_restock_rewards,
self._async_expire_rewards,
self._async_decay_points,
@@ -698,6 +728,11 @@ class TaskMateCoordinator(
# May stop sessions (mutates + saves -> bumps the version), so run first.
await self._async_auto_stop_capped_sessions()
await self._async_check_family_goal()
# Reactive-chore deadlines are minutes long, so they can't wait for
# midnight maintenance like the date-based expiries do. refresh=False:
# we are already inside the refresh, and the snapshot below picks the
# change up in this same tick.
await self._async_expire_deadline_chores(refresh=False)
self._refresh_tracked_availability_entities()
version = self.storage.data_version
cached = getattr(self, "_data_snapshot_cache", None)
+1
View File
@@ -28,6 +28,7 @@ CARDS: Final = [
"taskmate-design.js",
"taskmate-badges-card.js",
"taskmate-child-card.js",
"taskmate-routine-card.js",
"taskmate-rewards-card.js",
"taskmate-approvals-card.js",
"taskmate-points-card.js",
+1 -1
View File
@@ -17,5 +17,5 @@
"iot_class": "calculated",
"issue_tracker": "https://github.com/tempus2016/taskmate/issues",
"requirements": [],
"version": "4.5.1"
"version": "5.0.1"
}
+139
View File
@@ -68,6 +68,21 @@ def format_datetime(dt: datetime | None) -> str | None:
return utc_dt.isoformat().replace("+00:00", "Z")
def optional_float(value: Any) -> float | None:
"""Coerce a stored/user value to a float, or None when it isn't set.
Used for limits where 0 is a meaningful value (a 0 °C threshold is real),
so the usual "0 means off" sentinel can't be used. Empty strings and
unparseable values both read as "no limit".
"""
if value is None or value == "":
return None
try:
return float(value)
except (TypeError, ValueError):
return None
@dataclass
class TimedSession:
"""Tracks an active or paused timed-task session."""
@@ -159,6 +174,10 @@ class Child:
quiet_hours_start: str = "" # "HH:MM" — start of do-not-disturb window; empty = no quiet hours
quiet_hours_end: str = "" # "HH:MM" — end of do-not-disturb window; start>end means overnight
level: int = 1 # cached XP level (derived from total_points_earned)
# Guest profiles (#690): a visiting cousin gets a temporary child that
# expires on its own and stays out of the family leaderboard.
is_guest: bool = False
guest_expires_on: str = "" # ISO date; profile auto-archives the day after
id: str = field(default_factory=generate_id)
@classmethod
@@ -178,6 +197,8 @@ class Child:
streak_paused=data.get("streak_paused", False),
streak_milestones_achieved=list(data.get("streak_milestones_achieved", [])),
awarded_perfect_weeks=list(data.get("awarded_perfect_weeks", [])),
is_guest=bool(data.get("is_guest", False)),
guest_expires_on=str(data.get("guest_expires_on", "") or ""),
availability_entity=data.get("availability_entity", ""),
availability_inverted=data.get("availability_inverted", False),
unavailability_entity=data.get("unavailability_entity", ""),
@@ -208,6 +229,8 @@ class Child:
"streak_paused": self.streak_paused,
"streak_milestones_achieved": self.streak_milestones_achieved,
"awarded_perfect_weeks": self.awarded_perfect_weeks,
"is_guest": self.is_guest,
"guest_expires_on": self.guest_expires_on,
"availability_entity": self.availability_entity,
"availability_inverted": self.availability_inverted,
"unavailability_entity": self.unavailability_entity,
@@ -236,6 +259,9 @@ class Chore:
claim_allowance_minutes: int = 0 # Grace minutes past period end during which the chore stays claimable; 0 = no grace. Night chores still cap at midnight.
daily_limit: int = 1
completion_sound: str = "coin" # Sound to play on completion
# Optional picture for the chore. Text-free pre-reader mode (#683) needs
# one per chore; everything else falls back to the time-of-day icon.
icon: str = ""
difficulty: str = "medium" # easy | medium | hard — scales awarded points by the tier multiplier (medium = ×1.0 baseline)
# Scheduling
# schedule_mode: "specific_days" = show on selected days of week (Mode A)
@@ -251,12 +277,28 @@ class Chore:
visibility_entity: str = "" # optional: entity_id to check for visibility
visibility_state: str = "on" # state/value that makes chore visible (e.g. "on", "true", "123")
visibility_operator: str = "equals" # equals, gte, lte, gt, lt, not_equals
# Weather-aware chores (#673): hide an outdoor chore while the chosen
# weather.* entity reports unsuitable conditions. Every limit is optional
# and evaluation is fail-open — a missing or unavailable entity never hides
# a chore. Temperature/wind limits are read from the entity's attributes in
# whatever unit HA reports them (native_temperature/wind_speed_unit).
weather_entity: str = ""
weather_block_conditions: list[str] = field(default_factory=list) # e.g. ["rainy", "pouring"]
weather_temp_min: float | None = None # block below this temperature
weather_temp_max: float | None = None # block above this temperature
weather_wind_max: float | None = None # block above this wind speed
# One-shot chore fields
enabled: bool = True # False = soft-disabled (completed or expired)
disabled_for: list[str] = field(default_factory=list) # Child IDs this chore is disabled for
depends_on: list[str] = field(default_factory=list) # Chore IDs that must be approved-completed today before this is available
created_date: str = "" # ISO date for one-shot expiry, e.g. "2026-04-16"
expires_on: str = "" # optional ISO end date; chore auto-disables the day after
# Reactive chores (#674): a short-lived chore raised by an automation, e.g.
# "the washing machine finished — empty it within 30 minutes". deadline_at
# is an ISO datetime after which the chore is unavailable and gets
# soft-disabled; beat it and speed_bonus_points is added to the award.
deadline_at: str = ""
speed_bonus_points: int = 0
# Time-of-day incentive: when due_time (HH:MM) is set, a completion at/before
# it earns +early_bonus, after it loses late_penalty (applied to the award).
due_time: str = ""
@@ -311,6 +353,7 @@ class Chore:
claim_allowance_minutes=max(0, int(data.get("claim_allowance_minutes", 0) or 0)),
daily_limit=data.get("daily_limit", 1),
completion_sound=data.get("completion_sound", "coin"),
icon=str(data.get("icon", "") or ""),
difficulty=data.get("difficulty", "medium"),
schedule_mode=schedule_mode,
due_days=list(data.get("due_days", [])),
@@ -321,6 +364,13 @@ class Chore:
visibility_entity=data.get("visibility_entity", ""),
visibility_state=data.get("visibility_state", "on"),
visibility_operator=data.get("visibility_operator", "equals"),
weather_entity=data.get("weather_entity", ""),
weather_block_conditions=list(data.get("weather_block_conditions", [])),
weather_temp_min=optional_float(data.get("weather_temp_min")),
weather_temp_max=optional_float(data.get("weather_temp_max")),
weather_wind_max=optional_float(data.get("weather_wind_max")),
deadline_at=data.get("deadline_at", ""),
speed_bonus_points=int(data.get("speed_bonus_points", 0) or 0),
enabled=data.get("enabled", True),
disabled_for=list(data.get("disabled_for", [])),
depends_on=list(data.get("depends_on", []) or []),
@@ -366,6 +416,7 @@ class Chore:
"claim_allowance_minutes": self.claim_allowance_minutes,
"daily_limit": self.daily_limit,
"completion_sound": self.completion_sound,
"icon": self.icon,
"difficulty": self.difficulty,
"schedule_mode": self.schedule_mode,
"due_days": self.due_days,
@@ -376,6 +427,13 @@ class Chore:
"visibility_entity": self.visibility_entity,
"visibility_state": self.visibility_state,
"visibility_operator": self.visibility_operator,
"weather_entity": self.weather_entity,
"weather_block_conditions": self.weather_block_conditions,
"weather_temp_min": self.weather_temp_min,
"weather_temp_max": self.weather_temp_max,
"weather_wind_max": self.weather_wind_max,
"deadline_at": self.deadline_at,
"speed_bonus_points": self.speed_bonus_points,
"enabled": self.enabled,
"disabled_for": self.disabled_for,
"depends_on": self.depends_on,
@@ -422,6 +480,11 @@ class Reward:
restock_amount: int = 0
restock_period: str = "weekly" # daily | weekly (Mon) | monthly (1st)
restock_last: str = "" # ISO date this reward was last restocked
# Timed unlock (#678): on approval, turn this entity on and turn it back
# off after unlock_minutes. The entity must be on the parent's allowlist,
# checked both at save time and again when it fires.
unlock_entity: str = ""
unlock_minutes: int = 0
id: str = field(default_factory=generate_id)
def __post_init__(self) -> None:
@@ -451,6 +514,8 @@ class Reward:
expires_at=expires_at,
restock_enabled=data.get("restock_enabled", False),
restock_amount=int(data.get("restock_amount", 0) or 0),
unlock_entity=str(data.get("unlock_entity", "") or ""),
unlock_minutes=int(data.get("unlock_minutes", 0) or 0),
restock_period=data.get("restock_period", "weekly"),
restock_last=data.get("restock_last", ""),
id=data.get("id", generate_id()),
@@ -472,6 +537,8 @@ class Reward:
"restock_amount": self.restock_amount,
"restock_period": self.restock_period,
"restock_last": self.restock_last,
"unlock_entity": self.unlock_entity,
"unlock_minutes": self.unlock_minutes,
"id": self.id,
}
@@ -999,6 +1066,73 @@ class TaskGroup:
}
@dataclass
class ScheduledChange:
"""A chore edit queued to take effect on a future date (#675).
"From 1 September this chore is worth 20 points" / "from Monday it's
Ella's". ``changes`` maps chore field -> new value; only the fields in
``SCHEDULED_CHANGE_FIELDS`` may be set, so a queued change can't rewrite
runtime state like rotation anchors or publish history.
Applied changes are kept (not deleted) so the panel can show what happened
and when — a silent config change is worse than no config change.
"""
chore_id: str
apply_on: str # ISO date, e.g. "2026-09-01"
changes: dict[str, Any] = field(default_factory=dict)
note: str = ""
applied: bool = False
applied_at: str = ""
created_at: str = field(default_factory=dt_util_now_iso)
id: str = field(default_factory=generate_id)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "ScheduledChange":
return cls(
chore_id=data.get("chore_id", ""),
apply_on=data.get("apply_on", ""),
changes=dict(data.get("changes", {}) or {}),
note=data.get("note", ""),
applied=bool(data.get("applied", False)),
applied_at=data.get("applied_at", ""),
created_at=data.get("created_at", "") or dt_util_now_iso(),
id=data.get("id", generate_id()),
)
def to_dict(self) -> dict[str, Any]:
return {
"chore_id": self.chore_id,
"apply_on": self.apply_on,
"changes": self.changes,
"note": self.note,
"applied": self.applied,
"applied_at": self.applied_at,
"created_at": self.created_at,
"id": self.id,
}
# Chore fields a scheduled change is allowed to set. Deliberately excludes
# runtime state (assignment_current_child_id, skip_date, publish history) and
# anything that would need extra coordination to change safely.
SCHEDULED_CHANGE_FIELDS: dict[str, type | tuple[type, ...]] = {
"points": int,
"assigned_to": list,
"enabled": bool,
"time_category": str,
"difficulty": str,
"requires_approval": bool,
"daily_limit": int,
"due_days": list,
"mandatory": bool,
"mandatory_penalty_points": int,
"description": str,
"expires_on": str,
}
@dataclass
class ParentRecipient:
"""A configured parent notification target."""
@@ -1006,6 +1140,9 @@ class ParentRecipient:
name: str
notify_service: str
enabled: bool = True
# Presence entity for "whoever is home" routing (#687). Empty means this
# parent is always considered available.
presence_entity: str = ""
id: str = field(default_factory=lambda: f"parent:{generate_id()}")
@classmethod
@@ -1015,6 +1152,7 @@ class ParentRecipient:
name=data.get("name", ""),
notify_service=data.get("notify_service", ""),
enabled=bool(data.get("enabled", True)),
presence_entity=str(data.get("presence_entity", "") or ""),
id=rid,
)
@@ -1022,6 +1160,7 @@ class ParentRecipient:
return {
"id": self.id,
"name": self.name,
"presence_entity": self.presence_entity,
"notify_service": self.notify_service,
"enabled": self.enabled,
}
+7 -2
View File
@@ -123,9 +123,14 @@ def sign_photo_url(hass, photo_url: str, expiration_hours: int = 24) -> str:
return photo_url
from datetime import timedelta
from homeassistant.components.http.auth import async_sign_path
try:
# Import inside the try: this is called on the completion path now
# (#686 signs the evidence photo for the approval push), and an
# ImportError escaping here would fail the completion itself — which
# is exactly what "never break delivery over a signing hiccup" is
# meant to prevent.
from homeassistant.components.http.auth import async_sign_path
return async_sign_path(hass, photo_url, timedelta(hours=expiration_hours))
except Exception: # noqa: BLE001 - never break state delivery over a signing hiccup
_LOGGER.debug("Could not sign photo URL %s", photo_url, exc_info=True)
+1 -1
View File
@@ -14,7 +14,7 @@ from .coordinator import TaskMateCoordinator
# (setting_key, translation_key, options, default, icon)
_SELECTS = [
("streak_reset_mode", "streak_reset_mode", ["reset", "pause"], "reset", "mdi:restart"),
("card_design", "card_design", ["classic", "playroom", "console", "cleanpro"], "classic", "mdi:palette"),
("card_design", "card_design", ["classic", "playroom", "console", "cleanpro", "accessible"], "classic", "mdi:palette"),
]
+44 -4
View File
@@ -159,6 +159,20 @@ def _build_children_summary(coordinator: TaskMateCoordinator, common: dict) -> l
"name": c.name,
"points": c.points,
"pending_points": pending.get(c.id, 0),
# Guest profiles (#690): cards filter these out of competitive views.
**({"is_guest": True, "guest_expires_on": getattr(c, "guest_expires_on", "")}
if getattr(c, "is_guest", False) else {}),
# Chore roulette (#677): today's pick + spins left, so the card can
# show the result and disable the button once the allowance is used.
**(
{
"roulette": {
**(coordinator.roulette_selection(c.id) or {}),
"spins_left": coordinator.roulette_spins_left(c.id),
}
}
if coordinator.roulette_enabled() else {}
),
"committed_points": committed_amount,
"allocated_points": allocated.get(c.id, 0),
# Allocations were deducted from child.points already, so spendable
@@ -260,6 +274,25 @@ def _build_chores_list(coordinator: TaskMateCoordinator, common: dict) -> list[d
record["visibility_entity"] = visibility_entity
record["visibility_operator"] = getattr(c, 'visibility_operator', 'equals')
record["visibility_state"] = getattr(c, 'visibility_state', 'on')
weather_entity = getattr(c, 'weather_entity', '')
if weather_entity:
record["weather_entity"] = weather_entity
record["weather_block_conditions"] = list(getattr(c, 'weather_block_conditions', []) or [])
for limit in ("weather_temp_min", "weather_temp_max", "weather_wind_max"):
value = getattr(c, limit, None)
if value is not None:
record[limit] = value
# Why it's hidden, so the panel can say "rained off" instead of
# just dropping the chore out of the list.
reason = coordinator.weather_block_reason(c)
if reason:
record["weather_blocked"] = reason
deadline_at = getattr(c, 'deadline_at', '')
if deadline_at:
record["deadline_at"] = deadline_at
speed_bonus = getattr(c, 'speed_bonus_points', 0)
if speed_bonus:
record["speed_bonus_points"] = speed_bonus
disabled_for = getattr(c, 'disabled_for', [])
if disabled_for:
record["disabled_for"] = disabled_for
@@ -269,6 +302,9 @@ def _build_chores_list(coordinator: TaskMateCoordinator, common: dict) -> list[d
assignment_current_child_id = getattr(c, 'assignment_current_child_id', '')
if assignment_current_child_id:
record["assignment_current_child_id"] = assignment_current_child_id
icon = getattr(c, 'icon', '')
if icon:
record["icon"] = icon
completion_sound = getattr(c, 'completion_sound', 'coin')
if completion_sound and completion_sound != 'coin':
record["completion_sound"] = completion_sound
@@ -691,16 +727,20 @@ class _CachedAttrsSensor(TaskMateBaseSensor):
def __init__(self, coordinator: TaskMateCoordinator, entry: ConfigEntry) -> None:
super().__init__(coordinator, entry)
self._cached_attrs: dict | None = None
self._cached_data_id: int | None = None
self._cached_key: tuple[int, int] | None = None
@property
def extra_state_attributes(self) -> dict:
data_id = id(self.coordinator.data)
if self._cached_data_id == data_id and self._cached_attrs is not None:
# The coordinator reuses one snapshot object while storage.data_version
# is unchanged, so id(data) alone would pin these attributes forever
# when only an external entity moved — stranding the visibility and
# weather gates on stale state. external_state_version covers that.
key = (id(self.coordinator.data), getattr(self.coordinator, "external_state_version", 0))
if self._cached_key == key and self._cached_attrs is not None:
return self._cached_attrs
attrs = self._build_attributes()
self._cached_attrs = attrs
self._cached_data_id = data_id
self._cached_key = key
return attrs
def _build_attributes(self) -> dict: # pragma: no cover - abstract
+69
View File
@@ -677,6 +677,75 @@ add_chore:
default: true
selector:
boolean:
expires_in_minutes:
name: Expires in (minutes)
description: >-
Make this a reactive chore with a deadline this many minutes from now — e.g. the washing
machine finished, empty it within 30 minutes. The chore disappears and is disabled once the
deadline passes. 0 = no deadline.
required: false
default: 0
selector:
number:
min: 0
max: 10080
mode: box
unit_of_measurement: min
speed_bonus_points:
name: Speed bonus points
description: Extra points awarded if the chore is completed before the deadline. Needs expires_in_minutes.
required: false
default: 0
selector:
number:
min: 0
max: 10000
mode: box
read_aloud:
name: Read Chores Aloud
description: >-
Speak a child's outstanding chores to a media player — "Ella, you have three things left...".
Wording is set by the read-aloud templates in the TaskMate panel settings.
fields:
child_id:
name: Child ID
required: true
selector:
text:
media_player:
name: Media player
description: Where to speak. Falls back to the default set in Settings.
required: false
selector:
entity:
domain: media_player
tts_entity:
name: Text-to-speech entity
description: Which TTS service to use. Falls back to Settings, then to the only one installed.
required: false
selector:
entity:
domain: tts
message:
name: Message override
description: Say this instead of the generated summary.
required: false
selector:
text:
spin_roulette:
name: Spin Chore Roulette
description: >-
Pick a random outstanding chore for a child at a points multiplier. Opt-in — enable it and set
the multiplier and daily spin allowance in the TaskMate panel settings.
fields:
child_id:
name: Child ID
description: The ID of the child spinning
required: true
selector:
text:
skip_chore:
name: Skip Chore
+38
View File
@@ -28,6 +28,7 @@ from .models import (
Quest,
Reward,
RewardClaim,
ScheduledChange,
TaskGroup,
TimedSession,
)
@@ -115,6 +116,10 @@ class TaskMateStorage:
if "chore_display_order" not in self._data:
self._data["chore_display_order"] = []
# Ensure scheduled_changes store exists (#675)
if "scheduled_changes" not in self._data:
self._data["scheduled_changes"] = []
# Notifications overhaul (v3.9.0)
if "parent_recipients" not in self._data:
self._data["parent_recipients"] = []
@@ -847,6 +852,39 @@ class TaskMateStorage:
if chore_id in g.get("chore_ids", []):
g["chore_ids"] = [c for c in g["chore_ids"] if c != chore_id]
# Scheduled config changes (#675)
def get_scheduled_changes(self) -> list[ScheduledChange]:
"""All scheduled changes, pending and already applied."""
return [ScheduledChange.from_dict(c) for c in self._data.get("scheduled_changes", [])]
def get_scheduled_change(self, change_id: str) -> ScheduledChange | None:
for c in self._data.get("scheduled_changes", []):
if c.get("id") == change_id:
return ScheduledChange.from_dict(c)
return None
def add_scheduled_change(self, change: ScheduledChange) -> None:
self._data.setdefault("scheduled_changes", []).append(change.to_dict())
def update_scheduled_change(self, change: ScheduledChange) -> None:
changes = self._data.setdefault("scheduled_changes", [])
for i, c in enumerate(changes):
if c.get("id") == change.id:
changes[i] = change.to_dict()
return
changes.append(change.to_dict())
def remove_scheduled_change(self, change_id: str) -> None:
self._data["scheduled_changes"] = [
c for c in self._data.get("scheduled_changes", []) if c.get("id") != change_id
]
def remove_scheduled_changes_for_chore(self, chore_id: str) -> None:
"""Drop a deleted chore's queued changes so they can't fire on nothing."""
self._data["scheduled_changes"] = [
c for c in self._data.get("scheduled_changes", []) if c.get("chore_id") != chore_id
]
# Points transactions management
def get_points_transactions(self) -> list[PointsTransaction]:
"""Get all points transactions."""
+3 -1
View File
@@ -6,7 +6,9 @@ TEMPLATE_CHORE_FIELDS = (
"daily_limit", "completion_sound", "schedule_mode", "due_days",
"recurrence", "recurrence_day", "recurrence_start", "first_occurrence_mode",
"assignment_mode", "require_availability", "visibility_entity",
"visibility_state", "visibility_operator", "task_type",
"visibility_state", "visibility_operator",
"weather_entity", "weather_block_conditions", "weather_temp_min",
"weather_temp_max", "weather_wind_max", "task_type",
"timed_rate_points", "timed_rate_minutes", "timed_max_daily_minutes",
)
+246 -6
View File
@@ -77,6 +77,14 @@ WS_ADD_CHORE: Final = "taskmate/add_chore"
WS_UPDATE_CHORE: Final = "taskmate/update_chore"
WS_REMOVE_CHORE: Final = "taskmate/remove_chore"
WS_REPORT_FAIRNESS: Final = "taskmate/reports/fairness"
WS_REPORT_FRICTION: Final = "taskmate/reports/friction"
WS_REPORT_PROJECTION: Final = "taskmate/reports/projection"
WS_REPORT_HEALTH: Final = "taskmate/reports/health"
WS_SCHEDULED_LIST: Final = "taskmate/scheduled/list"
WS_SCHEDULED_ADD: Final = "taskmate/scheduled/add"
WS_SCHEDULED_REMOVE: Final = "taskmate/scheduled/remove"
WS_ADD_REWARD: Final = "taskmate/add_reward"
WS_UPDATE_REWARD: Final = "taskmate/update_reward"
WS_REMOVE_REWARD: Final = "taskmate/remove_reward"
@@ -127,6 +135,9 @@ WS_TEMPLATES_APPLY: Final = "taskmate/templates/apply"
WS_TEMPLATES_SAVE_FROM: Final = "taskmate/templates/save_from_chores"
WS_TEMPLATES_CREATE: Final = "taskmate/templates/create"
WS_TEMPLATES_UPDATE: Final = "taskmate/templates/update"
WS_TEMPLATES_EXPORT: Final = "taskmate/templates/export"
WS_TEMPLATES_IMPORT: Final = "taskmate/templates/import"
WS_PRINT_CHART: Final = "taskmate/print/weekly_chart"
WS_TEMPLATES_DELETE: Final = "taskmate/templates/delete"
# Notifications
@@ -177,8 +188,9 @@ WS_CONFIG_IMPORT: Final = "taskmate/config/import"
# Everything else routed through @_admin_only mutates state and is logged.
_AUDIT_EXCLUDE: Final = {
WS_GET_STATE, WS_NOTIF_GET_STATE, WS_NOTIF_LIST_NOTIFY,
WS_TEMPLATES_LIST, WS_TEMPLATES_GET, WS_AUDIT_LIST, WS_AUDIT_CLEAR,
WS_CONFIG_EXPORT,
WS_TEMPLATES_LIST, WS_TEMPLATES_GET, WS_TEMPLATES_EXPORT, WS_PRINT_CHART,
WS_AUDIT_LIST, WS_AUDIT_CLEAR,
WS_CONFIG_EXPORT, WS_SCHEDULED_LIST, WS_REPORT_FAIRNESS, WS_REPORT_FRICTION, WS_REPORT_PROJECTION, WS_REPORT_HEALTH,
}
@@ -294,6 +306,7 @@ def _build_state_snapshot(coordinator: TaskMateCoordinator) -> dict[str, Any]:
"children": list(data.get("children", [])),
"chores": list(data.get("chores", [])),
"chore_display_order": list(data.get("chore_display_order", [])),
"scheduled_changes": list(data.get("scheduled_changes", [])),
"rewards": list(data.get("rewards", [])),
"penalties": list(data.get("penalties", [])),
"bonuses": list(data.get("bonuses", [])),
@@ -377,6 +390,8 @@ async def _ws_add_child(hass, connection, msg, coordinator):
vol.Optional("unavailability_entity"): str,
vol.Optional("pause_streak_when_unavailable"): bool,
vol.Optional("linked_user_id"): str,
vol.Optional("is_guest"): bool,
vol.Optional("guest_expires_on"): str,
})
@websocket_api.async_response
@_admin_only
@@ -399,6 +414,19 @@ async def _ws_update_child(hass, connection, msg, coordinator):
existing.pause_streak_when_unavailable = bool(msg["pause_streak_when_unavailable"])
if "linked_user_id" in msg:
existing.linked_user_id = _opt_str(msg["linked_user_id"])
if "is_guest" in msg or "guest_expires_on" in msg:
# Routed through the coordinator so the expiry is validated and an
# archived guest is un-archived when promoted to a family member.
try:
await coordinator.async_set_guest(
existing.id,
bool(msg.get("is_guest", existing.is_guest)),
_opt_str(msg.get("guest_expires_on", existing.guest_expires_on)),
)
except ValueError as err:
connection.send_error(msg["id"], "invalid_format", str(err))
return
existing = coordinator.storage.get_child(msg["child_id"])
await coordinator.async_update_child(existing)
connection.send_result(msg["id"], {"id": existing.id})
@@ -448,10 +476,14 @@ async def _ws_list_ha_users(hass, connection, msg, coordinator):
_CHORE_EDITABLE_FIELDS = {
"name", "description", "points", "assigned_to", "depends_on", "requires_approval",
"time_category", "claim_allowance_minutes", "daily_limit", "completion_sound",
"difficulty",
"icon", "difficulty",
"schedule_mode", "due_days", "recurrence", "recurrence_day",
"recurrence_start", "first_occurrence_mode", "visibility_entity",
"visibility_state", "visibility_operator", "enabled", "expires_on",
"visibility_state", "visibility_operator",
"weather_entity", "weather_block_conditions", "weather_temp_min",
"weather_temp_max", "weather_wind_max",
"deadline_at", "speed_bonus_points",
"enabled", "expires_on",
"due_time", "early_bonus", "late_penalty", "require_photo",
"mandatory", "mandatory_penalty_points",
"assignment_mode", "assignment_rotation_anchor", "require_availability",
@@ -474,6 +506,7 @@ def _chore_payload_schema(*, require_name: bool):
vol.Optional("claim_allowance_minutes"): vol.All(int, vol.Range(min=0)),
vol.Optional("daily_limit"): vol.All(int, vol.Range(min=1)),
vol.Optional("completion_sound"): str,
vol.Optional("icon"): str,
vol.Optional("difficulty"): vol.In(["easy", "medium", "hard"]),
vol.Optional("schedule_mode"): vol.In(["specific_days", "recurring", "one_shot"]),
vol.Optional("due_days"): [str],
@@ -484,6 +517,14 @@ def _chore_payload_schema(*, require_name: bool):
vol.Optional("visibility_entity"): str,
vol.Optional("visibility_state"): str,
vol.Optional("visibility_operator"): str,
vol.Optional("weather_entity"): str,
vol.Optional("weather_block_conditions"): [str],
# None clears the limit — 0 is a real threshold, so it can't double as "off".
vol.Optional("weather_temp_min"): vol.Any(None, vol.Coerce(float)),
vol.Optional("weather_temp_max"): vol.Any(None, vol.Coerce(float)),
vol.Optional("weather_wind_max"): vol.Any(None, vol.All(vol.Coerce(float), vol.Range(min=0))),
vol.Optional("deadline_at"): str,
vol.Optional("speed_bonus_points"): vol.All(int, vol.Range(min=0)),
vol.Optional("enabled"): bool,
vol.Optional("expires_on"): str,
vol.Optional("due_time"): str,
@@ -602,13 +643,168 @@ async def _ws_remove_chore(hass, connection, msg, coordinator):
connection.send_result(msg["id"], {"id": msg["chore_id"]})
# ---------------------------------------------------------------------------
# Insight reports (#679)
# ---------------------------------------------------------------------------
@websocket_api.websocket_command({
vol.Required("type"): WS_REPORT_FAIRNESS,
vol.Optional("days"): vol.All(int, vol.Range(min=1, max=90)),
})
@websocket_api.async_response
@_admin_only
async def _ws_report_fairness(hass, connection, msg, coordinator):
connection.send_result(msg["id"], coordinator.fairness_report(msg.get("days")))
@websocket_api.websocket_command({
vol.Required("type"): WS_REPORT_FRICTION,
vol.Optional("days"): vol.All(int, vol.Range(min=1, max=90)),
})
@websocket_api.async_response
@_admin_only
async def _ws_report_friction(hass, connection, msg, coordinator):
connection.send_result(msg["id"], coordinator.friction_report(msg.get("days")))
@websocket_api.websocket_command({
vol.Required("type"): WS_REPORT_PROJECTION,
vol.Optional("days"): vol.All(int, vol.Range(min=1, max=28)),
})
@websocket_api.async_response
@_admin_only
async def _ws_report_projection(hass, connection, msg, coordinator):
connection.send_result(msg["id"], coordinator.projection_report(msg.get("days")))
@websocket_api.websocket_command({
vol.Required("type"): WS_TEMPLATES_EXPORT,
vol.Optional("template_ids"): [str],
})
@websocket_api.async_response
@_admin_only
async def _ws_templates_export(hass, connection, msg, coordinator):
connection.send_result(msg["id"], coordinator.export_templates(msg.get("template_ids")))
@websocket_api.websocket_command({
vol.Required("type"): WS_TEMPLATES_IMPORT,
vol.Required("pack"): dict,
})
@websocket_api.async_response
@_admin_only
async def _ws_templates_import(hass, connection, msg, coordinator):
try:
result = await coordinator.async_import_pack(msg["pack"])
except ValueError as err:
connection.send_error(msg["id"], "invalid_format", str(err))
return
connection.send_result(msg["id"], result)
@websocket_api.websocket_command({
vol.Required("type"): WS_PRINT_CHART,
vol.Optional("orientation", default="portrait"): vol.In(["portrait", "landscape"]),
vol.Optional("week_start"): str,
vol.Optional("title"): vol.All(str, vol.Length(max=80)),
})
@websocket_api.async_response
@_admin_only
async def _ws_print_chart(hass, connection, msg, coordinator):
from datetime import date as _date
from homeassistant.util import dt as dt_util
from . import printable
raw = msg.get("week_start", "")
try:
anchor = _date.fromisoformat(raw) if raw else dt_util.as_local(dt_util.now()).date()
except (TypeError, ValueError):
connection.send_error(msg["id"], "invalid_format", "week_start must be an ISO date")
return
data = coordinator.storage.data
html = printable.build_chart(
children=list(data.get("children", [])),
chores=list(data.get("chores", [])),
start=printable.week_start(anchor),
orientation=msg.get("orientation", "portrait"),
title=msg.get("title") or "This week",
points_name=coordinator.storage.get_points_name(),
)
connection.send_result(msg["id"], {"html": html})
@websocket_api.websocket_command({vol.Required("type"): WS_REPORT_HEALTH})
@websocket_api.async_response
@_admin_only
async def _ws_report_health(hass, connection, msg, coordinator):
connection.send_result(msg["id"], coordinator.health_report())
# ---------------------------------------------------------------------------
# Scheduled config changes (#675)
# ---------------------------------------------------------------------------
@websocket_api.websocket_command({
vol.Required("type"): WS_SCHEDULED_LIST,
vol.Optional("chore_id"): str,
})
@websocket_api.async_response
@_admin_only
async def _ws_scheduled_list(hass, connection, msg, coordinator):
changes = coordinator.get_scheduled_changes(msg.get("chore_id", ""))
connection.send_result(msg["id"], {"changes": [c.to_dict() for c in changes]})
@websocket_api.websocket_command({
vol.Required("type"): WS_SCHEDULED_ADD,
vol.Required("chore_id"): str,
vol.Required("apply_on"): str,
vol.Required("changes"): dict,
vol.Optional("note", default=""): vol.All(str, vol.Length(max=200)),
})
@websocket_api.async_response
@_admin_only
async def _ws_scheduled_add(hass, connection, msg, coordinator):
try:
change = await coordinator.async_add_scheduled_change(
chore_id=msg["chore_id"],
apply_on=msg["apply_on"],
changes=msg["changes"],
note=msg.get("note", ""),
)
except ValueError as err:
connection.send_error(msg["id"], "invalid_format", str(err))
return
connection.send_result(msg["id"], {"id": change.id})
@websocket_api.websocket_command({
vol.Required("type"): WS_SCHEDULED_REMOVE,
vol.Required("change_id"): str,
})
@websocket_api.async_response
@_admin_only
async def _ws_scheduled_remove(hass, connection, msg, coordinator):
try:
await coordinator.async_remove_scheduled_change(msg["change_id"])
except ValueError as err:
connection.send_error(msg["id"], "not_found", str(err))
return
connection.send_result(msg["id"], {"success": True})
# ---------------------------------------------------------------------------
# Rewards
# ---------------------------------------------------------------------------
_REWARD_FIELDS = {"name", "cost", "description", "icon", "assigned_to",
"is_jackpot", "pool_enabled", "quantity", "expires_at",
"restock_enabled", "restock_amount", "restock_period"}
"restock_enabled", "restock_amount", "restock_period",
"unlock_entity", "unlock_minutes"}
def _reward_payload_schema(*, require_name: bool):
@@ -627,6 +823,8 @@ def _reward_payload_schema(*, require_name: bool):
vol.Optional("restock_enabled"): bool,
vol.Optional("restock_amount"): vol.All(int, vol.Range(min=0, max=10000)),
vol.Optional("restock_period"): vol.In(["daily", "weekly", "monthly"]),
vol.Optional("unlock_entity"): str,
vol.Optional("unlock_minutes"): vol.All(int, vol.Range(min=0, max=1440)),
}
@@ -637,6 +835,16 @@ def _reward_payload_schema(*, require_name: bool):
@websocket_api.async_response
@_admin_only
async def _ws_add_reward(hass, connection, msg, coordinator):
# Timed unlock (#678): refuse an entity that isn't on the parent's
# allowlist, at save time, with a message the panel can show.
try:
unlock_entity, unlock_minutes = coordinator.validate_unlock(
msg.get("unlock_entity", ""), msg.get("unlock_minutes", 0),
)
except ValueError as err:
connection.send_error(msg["id"], "invalid_format", str(err))
return
# coordinator.async_add_reward accepts a subset; build a Reward dataclass
# to populate every editable field uniformly.
reward = Reward(
@@ -652,6 +860,8 @@ async def _ws_add_reward(hass, connection, msg, coordinator):
restock_enabled=msg.get("restock_enabled", False),
restock_amount=msg.get("restock_amount", 0),
restock_period=msg.get("restock_period", "weekly"),
unlock_entity=unlock_entity,
unlock_minutes=unlock_minutes,
)
coordinator.storage.add_reward(reward)
await coordinator.storage.async_save()
@@ -671,6 +881,15 @@ async def _ws_update_reward(hass, connection, msg, coordinator):
if not existing:
connection.send_error(msg["id"], "not_found", f"Reward {msg['reward_id']} not found")
return
if "unlock_entity" in msg or "unlock_minutes" in msg:
try:
coordinator.validate_unlock(
msg.get("unlock_entity", existing.unlock_entity),
msg.get("unlock_minutes", existing.unlock_minutes),
)
except ValueError as err:
connection.send_error(msg["id"], "invalid_format", str(err))
return
for field in _REWARD_FIELDS:
if field in msg:
value = msg[field]
@@ -1105,7 +1324,7 @@ async def _ws_remove_task_group(hass, connection, msg, coordinator):
# Top-level fields stored at storage._data root
_TOP_LEVEL_SETTINGS = {"points_name", "points_icon"}
# Allowed values for the global default card-design style (per-card design styles).
_ALLOWED_CARD_DESIGNS = {"classic", "playroom", "console", "cleanpro"}
_ALLOWED_CARD_DESIGNS = {"classic", "playroom", "console", "cleanpro", "accessible"}
# Settings stored under storage._data["settings"][key]
_SUBKEY_SETTINGS = {
"history_days", "streak_reset_mode", "card_design",
@@ -1113,6 +1332,10 @@ _SUBKEY_SETTINGS = {
"perfect_week_bonus", "streak_milestones",
"streak_requires_all_chores", "perfect_week_requires_all_chores",
"difficulty_multiplier_easy", "difficulty_multiplier_medium", "difficulty_multiplier_hard",
"unlock_allowlist", "parent_routing",
"read_aloud_media_player", "read_aloud_tts_entity", "read_aloud_template",
"read_aloud_one_template", "read_aloud_done_template", "read_aloud_joiner",
"roulette_enabled", "roulette_multiplier", "roulette_daily_spins",
"surprise_bonus_enabled", "surprise_bonus_chance", "surprise_bonus_min", "surprise_bonus_max",
"points_decay_enabled", "points_decay_period", "points_decay_percent",
"level_xp_step",
@@ -1272,6 +1495,17 @@ _UPDATE_SETTINGS_SCHEMA = {
vol.Optional("difficulty_multiplier_easy"): vol.All(vol.Coerce(float), vol.Range(min=0.0, max=10.0)),
vol.Optional("difficulty_multiplier_medium"): vol.All(vol.Coerce(float), vol.Range(min=0.0, max=10.0)),
vol.Optional("difficulty_multiplier_hard"): vol.All(vol.Coerce(float), vol.Range(min=0.0, max=10.0)),
vol.Optional("unlock_allowlist"): [str],
vol.Optional("parent_routing"): vol.In(["all", "home", "round_robin"]),
vol.Optional("read_aloud_media_player"): str,
vol.Optional("read_aloud_tts_entity"): str,
vol.Optional("read_aloud_template"): vol.All(str, vol.Length(max=300)),
vol.Optional("read_aloud_one_template"): vol.All(str, vol.Length(max=300)),
vol.Optional("read_aloud_done_template"): vol.All(str, vol.Length(max=300)),
vol.Optional("read_aloud_joiner"): vol.All(str, vol.Length(max=20)),
vol.Optional("roulette_enabled"): bool,
vol.Optional("roulette_multiplier"): vol.All(vol.Coerce(float), vol.Range(min=1.0, max=5.0)),
vol.Optional("roulette_daily_spins"): vol.All(int, vol.Range(min=1, max=10)),
vol.Optional("surprise_bonus_enabled"): bool,
vol.Optional("surprise_bonus_chance"): vol.All(vol.Coerce(float), vol.Range(min=0.0, max=100.0)),
vol.Optional("surprise_bonus_min"): vol.All(int, vol.Range(min=0, max=10000)),
@@ -1770,6 +2004,7 @@ async def ws_notif_set_child_quiet(hass, connection, msg, coordinator):
vol.Required("name"): str,
vol.Required("notify_service"): str,
vol.Optional("enabled", default=True): bool,
vol.Optional("presence_entity", default=""): str,
})
@websocket_api.async_response
@_admin_only
@@ -1787,6 +2022,7 @@ async def ws_notif_upsert_parent(hass, connection, msg, coordinator):
existing.name = msg["name"]
existing.notify_service = msg["notify_service"]
existing.enabled = msg["enabled"]
existing.presence_entity = msg.get("presence_entity", "")
await c.notifications.upsert_parent(existing)
connection.send_result(msg["id"], existing.to_dict())
else:
@@ -1794,6 +2030,7 @@ async def ws_notif_upsert_parent(hass, connection, msg, coordinator):
name=msg["name"],
notify_service=msg["notify_service"],
enabled=msg["enabled"],
presence_entity=msg.get("presence_entity", ""),
)
await c.notifications.upsert_parent(p)
connection.send_result(msg["id"], p.to_dict())
@@ -2060,6 +2297,9 @@ _COMMANDS = (
_ws_audit_list, _ws_audit_clear, _ws_undo_transaction,
_ws_add_child, _ws_update_child, _ws_remove_child, _ws_list_ha_users,
_ws_add_chore, _ws_update_chore, _ws_remove_chore, _ws_clone_chore,
_ws_scheduled_list, _ws_scheduled_add, _ws_scheduled_remove,
_ws_report_fairness, _ws_report_friction, _ws_report_projection, _ws_report_health,
_ws_templates_export, _ws_templates_import, _ws_print_chart,
_ws_bulk_chore_action, _ws_gift_points,
_ws_request_swap, _ws_approve_swap, _ws_reject_swap,
_ws_config_export, _ws_config_import,
+175 -1
View File
@@ -1538,5 +1538,179 @@
"panel.family_goal_reward": "Belohnung",
"panel.family_goal_reward_ph": "ein Familienfilmabend",
"notification.family_goal_reached.name": "Familienziel erreicht",
"notification.family_goal_reached.description": "Feiert, wenn die kombinierten Punkte der Familie das gemeinsame Ziel erreichen. Standardmäßig aus."
"notification.family_goal_reached.description": "Feiert, wenn die kombinierten Punkte der Familie das gemeinsame Ziel erreichen. Standardmäßig aus.",
"panel.chore_advanced_weather": "Erweitert — Wetterbedingungen",
"panel.chore_weather_intro": "Diese Aufgabe ausblenden, solange das Wetter ungeeignet ist. Praktisch für Arbeiten im Freien — eine wetterbedingt ausgefallene Aufgabe gilt nicht als versäumte Pflichtaufgabe und unterbricht keine Serie.",
"panel.chore_weather_entity_label": "Wetter-Entität",
"panel.chore_weather_entity_hint": "Leer lassen, um das Wetter komplett zu ignorieren.",
"panel.chore_weather_conditions_label": "Ausblenden bei folgendem Wetter",
"panel.chore_weather_conditions_hint": "Wähle die Bedingungen, bei denen die Aufgabe ausgeblendet wird.",
"panel.chore_weather_temp_min_label": "Mindesttemperatur",
"panel.chore_weather_temp_min_hint": "Darunter ausblenden. Leer lassen für keine Grenze.",
"panel.chore_weather_temp_max_label": "Höchsttemperatur",
"panel.chore_weather_temp_max_hint": "Darüber ausblenden. Leer lassen für keine Grenze.",
"panel.chore_weather_wind_max_label": "Maximale Windgeschwindigkeit",
"panel.chore_weather_wind_max_hint": "Darüber ausblenden. Leer lassen für keine Grenze.",
"panel.chore_weather_failopen_hint": "Die Grenzwerte verwenden die Einheiten deiner Wetter-Entität. Fehlt die Entität oder ist sie nicht verfügbar, bleibt die Aufgabe sichtbar.",
"weather.blocked_condition": "Ausgeblendet — ungeeignetes Wetter",
"weather.blocked_temp_low": "Ausgeblendet — zu kalt",
"weather.blocked_temp_high": "Ausgeblendet — zu heiß",
"weather.blocked_wind": "Ausgeblendet — zu windig",
"weather.condition_rainy": "Regnerisch",
"weather.condition_pouring": "Starkregen",
"weather.condition_snowy": "Schnee",
"weather.condition_snowy_rainy": "Schneeregen",
"weather.condition_hail": "Hagel",
"weather.condition_lightning": "Blitze",
"weather.condition_lightning_rainy": "Gewitter",
"weather.condition_fog": "Nebel",
"weather.condition_windy": "Windig",
"weather.condition_windy_variant": "Sehr windig",
"weather.condition_cloudy": "Bewölkt",
"weather.condition_exceptional": "Unwetter",
"child.deadline_minutes": "noch {mins} Min.",
"child.deadline_hours": "noch {hours} Std. {mins} Min.",
"child.deadline_title": "Erledige das, bevor die Zeit abläuft",
"panel.chore_advanced_scheduled": "Erweitert — geplante Änderungen",
"panel.chore_scheduled_intro": "Plane eine Änderung für ein späteres Datum — „ab 1. September 20 Punkte wert“. Wird um Mitternacht angewendet; war Home Assistant aus, wird es beim nächsten Start nachgeholt.",
"panel.chore_scheduled_none": "Keine Änderungen geplant.",
"panel.chore_scheduled_history": "Bereits angewendet",
"panel.chore_scheduled_applied": "Angewendet",
"panel.sched_date_label": "Anwenden am",
"panel.sched_field_label": "Änderung",
"panel.sched_value_label": "Neuer Wert",
"panel.sched_note_label": "Notiz (optional)",
"panel.sched_note_hint": "Eine Erinnerung an den Grund, neben der geplanten Änderung angezeigt.",
"panel.btn_add_scheduled": "Änderung planen",
"panel.sched_value_yes": "Ja",
"panel.sched_value_no": "Nein",
"panel.sched_value_nobody": "Niemand",
"panel.toast_sched_added": "Änderung geplant",
"panel.toast_sched_removed": "Änderung entfernt",
"panel.toast_sched_date_required": "Wähle ein Datum für die Änderung",
"panel.toast_sched_value_required": "Gib einen Wert für die Änderung ein",
"panel.sched_field_points": "Punkte",
"panel.sched_field_assigned_to": "Zugewiesen an",
"panel.sched_field_enabled": "Aktiviert",
"panel.sched_field_requires_approval": "Bestätigung nötig",
"panel.sched_field_daily_limit": "Tageslimit",
"panel.sched_field_due_days": "Tage",
"panel.sched_field_mandatory": "Pflicht",
"panel.sched_field_mandatory_penalty_points": "Strafpunkte",
"panel.sched_field_expires_on": "Läuft ab am",
"panel.sched_field_description": "Beschreibung",
"panel.sched_field_time_category": "Tageszeit",
"panel.sched_field_difficulty": "Schwierigkeit",
"routine.title": "{name}s Routine",
"routine.no_child": "Wähle ein Kind für diese Karte.",
"routine.task_of": "Aufgabe {current} von {total}",
"routine.all_done_count": "Alle {total} erledigt",
"routine.done": "Fertig ✓",
"routine.completed": "Erledigt",
"routine.sent_for_checking": "Zur Prüfung gesendet",
"routine.skip": "Später",
"routine.next": "Weiter →",
"routine.back": "← Zurück",
"routine.waiting_for_parent": "⏳ Warte auf die Prüfung durch einen Erwachsenen",
"routine.photo_note": "Hierfür ist ein Foto nötig — tippe es auf deiner Karte an.",
"routine.finished_title": "Routine geschafft!",
"routine.finished_body": "Gut gemacht, {name}. Das war alles.",
"routine.earned": "+{points} verdient",
"routine.awaiting_points": "+{points}, sobald ein Erwachsener prüft",
"routine.skipped_note": "{count} für später übrig.",
"routine.start_again": "Neu starten",
"routine.empty_title": "Nichts zu tun",
"routine.empty_body": "Diese Routine ist schon erledigt.",
"child.roulette_spin": "Für eine ×{multiplier}-Aufgabe drehen",
"child.roulette_respin": "Nochmal drehen (noch {count})",
"child.roulette_no_spins": "Heute keine Drehungen mehr",
"panel.settings_roulette_section": "Aufgaben-Roulette",
"panel.settings_roulette_enabled": "Aufgaben-Roulette aktivieren",
"panel.settings_roulette_enabled_hint": "Ein Kind kann für eine zufällige offene Aufgabe mit Extrapunkten drehen.",
"panel.settings_roulette_multiplier": "Punkte-Multiplikator",
"panel.settings_roulette_spins": "Drehungen pro Tag",
"panel.settings_unlock_allowlist": "Freischalt-Positivliste",
"panel.settings_unlock_allowlist_hint": "Nur diese Entitäten dürfen von einer Belohnung freigeschaltet werden. Bis du hier etwas hinzufügst, ist nichts erlaubt.",
"panel.settings_unlock_allowlist_empty": "Noch nichts erlaubt.",
"panel.settings_unlock_allowlist_add": "Entität zum Erlauben suchen",
"panel.reward_unlock_section": "Erweitert — zeitlich begrenzte Freischaltung",
"panel.reward_unlock_intro": "Schaltet etwas ein, wenn diese Belohnung genehmigt wird, und nach einer festgelegten Zeit wieder aus.",
"panel.reward_unlock_none": "Nichts",
"panel.reward_unlock_entity": "Freischalten",
"panel.reward_unlock_minutes": "Wie lange (Minuten)",
"panel.reward_unlock_minutes_hint": "0 = eingeschaltet lassen; du musst selbst ausschalten.",
"panel.reward_unlock_no_allowlist": "Füge zuerst in den Einstellungen eine Entität zur Freischalt-Positivliste hinzu.",
"panel.tab_insights": "Einblicke",
"panel.insights_loading": "Wird berechnet…",
"panel.insights_fairness_title": "Wer macht die Arbeit?",
"panel.insights_fairness_intro": "{start} bis {end}. Gleichmäßig verteilt wären {fair}% pro Kind.",
"panel.insights_last_days": "Letzte {days} Tage",
"panel.insights_no_data": "In diesem Zeitraum noch keine bestätigten Aufgaben.",
"panel.insights_balanced": "Wirkt recht ausgewogen.",
"panel.insights_unbalanced": "Die Arbeit ist ungleich verteilt.",
"panel.insights_chores": "Aufgaben",
"panel.insights_status_over": "Macht mehr",
"panel.insights_status_under": "Macht weniger",
"panel.insights_status_balanced": "Ausgewogen",
"panel.insights_status_idle": "Noch nichts",
"panel.insights_fairness_note": "Bewertet nach Anzahl der Aufgaben, nicht nach Punkten, damit eine teurere Aufgabe keine ungleiche Verteilung verdeckt. Markiert, wenn ein Kind mehr als {tolerance} Punkte von einem gleichen Anteil abweicht.",
"panel.insights_chore": "Aufgabe",
"panel.insights_view_fairness": "Fairness",
"panel.insights_view_friction": "Reibung",
"panel.insights_friction_title": "Was funktioniert nicht?",
"panel.insights_friction_intro": "{start} bis {end}. Aufgaben werden danach bewertet, wie oft sie tatsächlich erledigt wurden im Verhältnis dazu, wie oft sie anstanden.",
"panel.insights_friction_none": "Keine aktiven Aufgaben vorhanden.",
"panel.insights_friction_healthy": "Es wird alles erledigt.",
"panel.insights_friction_problems": "{count} Aufgabe(n) genauer ansehen.",
"panel.insights_col_chore": "Aufgabe",
"panel.insights_col_done": "Erledigt",
"panel.insights_col_rate": "Quote",
"panel.insights_col_last": "Zuletzt",
"panel.insights_col_verdict": "Urteil",
"panel.insights_col_suggestion": "Vorschlag",
"panel.insights_never": "Nie",
"panel.insights_days_ago": "vor {days} T.",
"panel.insights_verdict_never": "Nie erledigt",
"panel.insights_verdict_stalling": "Bleibt liegen",
"panel.insights_verdict_struggling": "Unregelmäßig",
"panel.insights_verdict_fine": "In Ordnung",
"panel.insights_verdict_unknown": "Unklar",
"panel.insights_suggest_retire": "Abschaffen",
"panel.insights_suggest_reprice": "Punkte erhöhen",
"panel.insights_suggest_reassign": "Jemand anderem geben",
"panel.insights_suggest_keep": "So lassen",
"panel.insights_outstanding_misses": "Offene versäumte Pflichtaufgaben",
"panel.insights_friction_note": "Ablehnungen sind nicht aufgeführt: TaskMate löscht eine Erledigung, wenn du sie ablehnst, es gibt also keinen Verlauf dazu. Die erwarteten Werte sind Näherungen — Rotation, Abhängigkeiten und Wetter werden nicht nachgestellt.",
"panel.insights_view_projection": "Kommende Woche",
"panel.insights_projection_title": "Was steht an?",
"panel.insights_projection_intro": "{start} bis {end}, basierend auf dem Zeitplan jeder Aufgabe.",
"panel.insights_next_days": "Nächste {days} Tage",
"panel.insights_col_day": "Tag",
"panel.insights_projected_earn": "bis zu {points} {name}",
"panel.insights_projected_total": "käme auf {total}",
"panel.insights_projection_unassigned": "{points} Punkte sind niemandem zugewiesen.",
"panel.insights_projection_note": "Eine Obergrenze, keine Prognose: Aufgaben für alle werden jedem berechtigten Kind angerechnet, da der Zeitplan nicht wissen kann, wer zuerst da ist. Wetter, Abhängigkeiten und Verfügbarkeit werden nicht vorhergesagt — sie hängen vom Tag ab.",
"panel.insights_view_health": "Zustand",
"panel.health_title": "Ist etwas kaputt?",
"panel.health_recheck": "Erneut prüfen",
"panel.health_all_good": "Nichts gefunden.",
"panel.health_issues": "{count} Sache(n) brauchen Aufmerksamkeit.",
"panel.health_sev_error": "Fehler",
"panel.health_sev_warning": "Warnung",
"panel.health_sev_info": "Hinweis",
"panel.health_goto": "Anzeigen",
"panel.health_count_children": "Kinder",
"panel.health_count_chores": "Aufgaben",
"panel.health_count_enabled_chores": "Aktiv",
"panel.health_count_rewards": "Belohnungen",
"panel.health_count_completions": "Erledigungen",
"panel.health_count_badges": "Abzeichen",
"panel.health_count_scheduled_changes": "Geplant",
"panel.health_count_active_unlocks": "Freischaltungen",
"panel.health_count_mandatory_misses": "Versäumt",
"panel.health_count_storage": "Speicher",
"panel.chore_icon_label": "Bild",
"child.editor.pre_reader": "Nur-Bilder-Modus (für kleine Kinder)",
"child.editor.pre_reader_labels": "Namen im Bildmodus anzeigen",
"common.design.accessible": "Barrierefrei"
}
@@ -1538,5 +1538,179 @@
"panel.family_goal_reward": "Reward",
"panel.family_goal_reward_ph": "a family movie night",
"notification.family_goal_reached.name": "Family goal reached",
"notification.family_goal_reached.description": "Celebrates when the family's combined points reach the shared goal. Off by default."
"notification.family_goal_reached.description": "Celebrates when the family's combined points reach the shared goal. Off by default.",
"panel.chore_advanced_weather": "Advanced — weather conditions",
"panel.chore_weather_intro": "Hide this chore while the weather is unsuitable. Useful for outdoor jobs — a rained-off chore doesn't count as a mandatory miss and won't break a streak.",
"panel.chore_weather_entity_label": "Weather entity",
"panel.chore_weather_entity_hint": "Leave empty to ignore the weather entirely.",
"panel.chore_weather_conditions_label": "Hide when the weather is",
"panel.chore_weather_conditions_hint": "Pick any conditions that should hide the chore.",
"panel.chore_weather_temp_min_label": "Minimum temperature",
"panel.chore_weather_temp_min_hint": "Hide below this. Leave empty for no limit.",
"panel.chore_weather_temp_max_label": "Maximum temperature",
"panel.chore_weather_temp_max_hint": "Hide above this. Leave empty for no limit.",
"panel.chore_weather_wind_max_label": "Maximum wind speed",
"panel.chore_weather_wind_max_hint": "Hide above this. Leave empty for no limit.",
"panel.chore_weather_failopen_hint": "Limits use whatever units your weather entity reports. If the entity is missing or unavailable, the chore stays visible.",
"weather.blocked_condition": "Hidden — unsuitable weather",
"weather.blocked_temp_low": "Hidden — too cold",
"weather.blocked_temp_high": "Hidden — too hot",
"weather.blocked_wind": "Hidden — too windy",
"weather.condition_rainy": "Rainy",
"weather.condition_pouring": "Pouring",
"weather.condition_snowy": "Snowy",
"weather.condition_snowy_rainy": "Sleet",
"weather.condition_hail": "Hail",
"weather.condition_lightning": "Lightning",
"weather.condition_lightning_rainy": "Thunderstorm",
"weather.condition_fog": "Fog",
"weather.condition_windy": "Windy",
"weather.condition_windy_variant": "Very windy",
"weather.condition_cloudy": "Cloudy",
"weather.condition_exceptional": "Severe",
"child.deadline_minutes": "{mins} min left",
"child.deadline_hours": "{hours}h {mins}m left",
"child.deadline_title": "Do this before the time runs out",
"panel.chore_advanced_scheduled": "Advanced — scheduled changes",
"panel.chore_scheduled_intro": "Queue a change to take effect on a future date — \"from 1 September this is worth 20 points\". Applied at midnight; if Home Assistant was off, it catches up on the next start.",
"panel.chore_scheduled_none": "No changes queued.",
"panel.chore_scheduled_history": "Already applied",
"panel.chore_scheduled_applied": "Applied",
"panel.sched_date_label": "Apply on",
"panel.sched_field_label": "Change",
"panel.sched_value_label": "New value",
"panel.sched_note_label": "Note (optional)",
"panel.sched_note_hint": "A reminder of why, shown beside the queued change.",
"panel.btn_add_scheduled": "Queue change",
"panel.sched_value_yes": "Yes",
"panel.sched_value_no": "No",
"panel.sched_value_nobody": "Nobody",
"panel.toast_sched_added": "Change queued",
"panel.toast_sched_removed": "Change removed",
"panel.toast_sched_date_required": "Pick a date for the change",
"panel.toast_sched_value_required": "Enter a value for the change",
"panel.sched_field_points": "Points",
"panel.sched_field_assigned_to": "Assigned to",
"panel.sched_field_enabled": "Enabled",
"panel.sched_field_requires_approval": "Requires approval",
"panel.sched_field_daily_limit": "Daily limit",
"panel.sched_field_due_days": "Days",
"panel.sched_field_mandatory": "Mandatory",
"panel.sched_field_mandatory_penalty_points": "Penalty points",
"panel.sched_field_expires_on": "Expires on",
"panel.sched_field_description": "Description",
"panel.sched_field_time_category": "Time of day",
"panel.sched_field_difficulty": "Difficulty",
"routine.title": "{name}'s routine",
"routine.no_child": "Pick a child for this card.",
"routine.task_of": "Task {current} of {total}",
"routine.all_done_count": "All {total} done",
"routine.done": "Done ✓",
"routine.completed": "Completed",
"routine.sent_for_checking": "Sent for checking",
"routine.skip": "Skip for now",
"routine.next": "Next →",
"routine.back": "← Back",
"routine.waiting_for_parent": "⏳ Waiting for a grown-up to check",
"routine.photo_note": "This one needs a photo — tap it on your card.",
"routine.finished_title": "Routine done!",
"routine.finished_body": "Nice one, {name}. That's everything.",
"routine.earned": "+{points} earned",
"routine.awaiting_points": "+{points} once a grown-up checks",
"routine.skipped_note": "{count} left for later.",
"routine.start_again": "Start again",
"routine.empty_title": "Nothing to do",
"routine.empty_body": "This routine is already finished.",
"child.roulette_spin": "Spin for a ×{multiplier} chore",
"child.roulette_respin": "Spin again ({count} left)",
"child.roulette_no_spins": "No spins left today",
"panel.settings_roulette_section": "Chore roulette",
"panel.settings_roulette_enabled": "Enable chore roulette",
"panel.settings_roulette_enabled_hint": "Lets a child spin for a random outstanding chore worth extra points.",
"panel.settings_roulette_multiplier": "Points multiplier",
"panel.settings_roulette_spins": "Spins per day",
"panel.settings_unlock_allowlist": "Unlock allowlist",
"panel.settings_unlock_allowlist_hint": "Only these entities may be unlocked by a reward. Nothing is allowed until you add something here.",
"panel.settings_unlock_allowlist_empty": "Nothing allowed yet.",
"panel.settings_unlock_allowlist_add": "Search for an entity to allow",
"panel.reward_unlock_section": "Advanced — timed unlock",
"panel.reward_unlock_intro": "Turn something on when this reward is approved, and back off again after a set time.",
"panel.reward_unlock_none": "Nothing",
"panel.reward_unlock_entity": "Unlock",
"panel.reward_unlock_minutes": "For how long (minutes)",
"panel.reward_unlock_minutes_hint": "0 = leave it on; you'll need to turn it off yourself.",
"panel.reward_unlock_no_allowlist": "Add an entity to the unlock allowlist in Settings first.",
"panel.tab_insights": "Insights",
"panel.insights_loading": "Working it out…",
"panel.insights_fairness_title": "Who's doing the work?",
"panel.insights_fairness_intro": "{start} to {end}. An even split would be {fair}% each.",
"panel.insights_last_days": "Last {days} days",
"panel.insights_no_data": "No approved chores in this period yet.",
"panel.insights_balanced": "Looks fairly balanced.",
"panel.insights_unbalanced": "The workload is uneven.",
"panel.insights_chores": "chores",
"panel.insights_status_over": "Doing more",
"panel.insights_status_under": "Doing less",
"panel.insights_status_balanced": "Balanced",
"panel.insights_status_idle": "Nothing yet",
"panel.insights_fairness_note": "Judged on chore count, not points, so a pricier chore doesn't hide an uneven split. Flagged when a child is more than {tolerance} points off an even share.",
"panel.insights_chore": "chore",
"panel.insights_view_fairness": "Fairness",
"panel.insights_view_friction": "Friction",
"panel.insights_friction_title": "What isn't working?",
"panel.insights_friction_intro": "{start} to {end}. Chores are judged on how often they actually get done versus how often they came up.",
"panel.insights_friction_none": "No active chores to look at.",
"panel.insights_friction_healthy": "Everything is getting done.",
"panel.insights_friction_problems": "{count} chore(s) worth a look.",
"panel.insights_col_chore": "Chore",
"panel.insights_col_done": "Done",
"panel.insights_col_rate": "Rate",
"panel.insights_col_last": "Last done",
"panel.insights_col_verdict": "Verdict",
"panel.insights_col_suggestion": "Try",
"panel.insights_never": "Never",
"panel.insights_days_ago": "{days}d ago",
"panel.insights_verdict_never": "Never done",
"panel.insights_verdict_stalling": "Stalling",
"panel.insights_verdict_struggling": "Patchy",
"panel.insights_verdict_fine": "Fine",
"panel.insights_verdict_unknown": "Unclear",
"panel.insights_suggest_retire": "Retire it",
"panel.insights_suggest_reprice": "Raise the points",
"panel.insights_suggest_reassign": "Give it to someone else",
"panel.insights_suggest_keep": "Leave it alone",
"panel.insights_outstanding_misses": "Outstanding mandatory misses",
"panel.insights_friction_note": "Rejections aren't listed: TaskMate deletes a completion when you reject it, so there's no rejection history to count. Expected counts are approximate — they don't replay rotation, dependencies or weather.",
"panel.insights_view_projection": "Week ahead",
"panel.insights_projection_title": "What's coming up?",
"panel.insights_projection_intro": "{start} to {end}, based on each chore's schedule.",
"panel.insights_next_days": "Next {days} days",
"panel.insights_col_day": "Day",
"panel.insights_projected_earn": "up to {points} {name}",
"panel.insights_projected_total": "would reach {total}",
"panel.insights_projection_unassigned": "{points} points aren't assigned to anyone.",
"panel.insights_projection_note": "A ceiling, not a forecast: chores open to everyone are counted for each eligible child, since the schedule can't know who'll get there first. Weather, dependencies and availability aren't projected — they depend on the day.",
"panel.insights_view_health": "Health",
"panel.health_title": "Is anything broken?",
"panel.health_recheck": "Check again",
"panel.health_all_good": "Nothing wrong found.",
"panel.health_issues": "{count} thing(s) need attention.",
"panel.health_sev_error": "Error",
"panel.health_sev_warning": "Warning",
"panel.health_sev_info": "Note",
"panel.health_goto": "Show me",
"panel.health_count_children": "Children",
"panel.health_count_chores": "Chores",
"panel.health_count_enabled_chores": "Active",
"panel.health_count_rewards": "Rewards",
"panel.health_count_completions": "Completions",
"panel.health_count_badges": "Badges",
"panel.health_count_scheduled_changes": "Scheduled",
"panel.health_count_active_unlocks": "Unlocks",
"panel.health_count_mandatory_misses": "Misses",
"panel.health_count_storage": "Storage",
"panel.chore_icon_label": "Picture",
"child.editor.pre_reader": "Picture-only mode (for young children)",
"child.editor.pre_reader_labels": "Show names in picture mode",
"common.design.accessible": "Accessible"
}
+175 -1
View File
@@ -1538,5 +1538,179 @@
"panel.family_goal_reward": "Reward",
"panel.family_goal_reward_ph": "a family movie night",
"notification.family_goal_reached.name": "Family goal reached",
"notification.family_goal_reached.description": "Celebrates when the family's combined points reach the shared goal. Off by default."
"notification.family_goal_reached.description": "Celebrates when the family's combined points reach the shared goal. Off by default.",
"panel.chore_advanced_weather": "Advanced — weather conditions",
"panel.chore_weather_intro": "Hide this chore while the weather is unsuitable. Useful for outdoor jobs — a rained-off chore doesn't count as a mandatory miss and won't break a streak.",
"panel.chore_weather_entity_label": "Weather entity",
"panel.chore_weather_entity_hint": "Leave empty to ignore the weather entirely.",
"panel.chore_weather_conditions_label": "Hide when the weather is",
"panel.chore_weather_conditions_hint": "Pick any conditions that should hide the chore.",
"panel.chore_weather_temp_min_label": "Minimum temperature",
"panel.chore_weather_temp_min_hint": "Hide below this. Leave empty for no limit.",
"panel.chore_weather_temp_max_label": "Maximum temperature",
"panel.chore_weather_temp_max_hint": "Hide above this. Leave empty for no limit.",
"panel.chore_weather_wind_max_label": "Maximum wind speed",
"panel.chore_weather_wind_max_hint": "Hide above this. Leave empty for no limit.",
"panel.chore_weather_failopen_hint": "Limits use whatever units your weather entity reports. If the entity is missing or unavailable, the chore stays visible.",
"weather.blocked_condition": "Hidden — unsuitable weather",
"weather.blocked_temp_low": "Hidden — too cold",
"weather.blocked_temp_high": "Hidden — too hot",
"weather.blocked_wind": "Hidden — too windy",
"weather.condition_rainy": "Rainy",
"weather.condition_pouring": "Pouring",
"weather.condition_snowy": "Snowy",
"weather.condition_snowy_rainy": "Sleet",
"weather.condition_hail": "Hail",
"weather.condition_lightning": "Lightning",
"weather.condition_lightning_rainy": "Thunderstorm",
"weather.condition_fog": "Fog",
"weather.condition_windy": "Windy",
"weather.condition_windy_variant": "Very windy",
"weather.condition_cloudy": "Cloudy",
"weather.condition_exceptional": "Severe",
"child.deadline_minutes": "{mins} min left",
"child.deadline_hours": "{hours}h {mins}m left",
"child.deadline_title": "Do this before the time runs out",
"panel.chore_advanced_scheduled": "Advanced — scheduled changes",
"panel.chore_scheduled_intro": "Queue a change to take effect on a future date — \"from 1 September this is worth 20 points\". Applied at midnight; if Home Assistant was off, it catches up on the next start.",
"panel.chore_scheduled_none": "No changes queued.",
"panel.chore_scheduled_history": "Already applied",
"panel.chore_scheduled_applied": "Applied",
"panel.sched_date_label": "Apply on",
"panel.sched_field_label": "Change",
"panel.sched_value_label": "New value",
"panel.sched_note_label": "Note (optional)",
"panel.sched_note_hint": "A reminder of why, shown beside the queued change.",
"panel.btn_add_scheduled": "Queue change",
"panel.sched_value_yes": "Yes",
"panel.sched_value_no": "No",
"panel.sched_value_nobody": "Nobody",
"panel.toast_sched_added": "Change queued",
"panel.toast_sched_removed": "Change removed",
"panel.toast_sched_date_required": "Pick a date for the change",
"panel.toast_sched_value_required": "Enter a value for the change",
"panel.sched_field_points": "Points",
"panel.sched_field_assigned_to": "Assigned to",
"panel.sched_field_enabled": "Enabled",
"panel.sched_field_requires_approval": "Requires approval",
"panel.sched_field_daily_limit": "Daily limit",
"panel.sched_field_due_days": "Days",
"panel.sched_field_mandatory": "Mandatory",
"panel.sched_field_mandatory_penalty_points": "Penalty points",
"panel.sched_field_expires_on": "Expires on",
"panel.sched_field_description": "Description",
"panel.sched_field_time_category": "Time of day",
"panel.sched_field_difficulty": "Difficulty",
"routine.title": "{name}'s routine",
"routine.no_child": "Pick a child for this card.",
"routine.task_of": "Task {current} of {total}",
"routine.all_done_count": "All {total} done",
"routine.done": "Done ✓",
"routine.completed": "Completed",
"routine.sent_for_checking": "Sent for checking",
"routine.skip": "Skip for now",
"routine.next": "Next →",
"routine.back": "← Back",
"routine.waiting_for_parent": "⏳ Waiting for a grown-up to check",
"routine.photo_note": "This one needs a photo — tap it on your card.",
"routine.finished_title": "Routine done!",
"routine.finished_body": "Nice one, {name}. That's everything.",
"routine.earned": "+{points} earned",
"routine.awaiting_points": "+{points} once a grown-up checks",
"routine.skipped_note": "{count} left for later.",
"routine.start_again": "Start again",
"routine.empty_title": "Nothing to do",
"routine.empty_body": "This routine is already finished.",
"child.roulette_spin": "Spin for a ×{multiplier} chore",
"child.roulette_respin": "Spin again ({count} left)",
"child.roulette_no_spins": "No spins left today",
"panel.settings_roulette_section": "Chore roulette",
"panel.settings_roulette_enabled": "Enable chore roulette",
"panel.settings_roulette_enabled_hint": "Lets a child spin for a random outstanding chore worth extra points.",
"panel.settings_roulette_multiplier": "Points multiplier",
"panel.settings_roulette_spins": "Spins per day",
"panel.settings_unlock_allowlist": "Unlock allowlist",
"panel.settings_unlock_allowlist_hint": "Only these entities may be unlocked by a reward. Nothing is allowed until you add something here.",
"panel.settings_unlock_allowlist_empty": "Nothing allowed yet.",
"panel.settings_unlock_allowlist_add": "Search for an entity to allow",
"panel.reward_unlock_section": "Advanced — timed unlock",
"panel.reward_unlock_intro": "Turn something on when this reward is approved, and back off again after a set time.",
"panel.reward_unlock_none": "Nothing",
"panel.reward_unlock_entity": "Unlock",
"panel.reward_unlock_minutes": "For how long (minutes)",
"panel.reward_unlock_minutes_hint": "0 = leave it on; you'll need to turn it off yourself.",
"panel.reward_unlock_no_allowlist": "Add an entity to the unlock allowlist in Settings first.",
"panel.tab_insights": "Insights",
"panel.insights_loading": "Working it out…",
"panel.insights_fairness_title": "Who's doing the work?",
"panel.insights_fairness_intro": "{start} to {end}. An even split would be {fair}% each.",
"panel.insights_last_days": "Last {days} days",
"panel.insights_no_data": "No approved chores in this period yet.",
"panel.insights_balanced": "Looks fairly balanced.",
"panel.insights_unbalanced": "The workload is uneven.",
"panel.insights_chores": "chores",
"panel.insights_status_over": "Doing more",
"panel.insights_status_under": "Doing less",
"panel.insights_status_balanced": "Balanced",
"panel.insights_status_idle": "Nothing yet",
"panel.insights_fairness_note": "Judged on chore count, not points, so a pricier chore doesn't hide an uneven split. Flagged when a child is more than {tolerance} points off an even share.",
"panel.insights_chore": "chore",
"panel.insights_view_fairness": "Fairness",
"panel.insights_view_friction": "Friction",
"panel.insights_friction_title": "What isn't working?",
"panel.insights_friction_intro": "{start} to {end}. Chores are judged on how often they actually get done versus how often they came up.",
"panel.insights_friction_none": "No active chores to look at.",
"panel.insights_friction_healthy": "Everything is getting done.",
"panel.insights_friction_problems": "{count} chore(s) worth a look.",
"panel.insights_col_chore": "Chore",
"panel.insights_col_done": "Done",
"panel.insights_col_rate": "Rate",
"panel.insights_col_last": "Last done",
"panel.insights_col_verdict": "Verdict",
"panel.insights_col_suggestion": "Try",
"panel.insights_never": "Never",
"panel.insights_days_ago": "{days}d ago",
"panel.insights_verdict_never": "Never done",
"panel.insights_verdict_stalling": "Stalling",
"panel.insights_verdict_struggling": "Patchy",
"panel.insights_verdict_fine": "Fine",
"panel.insights_verdict_unknown": "Unclear",
"panel.insights_suggest_retire": "Retire it",
"panel.insights_suggest_reprice": "Raise the points",
"panel.insights_suggest_reassign": "Give it to someone else",
"panel.insights_suggest_keep": "Leave it alone",
"panel.insights_outstanding_misses": "Outstanding mandatory misses",
"panel.insights_friction_note": "Rejections aren't listed: TaskMate deletes a completion when you reject it, so there's no rejection history to count. Expected counts are approximate — they don't replay rotation, dependencies or weather.",
"panel.insights_view_projection": "Week ahead",
"panel.insights_projection_title": "What's coming up?",
"panel.insights_projection_intro": "{start} to {end}, based on each chore's schedule.",
"panel.insights_next_days": "Next {days} days",
"panel.insights_col_day": "Day",
"panel.insights_projected_earn": "up to {points} {name}",
"panel.insights_projected_total": "would reach {total}",
"panel.insights_projection_unassigned": "{points} points aren't assigned to anyone.",
"panel.insights_projection_note": "A ceiling, not a forecast: chores open to everyone are counted for each eligible child, since the schedule can't know who'll get there first. Weather, dependencies and availability aren't projected — they depend on the day.",
"panel.insights_view_health": "Health",
"panel.health_title": "Is anything broken?",
"panel.health_recheck": "Check again",
"panel.health_all_good": "Nothing wrong found.",
"panel.health_issues": "{count} thing(s) need attention.",
"panel.health_sev_error": "Error",
"panel.health_sev_warning": "Warning",
"panel.health_sev_info": "Note",
"panel.health_goto": "Show me",
"panel.health_count_children": "Children",
"panel.health_count_chores": "Chores",
"panel.health_count_enabled_chores": "Active",
"panel.health_count_rewards": "Rewards",
"panel.health_count_completions": "Completions",
"panel.health_count_badges": "Badges",
"panel.health_count_scheduled_changes": "Scheduled",
"panel.health_count_active_unlocks": "Unlocks",
"panel.health_count_mandatory_misses": "Misses",
"panel.health_count_storage": "Storage",
"panel.chore_icon_label": "Picture",
"child.editor.pre_reader": "Picture-only mode (for young children)",
"child.editor.pre_reader_labels": "Show names in picture mode",
"common.design.accessible": "Accessible"
}
+175 -1
View File
@@ -1538,5 +1538,179 @@
"panel.family_goal_reward": "Récompense",
"panel.family_goal_reward_ph": "une soirée cinéma en famille",
"notification.family_goal_reached.name": "Objectif familial atteint",
"notification.family_goal_reached.description": "Célèbre lorsque les points cumulés de la famille atteignent l'objectif commun. Désactivé par défaut."
"notification.family_goal_reached.description": "Célèbre lorsque les points cumulés de la famille atteignent l'objectif commun. Désactivé par défaut.",
"panel.chore_advanced_weather": "Avancé — conditions météo",
"panel.chore_weather_intro": "Masquer cette tâche tant que la météo est défavorable. Utile pour les travaux extérieurs — une tâche annulée par la pluie ne compte pas comme un manquement obligatoire et ne casse aucune série.",
"panel.chore_weather_entity_label": "Entité météo",
"panel.chore_weather_entity_hint": "Laisser vide pour ignorer complètement la météo.",
"panel.chore_weather_conditions_label": "Masquer quand la météo est",
"panel.chore_weather_conditions_hint": "Choisissez les conditions qui doivent masquer la tâche.",
"panel.chore_weather_temp_min_label": "Température minimale",
"panel.chore_weather_temp_min_hint": "Masquer en dessous. Laisser vide pour aucune limite.",
"panel.chore_weather_temp_max_label": "Température maximale",
"panel.chore_weather_temp_max_hint": "Masquer au-dessus. Laisser vide pour aucune limite.",
"panel.chore_weather_wind_max_label": "Vitesse de vent maximale",
"panel.chore_weather_wind_max_hint": "Masquer au-dessus. Laisser vide pour aucune limite.",
"panel.chore_weather_failopen_hint": "Les limites utilisent les unités de votre entité météo. Si l'entité est absente ou indisponible, la tâche reste visible.",
"weather.blocked_condition": "Masquée — météo défavorable",
"weather.blocked_temp_low": "Masquée — trop froid",
"weather.blocked_temp_high": "Masquée — trop chaud",
"weather.blocked_wind": "Masquée — trop venteux",
"weather.condition_rainy": "Pluvieux",
"weather.condition_pouring": "Averses",
"weather.condition_snowy": "Neigeux",
"weather.condition_snowy_rainy": "Neige fondue",
"weather.condition_hail": "Grêle",
"weather.condition_lightning": "Éclairs",
"weather.condition_lightning_rainy": "Orage",
"weather.condition_fog": "Brouillard",
"weather.condition_windy": "Venteux",
"weather.condition_windy_variant": "Très venteux",
"weather.condition_cloudy": "Nuageux",
"weather.condition_exceptional": "Conditions extrêmes",
"child.deadline_minutes": "{mins} min restantes",
"child.deadline_hours": "{hours} h {mins} min restantes",
"child.deadline_title": "À faire avant la fin du temps imparti",
"panel.chore_advanced_scheduled": "Avancé — modifications planifiées",
"panel.chore_scheduled_intro": "Planifiez une modification pour une date future — « à partir du 1er septembre, elle vaut 20 points ». Appliquée à minuit ; si Home Assistant était éteint, elle se rattrape au démarrage suivant.",
"panel.chore_scheduled_none": "Aucune modification planifiée.",
"panel.chore_scheduled_history": "Déjà appliquées",
"panel.chore_scheduled_applied": "Appliquée",
"panel.sched_date_label": "Appliquer le",
"panel.sched_field_label": "Modification",
"panel.sched_value_label": "Nouvelle valeur",
"panel.sched_note_label": "Note (facultatif)",
"panel.sched_note_hint": "Un rappel du pourquoi, affiché à côté de la modification planifiée.",
"panel.btn_add_scheduled": "Planifier",
"panel.sched_value_yes": "Oui",
"panel.sched_value_no": "Non",
"panel.sched_value_nobody": "Personne",
"panel.toast_sched_added": "Modification planifiée",
"panel.toast_sched_removed": "Modification supprimée",
"panel.toast_sched_date_required": "Choisissez une date pour la modification",
"panel.toast_sched_value_required": "Saisissez une valeur pour la modification",
"panel.sched_field_points": "Points",
"panel.sched_field_assigned_to": "Attribuée à",
"panel.sched_field_enabled": "Activée",
"panel.sched_field_requires_approval": "Validation requise",
"panel.sched_field_daily_limit": "Limite quotidienne",
"panel.sched_field_due_days": "Jours",
"panel.sched_field_mandatory": "Obligatoire",
"panel.sched_field_mandatory_penalty_points": "Points de pénalité",
"panel.sched_field_expires_on": "Expire le",
"panel.sched_field_description": "Description",
"panel.sched_field_time_category": "Moment de la journée",
"panel.sched_field_difficulty": "Difficulté",
"routine.title": "Routine de {name}",
"routine.no_child": "Choisissez un enfant pour cette carte.",
"routine.task_of": "Tâche {current} sur {total}",
"routine.all_done_count": "Les {total} sont faites",
"routine.done": "Terminé ✓",
"routine.completed": "Terminée",
"routine.sent_for_checking": "Envoyé pour vérification",
"routine.skip": "Plus tard",
"routine.next": "Suivant →",
"routine.back": "← Retour",
"routine.waiting_for_parent": "⏳ En attente de la vérification d'un adulte",
"routine.photo_note": "Celle-ci demande une photo — touchez-la sur votre carte.",
"routine.finished_title": "Routine terminée !",
"routine.finished_body": "Bravo, {name}. C'est tout.",
"routine.earned": "+{points} gagnés",
"routine.awaiting_points": "+{points} après vérification",
"routine.skipped_note": "{count} laissée(s) pour plus tard.",
"routine.start_again": "Recommencer",
"routine.empty_title": "Rien à faire",
"routine.empty_body": "Cette routine est déjà terminée.",
"child.roulette_spin": "Tourner pour une tâche ×{multiplier}",
"child.roulette_respin": "Tourner encore ({count} restant)",
"child.roulette_no_spins": "Plus de tours aujourd'hui",
"panel.settings_roulette_section": "Roulette des tâches",
"panel.settings_roulette_enabled": "Activer la roulette des tâches",
"panel.settings_roulette_enabled_hint": "Permet à un enfant de tirer une tâche au hasard valant plus de points.",
"panel.settings_roulette_multiplier": "Multiplicateur de points",
"panel.settings_roulette_spins": "Tours par jour",
"panel.settings_unlock_allowlist": "Liste d'autorisation de déverrouillage",
"panel.settings_unlock_allowlist_hint": "Seules ces entités peuvent être déverrouillées par une récompense. Rien n'est autorisé tant que vous n'ajoutez rien ici.",
"panel.settings_unlock_allowlist_empty": "Rien d'autorisé pour l'instant.",
"panel.settings_unlock_allowlist_add": "Rechercher une entité à autoriser",
"panel.reward_unlock_section": "Avancé — déverrouillage temporaire",
"panel.reward_unlock_intro": "Allume quelque chose quand cette récompense est validée, puis l'éteint après un temps donné.",
"panel.reward_unlock_none": "Rien",
"panel.reward_unlock_entity": "Déverrouiller",
"panel.reward_unlock_minutes": "Pendant combien de temps (minutes)",
"panel.reward_unlock_minutes_hint": "0 = laisser allumé ; vous devrez l'éteindre vous-même.",
"panel.reward_unlock_no_allowlist": "Ajoutez d'abord une entité à la liste d'autorisation dans les Réglages.",
"panel.tab_insights": "Analyses",
"panel.insights_loading": "Calcul en cours…",
"panel.insights_fairness_title": "Qui fait le travail ?",
"panel.insights_fairness_intro": "Du {start} au {end}. Une répartition égale serait de {fair}% chacun.",
"panel.insights_last_days": "{days} derniers jours",
"panel.insights_no_data": "Aucune tâche validée sur cette période.",
"panel.insights_balanced": "Cela semble équilibré.",
"panel.insights_unbalanced": "La charge est déséquilibrée.",
"panel.insights_chores": "tâches",
"panel.insights_status_over": "En fait plus",
"panel.insights_status_under": "En fait moins",
"panel.insights_status_balanced": "Équilibré",
"panel.insights_status_idle": "Rien encore",
"panel.insights_fairness_note": "Évalué sur le nombre de tâches, pas les points, pour qu'une tâche mieux payée ne masque pas un déséquilibre. Signalé au-delà de {tolerance} points d'écart avec une part égale.",
"panel.insights_chore": "tâche",
"panel.insights_view_fairness": "Équité",
"panel.insights_view_friction": "Frictions",
"panel.insights_friction_title": "Qu'est-ce qui ne marche pas ?",
"panel.insights_friction_intro": "Du {start} au {end}. Les tâches sont jugées sur la fréquence réelle d'exécution par rapport à leur fréquence d'apparition.",
"panel.insights_friction_none": "Aucune tâche active à examiner.",
"panel.insights_friction_healthy": "Tout se fait.",
"panel.insights_friction_problems": "{count} tâche(s) à regarder.",
"panel.insights_col_chore": "Tâche",
"panel.insights_col_done": "Faites",
"panel.insights_col_rate": "Taux",
"panel.insights_col_last": "Dernière fois",
"panel.insights_col_verdict": "Verdict",
"panel.insights_col_suggestion": "Essayez",
"panel.insights_never": "Jamais",
"panel.insights_days_ago": "il y a {days} j",
"panel.insights_verdict_never": "Jamais faite",
"panel.insights_verdict_stalling": "À la traîne",
"panel.insights_verdict_struggling": "Irrégulière",
"panel.insights_verdict_fine": "Correcte",
"panel.insights_verdict_unknown": "Incertain",
"panel.insights_suggest_retire": "La supprimer",
"panel.insights_suggest_reprice": "Augmenter les points",
"panel.insights_suggest_reassign": "La confier à un autre",
"panel.insights_suggest_keep": "Ne rien changer",
"panel.insights_outstanding_misses": "Manquements obligatoires en attente",
"panel.insights_friction_note": "Les refus ne sont pas listés : TaskMate supprime une validation quand vous la refusez, il n'y a donc pas d'historique. Les valeurs attendues sont approximatives — rotation, dépendances et météo ne sont pas rejouées.",
"panel.insights_view_projection": "Semaine à venir",
"panel.insights_projection_title": "Qu'est-ce qui arrive ?",
"panel.insights_projection_intro": "Du {start} au {end}, d'après le planning de chaque tâche.",
"panel.insights_next_days": "{days} prochains jours",
"panel.insights_col_day": "Jour",
"panel.insights_projected_earn": "jusqu'à {points} {name}",
"panel.insights_projected_total": "atteindrait {total}",
"panel.insights_projection_unassigned": "{points} points ne sont attribués à personne.",
"panel.insights_projection_note": "Un plafond, pas une prévision : les tâches ouvertes à tous sont comptées pour chaque enfant éligible, le planning ne pouvant savoir qui arrivera en premier. Météo, dépendances et disponibilité ne sont pas projetées — elles dépendent du jour.",
"panel.insights_view_health": "Santé",
"panel.health_title": "Quelque chose ne va pas ?",
"panel.health_recheck": "Revérifier",
"panel.health_all_good": "Rien d'anormal.",
"panel.health_issues": "{count} point(s) à regarder.",
"panel.health_sev_error": "Erreur",
"panel.health_sev_warning": "Avertissement",
"panel.health_sev_info": "Note",
"panel.health_goto": "Voir",
"panel.health_count_children": "Enfants",
"panel.health_count_chores": "Tâches",
"panel.health_count_enabled_chores": "Actives",
"panel.health_count_rewards": "Récompenses",
"panel.health_count_completions": "Validations",
"panel.health_count_badges": "Badges",
"panel.health_count_scheduled_changes": "Planifiées",
"panel.health_count_active_unlocks": "Déverrouillages",
"panel.health_count_mandatory_misses": "Manquements",
"panel.health_count_storage": "Stockage",
"panel.chore_icon_label": "Image",
"child.editor.pre_reader": "Mode images seules (jeunes enfants)",
"child.editor.pre_reader_labels": "Afficher les noms en mode images",
"common.design.accessible": "Accessible"
}
+175 -1
View File
@@ -1538,5 +1538,179 @@
"panel.family_goal_reward": "Belønning",
"panel.family_goal_reward_ph": "en familiefilmkveld",
"notification.family_goal_reached.name": "Familiemål nådd",
"notification.family_goal_reached.description": "Feirer når familiens samlede poeng når det felles målet. Av som standard."
"notification.family_goal_reached.description": "Feirer når familiens samlede poeng når det felles målet. Av som standard.",
"panel.chore_advanced_weather": "Avansert — værforhold",
"panel.chore_weather_intro": "Skjul denne oppgaven mens været er uegnet. Nyttig for utendørsarbeid — en oppgave som regner bort teller ikke som en glemt obligatorisk oppgave og bryter ingen rekke.",
"panel.chore_weather_entity_label": "Vær-entitet",
"panel.chore_weather_entity_hint": "La stå tom for å ignorere været helt.",
"panel.chore_weather_conditions_label": "Skjul når været er",
"panel.chore_weather_conditions_hint": "Velg forholdene som skal skjule oppgaven.",
"panel.chore_weather_temp_min_label": "Minimumstemperatur",
"panel.chore_weather_temp_min_hint": "Skjul under dette. La stå tom for ingen grense.",
"panel.chore_weather_temp_max_label": "Maksimumstemperatur",
"panel.chore_weather_temp_max_hint": "Skjul over dette. La stå tom for ingen grense.",
"panel.chore_weather_wind_max_label": "Maksimal vindstyrke",
"panel.chore_weather_wind_max_hint": "Skjul over dette. La stå tom for ingen grense.",
"panel.chore_weather_failopen_hint": "Grensene bruker enhetene vær-entiteten din rapporterer. Hvis entiteten mangler eller er utilgjengelig, forblir oppgaven synlig.",
"weather.blocked_condition": "Skjult — uegnet vær",
"weather.blocked_temp_low": "Skjult — for kaldt",
"weather.blocked_temp_high": "Skjult — for varmt",
"weather.blocked_wind": "Skjult — for mye vind",
"weather.condition_rainy": "Regn",
"weather.condition_pouring": "Kraftig regn",
"weather.condition_snowy": "Snø",
"weather.condition_snowy_rainy": "Sludd",
"weather.condition_hail": "Hagl",
"weather.condition_lightning": "Lyn",
"weather.condition_lightning_rainy": "Tordenvær",
"weather.condition_fog": "Tåke",
"weather.condition_windy": "Vindfullt",
"weather.condition_windy_variant": "Svært vindfullt",
"weather.condition_cloudy": "Skyet",
"weather.condition_exceptional": "Ekstremvær",
"child.deadline_minutes": "{mins} min igjen",
"child.deadline_hours": "{hours}t {mins}m igjen",
"child.deadline_title": "Gjør dette før tiden renner ut",
"panel.chore_advanced_scheduled": "Avansert — planlagte endringer",
"panel.chore_scheduled_intro": "Planlegg en endring til en framtidig dato — «fra 1. september er den verdt 20 poeng». Utføres ved midnatt; var Home Assistant av, tas den igjen ved neste oppstart.",
"panel.chore_scheduled_none": "Ingen endringer planlagt.",
"panel.chore_scheduled_history": "Allerede utført",
"panel.chore_scheduled_applied": "Utført",
"panel.sched_date_label": "Utfør den",
"panel.sched_field_label": "Endring",
"panel.sched_value_label": "Ny verdi",
"panel.sched_note_label": "Notat (valgfritt)",
"panel.sched_note_hint": "En påminnelse om hvorfor, vist ved siden av den planlagte endringen.",
"panel.btn_add_scheduled": "Planlegg endring",
"panel.sched_value_yes": "Ja",
"panel.sched_value_no": "Nei",
"panel.sched_value_nobody": "Ingen",
"panel.toast_sched_added": "Endring planlagt",
"panel.toast_sched_removed": "Endring fjernet",
"panel.toast_sched_date_required": "Velg en dato for endringen",
"panel.toast_sched_value_required": "Skriv inn en verdi for endringen",
"panel.sched_field_points": "Poeng",
"panel.sched_field_assigned_to": "Tildelt",
"panel.sched_field_enabled": "Aktivert",
"panel.sched_field_requires_approval": "Krever godkjenning",
"panel.sched_field_daily_limit": "Daglig grense",
"panel.sched_field_due_days": "Dager",
"panel.sched_field_mandatory": "Obligatorisk",
"panel.sched_field_mandatory_penalty_points": "Strafferpoeng",
"panel.sched_field_expires_on": "Utløper",
"panel.sched_field_description": "Beskrivelse",
"panel.sched_field_time_category": "Tid på dagen",
"panel.sched_field_difficulty": "Vanskelighetsgrad",
"routine.title": "{name}s rutine",
"routine.no_child": "Velg et barn for dette kortet.",
"routine.task_of": "Oppgave {current} av {total}",
"routine.all_done_count": "Alle {total} ferdige",
"routine.done": "Ferdig ✓",
"routine.completed": "Fullført",
"routine.sent_for_checking": "Sendt til sjekk",
"routine.skip": "Senere",
"routine.next": "Neste →",
"routine.back": "← Tilbake",
"routine.waiting_for_parent": "⏳ Venter på at en voksen sjekker",
"routine.photo_note": "Denne trenger et bilde — trykk på den på kortet ditt.",
"routine.finished_title": "Rutinen er ferdig!",
"routine.finished_body": "Bra jobba, {name}. Det var alt.",
"routine.earned": "+{points} tjent",
"routine.awaiting_points": "+{points} når en voksen har sjekket",
"routine.skipped_note": "{count} igjen til senere.",
"routine.start_again": "Start på nytt",
"routine.empty_title": "Ingenting å gjøre",
"routine.empty_body": "Denne rutinen er allerede ferdig.",
"child.roulette_spin": "Spinn for en ×{multiplier}-oppgave",
"child.roulette_respin": "Spinn igjen ({count} igjen)",
"child.roulette_no_spins": "Ingen spinn igjen i dag",
"panel.settings_roulette_section": "Oppgaveroulette",
"panel.settings_roulette_enabled": "Slå på oppgaveroulette",
"panel.settings_roulette_enabled_hint": "Lar et barn spinne for en tilfeldig gjenstående oppgave verdt ekstra poeng.",
"panel.settings_roulette_multiplier": "Poengmultiplikator",
"panel.settings_roulette_spins": "Spinn per dag",
"panel.settings_unlock_allowlist": "Tillatelsesliste for opplåsing",
"panel.settings_unlock_allowlist_hint": "Bare disse entitetene kan låses opp av en belønning. Ingenting er tillatt før du legger til noe her.",
"panel.settings_unlock_allowlist_empty": "Ingenting tillatt ennå.",
"panel.settings_unlock_allowlist_add": "Søk etter en entitet å tillate",
"panel.reward_unlock_section": "Avansert — tidsbegrenset opplåsing",
"panel.reward_unlock_intro": "Slår på noe når denne belønningen godkjennes, og av igjen etter en angitt tid.",
"panel.reward_unlock_none": "Ingenting",
"panel.reward_unlock_entity": "Lås opp",
"panel.reward_unlock_minutes": "Hvor lenge (minutter)",
"panel.reward_unlock_minutes_hint": "0 = la den stå på; du må slå av selv.",
"panel.reward_unlock_no_allowlist": "Legg først til en entitet i tillatelseslisten under Innstillinger.",
"panel.tab_insights": "Innsikt",
"panel.insights_loading": "Regner ut…",
"panel.insights_fairness_title": "Hvem gjør jobben?",
"panel.insights_fairness_intro": "{start} til {end}. En jevn fordeling ville vært {fair}% hver.",
"panel.insights_last_days": "Siste {days} dager",
"panel.insights_no_data": "Ingen godkjente oppgaver i denne perioden ennå.",
"panel.insights_balanced": "Ser ganske jevnt fordelt ut.",
"panel.insights_unbalanced": "Arbeidet er ujevnt fordelt.",
"panel.insights_chores": "oppgaver",
"panel.insights_status_over": "Gjør mer",
"panel.insights_status_under": "Gjør mindre",
"panel.insights_status_balanced": "Jevnt",
"panel.insights_status_idle": "Ingenting ennå",
"panel.insights_fairness_note": "Vurdert på antall oppgaver, ikke poeng, så en dyrere oppgave ikke skjuler en skjev fordeling. Markert når et barn er mer enn {tolerance} poeng unna en lik andel.",
"panel.insights_chore": "oppgave",
"panel.insights_view_fairness": "Rettferdighet",
"panel.insights_view_friction": "Friksjon",
"panel.insights_friction_title": "Hva fungerer ikke?",
"panel.insights_friction_intro": "{start} til {end}. Oppgaver vurderes etter hvor ofte de faktisk blir gjort mot hvor ofte de dukket opp.",
"panel.insights_friction_none": "Ingen aktive oppgaver å se på.",
"panel.insights_friction_healthy": "Alt blir gjort.",
"panel.insights_friction_problems": "{count} oppgave(r) verdt et blikk.",
"panel.insights_col_chore": "Oppgave",
"panel.insights_col_done": "Gjort",
"panel.insights_col_rate": "Andel",
"panel.insights_col_last": "Sist gjort",
"panel.insights_col_verdict": "Dom",
"panel.insights_col_suggestion": "Prøv",
"panel.insights_never": "Aldri",
"panel.insights_days_ago": "for {days}d siden",
"panel.insights_verdict_never": "Aldri gjort",
"panel.insights_verdict_stalling": "Stopper opp",
"panel.insights_verdict_struggling": "Ujevn",
"panel.insights_verdict_fine": "Greit",
"panel.insights_verdict_unknown": "Uklart",
"panel.insights_suggest_retire": "Fjern den",
"panel.insights_suggest_reprice": "Øk poengene",
"panel.insights_suggest_reassign": "Gi den til noen andre",
"panel.insights_suggest_keep": "La den være",
"panel.insights_outstanding_misses": "Uavklarte glemte pliktoppgaver",
"panel.insights_friction_note": "Avvisninger er ikke listet: TaskMate sletter en fullføring når du avviser den, så det finnes ingen historikk. Forventede tall er omtrentlige — rotasjon, avhengigheter og vær spilles ikke av på nytt.",
"panel.insights_view_projection": "Uka som kommer",
"panel.insights_projection_title": "Hva står for tur?",
"panel.insights_projection_intro": "{start} til {end}, basert på timeplanen til hver oppgave.",
"panel.insights_next_days": "Neste {days} dager",
"panel.insights_col_day": "Dag",
"panel.insights_projected_earn": "opptil {points} {name}",
"panel.insights_projected_total": "ville nå {total}",
"panel.insights_projection_unassigned": "{points} poeng er ikke tildelt noen.",
"panel.insights_projection_note": "Et tak, ikke en prognose: oppgaver åpne for alle telles for hvert aktuelt barn, siden timeplanen ikke kan vite hvem som rekker først. Vær, avhengigheter og tilgjengelighet framskrives ikke — de avhenger av dagen.",
"panel.insights_view_health": "Tilstand",
"panel.health_title": "Er noe ødelagt?",
"panel.health_recheck": "Sjekk igjen",
"panel.health_all_good": "Fant ingenting galt.",
"panel.health_issues": "{count} ting trenger oppmerksomhet.",
"panel.health_sev_error": "Feil",
"panel.health_sev_warning": "Advarsel",
"panel.health_sev_info": "Merk",
"panel.health_goto": "Vis meg",
"panel.health_count_children": "Barn",
"panel.health_count_chores": "Oppgaver",
"panel.health_count_enabled_chores": "Aktive",
"panel.health_count_rewards": "Belønninger",
"panel.health_count_completions": "Fullføringer",
"panel.health_count_badges": "Merker",
"panel.health_count_scheduled_changes": "Planlagte",
"panel.health_count_active_unlocks": "Opplåsinger",
"panel.health_count_mandatory_misses": "Glemt",
"panel.health_count_storage": "Lagring",
"panel.chore_icon_label": "Bilde",
"child.editor.pre_reader": "Kun bilder (for små barn)",
"child.editor.pre_reader_labels": "Vis navn i bildemodus",
"common.design.accessible": "Tilgjengelig"
}
+175 -1
View File
@@ -1538,5 +1538,179 @@
"panel.family_goal_reward": "Beløning",
"panel.family_goal_reward_ph": "ein familiefilmkveld",
"notification.family_goal_reached.name": "Familiemål nådd",
"notification.family_goal_reached.description": "Feirar når dei samla poenga til familien når det felles målet. Av som standard."
"notification.family_goal_reached.description": "Feirar når dei samla poenga til familien når det felles målet. Av som standard.",
"panel.chore_advanced_weather": "Avansert — vertilhøve",
"panel.chore_weather_intro": "Skjul denne oppgåva medan veret er uegna. Nyttig for utandørsarbeid — ei oppgåve som regnar bort tel ikkje som ei gløymd obligatorisk oppgåve og bryt ingen rekkje.",
"panel.chore_weather_entity_label": "Ver-entitet",
"panel.chore_weather_entity_hint": "La stå tom for å ignorere veret heilt.",
"panel.chore_weather_conditions_label": "Skjul når veret er",
"panel.chore_weather_conditions_hint": "Vel tilhøva som skal skjule oppgåva.",
"panel.chore_weather_temp_min_label": "Minimumstemperatur",
"panel.chore_weather_temp_min_hint": "Skjul under dette. La stå tom for inga grense.",
"panel.chore_weather_temp_max_label": "Maksimumstemperatur",
"panel.chore_weather_temp_max_hint": "Skjul over dette. La stå tom for inga grense.",
"panel.chore_weather_wind_max_label": "Maksimal vindstyrke",
"panel.chore_weather_wind_max_hint": "Skjul over dette. La stå tom for inga grense.",
"panel.chore_weather_failopen_hint": "Grensene bruker einingane ver-entiteten din rapporterer. Om entiteten manglar eller er utilgjengeleg, held oppgåva fram med å vere synleg.",
"weather.blocked_condition": "Skjult — uegna ver",
"weather.blocked_temp_low": "Skjult — for kaldt",
"weather.blocked_temp_high": "Skjult — for varmt",
"weather.blocked_wind": "Skjult — for mykje vind",
"weather.condition_rainy": "Regn",
"weather.condition_pouring": "Kraftig regn",
"weather.condition_snowy": "Snø",
"weather.condition_snowy_rainy": "Sludd",
"weather.condition_hail": "Hagl",
"weather.condition_lightning": "Lyn",
"weather.condition_lightning_rainy": "Tordenver",
"weather.condition_fog": "Skodde",
"weather.condition_windy": "Vindfullt",
"weather.condition_windy_variant": "Svært vindfullt",
"weather.condition_cloudy": "Skya",
"weather.condition_exceptional": "Ekstremver",
"child.deadline_minutes": "{mins} min att",
"child.deadline_hours": "{hours}t {mins}m att",
"child.deadline_title": "Gjer dette før tida renn ut",
"panel.chore_advanced_scheduled": "Avansert — planlagde endringar",
"panel.chore_scheduled_intro": "Planlegg ei endring til ein framtidig dato — «frå 1. september er ho verdt 20 poeng». Vert utført ved midnatt; var Home Assistant av, vert ho teken att ved neste oppstart.",
"panel.chore_scheduled_none": "Ingen endringar planlagde.",
"panel.chore_scheduled_history": "Alt utført",
"panel.chore_scheduled_applied": "Utført",
"panel.sched_date_label": "Utfør den",
"panel.sched_field_label": "Endring",
"panel.sched_value_label": "Ny verdi",
"panel.sched_note_label": "Notat (valfritt)",
"panel.sched_note_hint": "Ei påminning om kvifor, vist ved sida av den planlagde endringa.",
"panel.btn_add_scheduled": "Planlegg endring",
"panel.sched_value_yes": "Ja",
"panel.sched_value_no": "Nei",
"panel.sched_value_nobody": "Ingen",
"panel.toast_sched_added": "Endring planlagd",
"panel.toast_sched_removed": "Endring fjerna",
"panel.toast_sched_date_required": "Vel ein dato for endringa",
"panel.toast_sched_value_required": "Skriv inn ein verdi for endringa",
"panel.sched_field_points": "Poeng",
"panel.sched_field_assigned_to": "Tildelt",
"panel.sched_field_enabled": "Aktivert",
"panel.sched_field_requires_approval": "Krev godkjenning",
"panel.sched_field_daily_limit": "Dagleg grense",
"panel.sched_field_due_days": "Dagar",
"panel.sched_field_mandatory": "Obligatorisk",
"panel.sched_field_mandatory_penalty_points": "Straffepoeng",
"panel.sched_field_expires_on": "Går ut",
"panel.sched_field_description": "Skildring",
"panel.sched_field_time_category": "Tid på dagen",
"panel.sched_field_difficulty": "Vanskegrad",
"routine.title": "{name} si rutine",
"routine.no_child": "Vel eit barn for dette kortet.",
"routine.task_of": "Oppgåve {current} av {total}",
"routine.all_done_count": "Alle {total} ferdige",
"routine.done": "Ferdig ✓",
"routine.completed": "Fullført",
"routine.sent_for_checking": "Sendt til sjekk",
"routine.skip": "Seinare",
"routine.next": "Neste →",
"routine.back": "← Tilbake",
"routine.waiting_for_parent": "⏳ Ventar på at ein vaksen sjekkar",
"routine.photo_note": "Denne treng eit bilete — trykk på han på kortet ditt.",
"routine.finished_title": "Rutinen er ferdig!",
"routine.finished_body": "Bra jobba, {name}. Det var alt.",
"routine.earned": "+{points} tent",
"routine.awaiting_points": "+{points} når ein vaksen har sjekka",
"routine.skipped_note": "{count} att til seinare.",
"routine.start_again": "Start på nytt",
"routine.empty_title": "Ingenting å gjere",
"routine.empty_body": "Denne rutinen er alt ferdig.",
"child.roulette_spin": "Spinn for ei ×{multiplier}-oppgåve",
"child.roulette_respin": "Spinn igjen ({count} att)",
"child.roulette_no_spins": "Ingen spinn att i dag",
"panel.settings_roulette_section": "Oppgåveroulette",
"panel.settings_roulette_enabled": "Slå på oppgåveroulette",
"panel.settings_roulette_enabled_hint": "Lèt eit barn spinne for ei tilfeldig gjenståande oppgåve verdt ekstra poeng.",
"panel.settings_roulette_multiplier": "Poengmultiplikator",
"panel.settings_roulette_spins": "Spinn per dag",
"panel.settings_unlock_allowlist": "Løyveliste for opplåsing",
"panel.settings_unlock_allowlist_hint": "Berre desse entitetane kan låsast opp av ei belønning. Ingenting er tillate før du legg til noko her.",
"panel.settings_unlock_allowlist_empty": "Ingenting tillate enno.",
"panel.settings_unlock_allowlist_add": "Søk etter ein entitet å tillate",
"panel.reward_unlock_section": "Avansert — tidsavgrensa opplåsing",
"panel.reward_unlock_intro": "Slår på noko når denne belønninga vert godkjend, og av igjen etter ei fastsett tid.",
"panel.reward_unlock_none": "Ingenting",
"panel.reward_unlock_entity": "Lås opp",
"panel.reward_unlock_minutes": "Kor lenge (minutt)",
"panel.reward_unlock_minutes_hint": "0 = la han stå på; du må slå av sjølv.",
"panel.reward_unlock_no_allowlist": "Legg først til ein entitet i løyvelista under Innstillingar.",
"panel.tab_insights": "Innsikt",
"panel.insights_loading": "Reknar ut…",
"panel.insights_fairness_title": "Kven gjer jobben?",
"panel.insights_fairness_intro": "{start} til {end}. Ei jamn fordeling ville vore {fair}% kvar.",
"panel.insights_last_days": "Siste {days} dagar",
"panel.insights_no_data": "Ingen godkjende oppgåver i denne perioden enno.",
"panel.insights_balanced": "Ser ganske jamt fordelt ut.",
"panel.insights_unbalanced": "Arbeidet er ujamt fordelt.",
"panel.insights_chores": "oppgåver",
"panel.insights_status_over": "Gjer meir",
"panel.insights_status_under": "Gjer mindre",
"panel.insights_status_balanced": "Jamt",
"panel.insights_status_idle": "Ingenting enno",
"panel.insights_fairness_note": "Vurdert på tal oppgåver, ikkje poeng, så ei dyrare oppgåve ikkje skjuler ei skeiv fordeling. Markert når eit barn er meir enn {tolerance} poeng unna ein lik del.",
"panel.insights_chore": "oppgåve",
"panel.insights_view_fairness": "Rettferd",
"panel.insights_view_friction": "Friksjon",
"panel.insights_friction_title": "Kva fungerer ikkje?",
"panel.insights_friction_intro": "{start} til {end}. Oppgåver vert vurderte etter kor ofte dei faktisk vert gjorde mot kor ofte dei dukka opp.",
"panel.insights_friction_none": "Ingen aktive oppgåver å sjå på.",
"panel.insights_friction_healthy": "Alt vert gjort.",
"panel.insights_friction_problems": "{count} oppgåve(r) verdt eit blikk.",
"panel.insights_col_chore": "Oppgåve",
"panel.insights_col_done": "Gjort",
"panel.insights_col_rate": "Del",
"panel.insights_col_last": "Sist gjort",
"panel.insights_col_verdict": "Dom",
"panel.insights_col_suggestion": "Prøv",
"panel.insights_never": "Aldri",
"panel.insights_days_ago": "for {days}d sidan",
"panel.insights_verdict_never": "Aldri gjort",
"panel.insights_verdict_stalling": "Stoppar opp",
"panel.insights_verdict_struggling": "Ujamn",
"panel.insights_verdict_fine": "Greitt",
"panel.insights_verdict_unknown": "Uklart",
"panel.insights_suggest_retire": "Fjern henne",
"panel.insights_suggest_reprice": "Auk poenga",
"panel.insights_suggest_reassign": "Gje henne til nokon andre",
"panel.insights_suggest_keep": "La henne vere",
"panel.insights_outstanding_misses": "Uavklarte gløymde pliktoppgåver",
"panel.insights_friction_note": "Avvisingar er ikkje lista: TaskMate slettar ei fullføring når du avviser henne, så det finst ingen historikk. Venta tal er omtrentlege — rotasjon, avhengnader og ver vert ikkje spelte av på nytt.",
"panel.insights_view_projection": "Veka som kjem",
"panel.insights_projection_title": "Kva står for tur?",
"panel.insights_projection_intro": "{start} til {end}, basert på timeplanen til kvar oppgåve.",
"panel.insights_next_days": "Neste {days} dagar",
"panel.insights_col_day": "Dag",
"panel.insights_projected_earn": "opptil {points} {name}",
"panel.insights_projected_total": "ville nå {total}",
"panel.insights_projection_unassigned": "{points} poeng er ikkje tildelte nokon.",
"panel.insights_projection_note": "Eit tak, ikkje ein prognose: oppgåver opne for alle vert talde for kvart aktuelt barn, sidan timeplanen ikkje kan vite kven som rekk først. Ver, avhengnader og tilgjenge vert ikkje framskrivne — dei kjem an på dagen.",
"panel.insights_view_health": "Tilstand",
"panel.health_title": "Er noko øydelagt?",
"panel.health_recheck": "Sjekk igjen",
"panel.health_all_good": "Fann ingenting gale.",
"panel.health_issues": "{count} ting treng merksemd.",
"panel.health_sev_error": "Feil",
"panel.health_sev_warning": "Åtvaring",
"panel.health_sev_info": "Merk",
"panel.health_goto": "Vis meg",
"panel.health_count_children": "Born",
"panel.health_count_chores": "Oppgåver",
"panel.health_count_enabled_chores": "Aktive",
"panel.health_count_rewards": "Belønningar",
"panel.health_count_completions": "Fullføringar",
"panel.health_count_badges": "Merke",
"panel.health_count_scheduled_changes": "Planlagde",
"panel.health_count_active_unlocks": "Opplåsingar",
"panel.health_count_mandatory_misses": "Gløymt",
"panel.health_count_storage": "Lagring",
"panel.chore_icon_label": "Bilete",
"child.editor.pre_reader": "Berre bilete (for små born)",
"child.editor.pre_reader_labels": "Vis namn i biletemodus",
"common.design.accessible": "Tilgjengeleg"
}
@@ -1538,5 +1538,179 @@
"panel.family_goal_reward": "Recompensa",
"panel.family_goal_reward_ph": "uma noite de cinema em família",
"notification.family_goal_reached.name": "Meta da família alcançada",
"notification.family_goal_reached.description": "Comemora quando os pontos combinados da família atingem a meta compartilhada. Desativado por padrão."
"notification.family_goal_reached.description": "Comemora quando os pontos combinados da família atingem a meta compartilhada. Desativado por padrão.",
"panel.chore_advanced_weather": "Avançado — condições do tempo",
"panel.chore_weather_intro": "Ocultar esta tarefa enquanto o tempo não estiver adequado. Útil para trabalhos ao ar livre — uma tarefa cancelada pela chuva não conta como falha obrigatória nem quebra uma sequência.",
"panel.chore_weather_entity_label": "Entidade de clima",
"panel.chore_weather_entity_hint": "Deixe vazio para ignorar o tempo completamente.",
"panel.chore_weather_conditions_label": "Ocultar quando o tempo estiver",
"panel.chore_weather_conditions_hint": "Escolha as condições que devem ocultar a tarefa.",
"panel.chore_weather_temp_min_label": "Temperatura mínima",
"panel.chore_weather_temp_min_hint": "Ocultar abaixo disso. Deixe vazio para não ter limite.",
"panel.chore_weather_temp_max_label": "Temperatura máxima",
"panel.chore_weather_temp_max_hint": "Ocultar acima disso. Deixe vazio para não ter limite.",
"panel.chore_weather_wind_max_label": "Velocidade máxima do vento",
"panel.chore_weather_wind_max_hint": "Ocultar acima disso. Deixe vazio para não ter limite.",
"panel.chore_weather_failopen_hint": "Os limites usam as unidades informadas pela sua entidade de clima. Se a entidade estiver ausente ou indisponível, a tarefa continua visível.",
"weather.blocked_condition": "Oculta — tempo inadequado",
"weather.blocked_temp_low": "Oculta — frio demais",
"weather.blocked_temp_high": "Oculta — calor demais",
"weather.blocked_wind": "Oculta — vento demais",
"weather.condition_rainy": "Chuvoso",
"weather.condition_pouring": "Chuva forte",
"weather.condition_snowy": "Neve",
"weather.condition_snowy_rainy": "Chuva com neve",
"weather.condition_hail": "Granizo",
"weather.condition_lightning": "Relâmpagos",
"weather.condition_lightning_rainy": "Tempestade",
"weather.condition_fog": "Neblina",
"weather.condition_windy": "Ventando",
"weather.condition_windy_variant": "Muito vento",
"weather.condition_cloudy": "Nublado",
"weather.condition_exceptional": "Tempo severo",
"child.deadline_minutes": "faltam {mins} min",
"child.deadline_hours": "faltam {hours}h {mins}m",
"child.deadline_title": "Faça isso antes de o tempo acabar",
"panel.chore_advanced_scheduled": "Avançado — alterações agendadas",
"panel.chore_scheduled_intro": "Agende uma alteração para uma data futura — \"a partir de 1º de setembro vale 20 pontos\". Aplicada à meia-noite; se o Home Assistant estiver desligado, é recuperada na próxima inicialização.",
"panel.chore_scheduled_none": "Nenhuma alteração agendada.",
"panel.chore_scheduled_history": "Já aplicadas",
"panel.chore_scheduled_applied": "Aplicada",
"panel.sched_date_label": "Aplicar em",
"panel.sched_field_label": "Alteração",
"panel.sched_value_label": "Novo valor",
"panel.sched_note_label": "Observação (opcional)",
"panel.sched_note_hint": "Um lembrete do motivo, mostrado ao lado da alteração agendada.",
"panel.btn_add_scheduled": "Agendar alteração",
"panel.sched_value_yes": "Sim",
"panel.sched_value_no": "Não",
"panel.sched_value_nobody": "Ninguém",
"panel.toast_sched_added": "Alteração agendada",
"panel.toast_sched_removed": "Alteração removida",
"panel.toast_sched_date_required": "Escolha uma data para a alteração",
"panel.toast_sched_value_required": "Digite um valor para a alteração",
"panel.sched_field_points": "Pontos",
"panel.sched_field_assigned_to": "Atribuída a",
"panel.sched_field_enabled": "Ativada",
"panel.sched_field_requires_approval": "Requer aprovação",
"panel.sched_field_daily_limit": "Limite diário",
"panel.sched_field_due_days": "Dias",
"panel.sched_field_mandatory": "Obrigatória",
"panel.sched_field_mandatory_penalty_points": "Pontos de penalidade",
"panel.sched_field_expires_on": "Expira em",
"panel.sched_field_description": "Descrição",
"panel.sched_field_time_category": "Período do dia",
"panel.sched_field_difficulty": "Dificuldade",
"routine.title": "Rotina de {name}",
"routine.no_child": "Escolha uma criança para este card.",
"routine.task_of": "Tarefa {current} de {total}",
"routine.all_done_count": "Todas as {total} feitas",
"routine.done": "Feito ✓",
"routine.completed": "Concluída",
"routine.sent_for_checking": "Enviado para verificação",
"routine.skip": "Mais tarde",
"routine.next": "Próxima →",
"routine.back": "← Voltar",
"routine.waiting_for_parent": "⏳ Aguardando a verificação de um adulto",
"routine.photo_note": "Esta precisa de uma foto — toque nela no seu card.",
"routine.finished_title": "Rotina concluída!",
"routine.finished_body": "Boa, {name}. É tudo.",
"routine.earned": "+{points} ganhos",
"routine.awaiting_points": "+{points} assim que um adulto verificar",
"routine.skipped_note": "{count} deixada(s) para depois.",
"routine.start_again": "Recomeçar",
"routine.empty_title": "Nada a fazer",
"routine.empty_body": "Esta rotina já está concluída.",
"child.roulette_spin": "Girar para uma tarefa ×{multiplier}",
"child.roulette_respin": "Girar de novo ({count} restante)",
"child.roulette_no_spins": "Sem giros hoje",
"panel.settings_roulette_section": "Roleta de tarefas",
"panel.settings_roulette_enabled": "Ativar roleta de tarefas",
"panel.settings_roulette_enabled_hint": "Permite que uma criança gire por uma tarefa pendente aleatória que vale pontos extras.",
"panel.settings_roulette_multiplier": "Multiplicador de pontos",
"panel.settings_roulette_spins": "Giros por dia",
"panel.settings_unlock_allowlist": "Lista de permissões de desbloqueio",
"panel.settings_unlock_allowlist_hint": "Só estas entidades podem ser desbloqueadas por uma recompensa. Nada é permitido até você adicionar algo aqui.",
"panel.settings_unlock_allowlist_empty": "Nada permitido ainda.",
"panel.settings_unlock_allowlist_add": "Procurar uma entidade para permitir",
"panel.reward_unlock_section": "Avançado — desbloqueio temporário",
"panel.reward_unlock_intro": "Liga algo quando esta recompensa for aprovada e desliga de novo após um tempo definido.",
"panel.reward_unlock_none": "Nada",
"panel.reward_unlock_entity": "Desbloquear",
"panel.reward_unlock_minutes": "Por quanto tempo (minutos)",
"panel.reward_unlock_minutes_hint": "0 = deixar ligado; você terá de desligar.",
"panel.reward_unlock_no_allowlist": "Adicione primeiro uma entidade à lista de permissões nas Configurações.",
"panel.tab_insights": "Análises",
"panel.insights_loading": "Calculando…",
"panel.insights_fairness_title": "Quem está fazendo o trabalho?",
"panel.insights_fairness_intro": "{start} a {end}. Uma divisão igual seria {fair}% para cada.",
"panel.insights_last_days": "Últimos {days} dias",
"panel.insights_no_data": "Ainda sem tarefas aprovadas neste período.",
"panel.insights_balanced": "Parece bem equilibrado.",
"panel.insights_unbalanced": "A carga está desequilibrada.",
"panel.insights_chores": "tarefas",
"panel.insights_status_over": "Faz mais",
"panel.insights_status_under": "Faz menos",
"panel.insights_status_balanced": "Equilibrado",
"panel.insights_status_idle": "Ainda nada",
"panel.insights_fairness_note": "Avaliado pelo número de tarefas, não por pontos, para que uma tarefa mais cara não esconda uma divisão desigual. Sinalizado quando uma criança está a mais de {tolerance} pontos de uma parte igual.",
"panel.insights_chore": "tarefa",
"panel.insights_view_fairness": "Equidade",
"panel.insights_view_friction": "Atrito",
"panel.insights_friction_title": "O que não está funcionando?",
"panel.insights_friction_intro": "{start} a {end}. As tarefas são avaliadas pela frequência com que são feitas em relação à frequência com que surgiram.",
"panel.insights_friction_none": "Sem tarefas ativas para analisar.",
"panel.insights_friction_healthy": "Está tudo sendo feito.",
"panel.insights_friction_problems": "{count} tarefa(s) merecendo atenção.",
"panel.insights_col_chore": "Tarefa",
"panel.insights_col_done": "Feitas",
"panel.insights_col_rate": "Taxa",
"panel.insights_col_last": "Última vez",
"panel.insights_col_verdict": "Veredito",
"panel.insights_col_suggestion": "Tente",
"panel.insights_never": "Nunca",
"panel.insights_days_ago": "há {days}d",
"panel.insights_verdict_never": "Nunca feita",
"panel.insights_verdict_stalling": "Parada",
"panel.insights_verdict_struggling": "Irregular",
"panel.insights_verdict_fine": "Bem",
"panel.insights_verdict_unknown": "Incerto",
"panel.insights_suggest_retire": "Remover",
"panel.insights_suggest_reprice": "Aumentar os pontos",
"panel.insights_suggest_reassign": "Dar para outra pessoa",
"panel.insights_suggest_keep": "Deixar como está",
"panel.insights_outstanding_misses": "Falhas obrigatórias pendentes",
"panel.insights_friction_note": "As rejeições não são listadas: o TaskMate apaga a conclusão quando você a rejeita, então não há histórico. Os valores esperados são aproximados — rotação, dependências e clima não são reproduzidos.",
"panel.insights_view_projection": "Semana seguinte",
"panel.insights_projection_title": "O que vem por aí?",
"panel.insights_projection_intro": "{start} a {end}, com base na programação de cada tarefa.",
"panel.insights_next_days": "Próximos {days} dias",
"panel.insights_col_day": "Dia",
"panel.insights_projected_earn": "até {points} {name}",
"panel.insights_projected_total": "chegaria a {total}",
"panel.insights_projection_unassigned": "{points} pontos não estão atribuídos a ninguém.",
"panel.insights_projection_note": "Um teto, não uma previsão: tarefas abertas a todos são contadas para cada criança elegível, já que a programação não sabe quem chega primeiro. Clima, dependências e disponibilidade não são projetados — dependem do dia.",
"panel.insights_view_health": "Saúde",
"panel.health_title": "Tem algo quebrado?",
"panel.health_recheck": "Verificar de novo",
"panel.health_all_good": "Nada de errado.",
"panel.health_issues": "{count} coisa(s) precisando de atenção.",
"panel.health_sev_error": "Erro",
"panel.health_sev_warning": "Aviso",
"panel.health_sev_info": "Nota",
"panel.health_goto": "Mostrar",
"panel.health_count_children": "Crianças",
"panel.health_count_chores": "Tarefas",
"panel.health_count_enabled_chores": "Ativas",
"panel.health_count_rewards": "Recompensas",
"panel.health_count_completions": "Conclusões",
"panel.health_count_badges": "Emblemas",
"panel.health_count_scheduled_changes": "Agendadas",
"panel.health_count_active_unlocks": "Desbloqueios",
"panel.health_count_mandatory_misses": "Falhas",
"panel.health_count_storage": "Armazenamento",
"panel.chore_icon_label": "Imagem",
"child.editor.pre_reader": "Modo só imagens (crianças pequenas)",
"child.editor.pre_reader_labels": "Mostrar nomes no modo imagens",
"common.design.accessible": "Acessível"
}
+175 -1
View File
@@ -1538,5 +1538,179 @@
"panel.family_goal_reward": "Recompensa",
"panel.family_goal_reward_ph": "uma noite de cinema em família",
"notification.family_goal_reached.name": "Objetivo da família alcançado",
"notification.family_goal_reached.description": "Comemora quando os pontos combinados da família atingem o objetivo partilhado. Desativado por predefinição."
"notification.family_goal_reached.description": "Comemora quando os pontos combinados da família atingem o objetivo partilhado. Desativado por predefinição.",
"panel.chore_advanced_weather": "Avançado — condições meteorológicas",
"panel.chore_weather_intro": "Ocultar esta tarefa enquanto o tempo não for adequado. Útil para trabalhos no exterior — uma tarefa cancelada pela chuva não conta como falha obrigatória nem quebra uma sequência.",
"panel.chore_weather_entity_label": "Entidade meteorológica",
"panel.chore_weather_entity_hint": "Deixe vazio para ignorar completamente o tempo.",
"panel.chore_weather_conditions_label": "Ocultar quando o tempo estiver",
"panel.chore_weather_conditions_hint": "Escolha as condições que devem ocultar a tarefa.",
"panel.chore_weather_temp_min_label": "Temperatura mínima",
"panel.chore_weather_temp_min_hint": "Ocultar abaixo disto. Deixe vazio para não ter limite.",
"panel.chore_weather_temp_max_label": "Temperatura máxima",
"panel.chore_weather_temp_max_hint": "Ocultar acima disto. Deixe vazio para não ter limite.",
"panel.chore_weather_wind_max_label": "Velocidade máxima do vento",
"panel.chore_weather_wind_max_hint": "Ocultar acima disto. Deixe vazio para não ter limite.",
"panel.chore_weather_failopen_hint": "Os limites usam as unidades comunicadas pela sua entidade meteorológica. Se a entidade estiver em falta ou indisponível, a tarefa permanece visível.",
"weather.blocked_condition": "Oculta — tempo inadequado",
"weather.blocked_temp_low": "Oculta — demasiado frio",
"weather.blocked_temp_high": "Oculta — demasiado calor",
"weather.blocked_wind": "Oculta — demasiado vento",
"weather.condition_rainy": "Chuvoso",
"weather.condition_pouring": "Chuva forte",
"weather.condition_snowy": "Neve",
"weather.condition_snowy_rainy": "Chuva com neve",
"weather.condition_hail": "Granizo",
"weather.condition_lightning": "Relâmpagos",
"weather.condition_lightning_rainy": "Trovoada",
"weather.condition_fog": "Nevoeiro",
"weather.condition_windy": "Ventoso",
"weather.condition_windy_variant": "Muito ventoso",
"weather.condition_cloudy": "Nublado",
"weather.condition_exceptional": "Tempo severo",
"child.deadline_minutes": "faltam {mins} min",
"child.deadline_hours": "faltam {hours}h {mins}m",
"child.deadline_title": "Faz isto antes de o tempo acabar",
"panel.chore_advanced_scheduled": "Avançado — alterações agendadas",
"panel.chore_scheduled_intro": "Agende uma alteração para uma data futura — «a partir de 1 de setembro vale 20 pontos». Aplicada à meia-noite; se o Home Assistant estiver desligado, é recuperada no arranque seguinte.",
"panel.chore_scheduled_none": "Sem alterações agendadas.",
"panel.chore_scheduled_history": "Já aplicadas",
"panel.chore_scheduled_applied": "Aplicada",
"panel.sched_date_label": "Aplicar em",
"panel.sched_field_label": "Alteração",
"panel.sched_value_label": "Novo valor",
"panel.sched_note_label": "Nota (opcional)",
"panel.sched_note_hint": "Um lembrete do porquê, mostrado junto à alteração agendada.",
"panel.btn_add_scheduled": "Agendar alteração",
"panel.sched_value_yes": "Sim",
"panel.sched_value_no": "Não",
"panel.sched_value_nobody": "Ninguém",
"panel.toast_sched_added": "Alteração agendada",
"panel.toast_sched_removed": "Alteração removida",
"panel.toast_sched_date_required": "Escolha uma data para a alteração",
"panel.toast_sched_value_required": "Introduza um valor para a alteração",
"panel.sched_field_points": "Pontos",
"panel.sched_field_assigned_to": "Atribuída a",
"panel.sched_field_enabled": "Ativada",
"panel.sched_field_requires_approval": "Requer aprovação",
"panel.sched_field_daily_limit": "Limite diário",
"panel.sched_field_due_days": "Dias",
"panel.sched_field_mandatory": "Obrigatória",
"panel.sched_field_mandatory_penalty_points": "Pontos de penalização",
"panel.sched_field_expires_on": "Expira em",
"panel.sched_field_description": "Descrição",
"panel.sched_field_time_category": "Altura do dia",
"panel.sched_field_difficulty": "Dificuldade",
"routine.title": "Rotina de {name}",
"routine.no_child": "Escolhe uma criança para este cartão.",
"routine.task_of": "Tarefa {current} de {total}",
"routine.all_done_count": "Todas as {total} feitas",
"routine.done": "Feito ✓",
"routine.completed": "Concluída",
"routine.sent_for_checking": "Enviado para verificação",
"routine.skip": "Mais tarde",
"routine.next": "Seguinte →",
"routine.back": "← Voltar",
"routine.waiting_for_parent": "⏳ À espera da verificação de um adulto",
"routine.photo_note": "Esta precisa de uma foto — toca nela no teu cartão.",
"routine.finished_title": "Rotina concluída!",
"routine.finished_body": "Boa, {name}. É tudo.",
"routine.earned": "+{points} ganhos",
"routine.awaiting_points": "+{points} assim que um adulto verificar",
"routine.skipped_note": "{count} deixada(s) para depois.",
"routine.start_again": "Recomeçar",
"routine.empty_title": "Nada a fazer",
"routine.empty_body": "Esta rotina já está concluída.",
"child.roulette_spin": "Girar para uma tarefa ×{multiplier}",
"child.roulette_respin": "Girar de novo ({count} restante)",
"child.roulette_no_spins": "Sem giros hoje",
"panel.settings_roulette_section": "Roleta de tarefas",
"panel.settings_roulette_enabled": "Ativar roleta de tarefas",
"panel.settings_roulette_enabled_hint": "Permite a uma criança girar por uma tarefa pendente aleatória que vale pontos extra.",
"panel.settings_roulette_multiplier": "Multiplicador de pontos",
"panel.settings_roulette_spins": "Giros por dia",
"panel.settings_unlock_allowlist": "Lista de permissões de desbloqueio",
"panel.settings_unlock_allowlist_hint": "Só estas entidades podem ser desbloqueadas por uma recompensa. Nada é permitido até adicionares algo aqui.",
"panel.settings_unlock_allowlist_empty": "Nada permitido ainda.",
"panel.settings_unlock_allowlist_add": "Procurar uma entidade para permitir",
"panel.reward_unlock_section": "Avançado — desbloqueio temporário",
"panel.reward_unlock_intro": "Liga algo quando esta recompensa for aprovada e desliga de novo após um tempo definido.",
"panel.reward_unlock_none": "Nada",
"panel.reward_unlock_entity": "Desbloquear",
"panel.reward_unlock_minutes": "Durante quanto tempo (minutos)",
"panel.reward_unlock_minutes_hint": "0 = deixar ligado; terás de desligar tu.",
"panel.reward_unlock_no_allowlist": "Adiciona primeiro uma entidade à lista de permissões nas Definições.",
"panel.tab_insights": "Análises",
"panel.insights_loading": "A calcular…",
"panel.insights_fairness_title": "Quem está a fazer o trabalho?",
"panel.insights_fairness_intro": "{start} a {end}. Uma divisão igual seria {fair}% para cada.",
"panel.insights_last_days": "Últimos {days} dias",
"panel.insights_no_data": "Ainda sem tarefas aprovadas neste período.",
"panel.insights_balanced": "Parece bastante equilibrado.",
"panel.insights_unbalanced": "A carga está desequilibrada.",
"panel.insights_chores": "tarefas",
"panel.insights_status_over": "Faz mais",
"panel.insights_status_under": "Faz menos",
"panel.insights_status_balanced": "Equilibrado",
"panel.insights_status_idle": "Ainda nada",
"panel.insights_fairness_note": "Avaliado pelo número de tarefas, não por pontos, para que uma tarefa mais cara não esconda uma divisão desigual. Assinalado quando uma criança está a mais de {tolerance} pontos de uma parte igual.",
"panel.insights_chore": "tarefa",
"panel.insights_view_fairness": "Equidade",
"panel.insights_view_friction": "Atrito",
"panel.insights_friction_title": "O que não está a funcionar?",
"panel.insights_friction_intro": "{start} a {end}. As tarefas são avaliadas pela frequência com que são feitas face à frequência com que surgiram.",
"panel.insights_friction_none": "Sem tarefas ativas para analisar.",
"panel.insights_friction_healthy": "Está tudo a ser feito.",
"panel.insights_friction_problems": "{count} tarefa(s) a merecer atenção.",
"panel.insights_col_chore": "Tarefa",
"panel.insights_col_done": "Feitas",
"panel.insights_col_rate": "Taxa",
"panel.insights_col_last": "Última vez",
"panel.insights_col_verdict": "Veredito",
"panel.insights_col_suggestion": "Tenta",
"panel.insights_never": "Nunca",
"panel.insights_days_ago": "há {days}d",
"panel.insights_verdict_never": "Nunca feita",
"panel.insights_verdict_stalling": "Parada",
"panel.insights_verdict_struggling": "Irregular",
"panel.insights_verdict_fine": "Bem",
"panel.insights_verdict_unknown": "Incerto",
"panel.insights_suggest_retire": "Remover",
"panel.insights_suggest_reprice": "Aumentar os pontos",
"panel.insights_suggest_reassign": "Dar a outra pessoa",
"panel.insights_suggest_keep": "Deixar como está",
"panel.insights_outstanding_misses": "Falhas obrigatórias por resolver",
"panel.insights_friction_note": "As rejeições não são listadas: o TaskMate apaga a conclusão quando a rejeitas, por isso não há histórico. Os valores esperados são aproximados — rotação, dependências e meteorologia não são reproduzidas.",
"panel.insights_view_projection": "Semana seguinte",
"panel.insights_projection_title": "O que vem aí?",
"panel.insights_projection_intro": "{start} a {end}, com base no horário de cada tarefa.",
"panel.insights_next_days": "Próximos {days} dias",
"panel.insights_col_day": "Dia",
"panel.insights_projected_earn": "até {points} {name}",
"panel.insights_projected_total": "chegaria a {total}",
"panel.insights_projection_unassigned": "{points} pontos não estão atribuídos a ninguém.",
"panel.insights_projection_note": "Um teto, não uma previsão: tarefas abertas a todos são contadas para cada criança elegível, já que o horário não sabe quem chega primeiro. Meteorologia, dependências e disponibilidade não são projetadas — dependem do dia.",
"panel.insights_view_health": "Estado",
"panel.health_title": "Está algo avariado?",
"panel.health_recheck": "Verificar de novo",
"panel.health_all_good": "Nada de errado.",
"panel.health_issues": "{count} coisa(s) a precisar de atenção.",
"panel.health_sev_error": "Erro",
"panel.health_sev_warning": "Aviso",
"panel.health_sev_info": "Nota",
"panel.health_goto": "Mostrar",
"panel.health_count_children": "Crianças",
"panel.health_count_chores": "Tarefas",
"panel.health_count_enabled_chores": "Ativas",
"panel.health_count_rewards": "Recompensas",
"panel.health_count_completions": "Conclusões",
"panel.health_count_badges": "Emblemas",
"panel.health_count_scheduled_changes": "Agendadas",
"panel.health_count_active_unlocks": "Desbloqueios",
"panel.health_count_mandatory_misses": "Falhas",
"panel.health_count_storage": "Armazenamento",
"panel.chore_icon_label": "Imagem",
"child.editor.pre_reader": "Modo só imagens (crianças pequenas)",
"child.editor.pre_reader_labels": "Mostrar nomes no modo imagens",
"common.design.accessible": "Acessível"
}
@@ -91,9 +91,17 @@ class TaskMateChildCard extends LitElement {
|| this.hass?.states?.[this.config?.entity]?.attributes || {};
const sessions = attrs.active_timed_sessions || [];
const hasRunning = sessions.some(s => s.state === 'running' && s.child_id === this.config?.child_id);
if (hasRunning && !this._timerInterval) {
// A reactive chore's countdown (#674) has to keep ticking too, otherwise it
// sits on a stale minute until the next coordinator push.
const now = Date.now();
const hasDeadline = (attrs.chores || []).some(c => {
if (!c.deadline_at) return false;
const at = new Date(c.deadline_at).getTime();
return !Number.isNaN(at) && at > now;
});
if ((hasRunning || hasDeadline) && !this._timerInterval) {
this._timerInterval = setInterval(() => { this._timerTick++; this.requestUpdate(); }, 1000);
} else if (!hasRunning && this._timerInterval) {
} else if (!hasRunning && !hasDeadline && this._timerInterval) {
this._stopTimerTick();
}
}
@@ -600,6 +608,23 @@ class TaskMateChildCard extends LitElement {
width: 18px; height: 18px; border-radius: 50%;
background: rgba(0,0,0,0.45); display: flex; align-items: center; justify-content: center;
}
/* Designed-header avatar wrapper: mirror of the classic avatar-container
so the picker works under every design, not just classic. The picker
itself (.avatar-picker) is absolute, so the header is the positioned
anchor. Base styles here win over the shared kit's .tmd-hd because the
card includes them after the kit in static styles(). */
.tmd-hd { position: relative; }
.tmd-av-wrap { position: relative; display: inline-flex; align-items: center; flex: none; }
.tmd-av-wrap.avatar-clickable { cursor: pointer; }
.tmd-av-edit {
position: absolute; bottom: -2px; right: -2px;
width: 15px; height: 15px; border-radius: 50%;
background: var(--tmd-accent, #7c3aed); color: #fff;
display: grid; place-items: center;
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
}
.tmd-av-edit ha-icon { --mdc-icon-size: 10px; color: #fff; }
.avatar-edit-dot ha-icon { --mdc-icon-size: 12px; color: white; }
.avatar-picker {
position: absolute; z-index: 20; margin-top: 56px;
@@ -1185,6 +1210,121 @@ class TaskMateChildCard extends LitElement {
width: 6px;
background: var(--fun-red);
}
/* Pre-reader mode (#683) — picture-only, huge targets */
.pre-reader-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(128px, 1fr));
gap: 14px;
padding: 4px 0 8px;
}
.pre-tile {
position: relative;
display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 8px;
min-height: 140px; padding: 16px 10px;
border: 3px solid var(--divider-color, #e0e0e0);
border-radius: 26px;
background: var(--card-background-color, #fff);
font-family: inherit; cursor: pointer;
transition: transform 0.12s ease, border-color 0.12s ease;
}
.pre-tile:active { transform: scale(0.96); }
.pre-tile:focus-visible { outline: 4px solid var(--primary-color); outline-offset: 3px; }
.pre-tile-icon ha-icon { --mdc-icon-size: 64px; color: var(--primary-color); }
.pre-tile-stars { display: flex; gap: 2px; }
.pre-tile-stars ha-icon { --mdc-icon-size: 18px; color: #f1c40f; }
.pre-tile-label {
font-size: 0.85rem; font-weight: 700; text-align: center;
color: var(--secondary-text-color); line-height: 1.2;
}
.pre-tile.done {
border-color: var(--success-color, #2ecc71);
background: rgba(46, 204, 113, 0.10);
}
.pre-tile.done .pre-tile-icon ha-icon { opacity: 0.35; }
.pre-tile.done .pre-tile-stars { opacity: 0.35; }
.pre-tile-tick {
position: absolute; inset: 0;
display: grid; place-items: center;
pointer-events: none;
}
.pre-tile-tick ha-icon {
--mdc-icon-size: 72px;
color: var(--success-color, #2ecc71);
}
.pre-tile.locked { opacity: 0.4; cursor: default; }
.pre-tile.loading { opacity: 0.6; }
/* Chore roulette (#677) */
.roulette {
display: flex; flex-direction: column; gap: 8px;
margin: 4px 0 12px;
}
/* The --tmd-* tokens only resolve under a designed style; the fallbacks
are the original classic values, so classic is unchanged. */
.roulette-btn {
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
background: var(--tmd-accent, linear-gradient(135deg, #8e44ad, #c0392b));
color: #fff; border: 0;
border-radius: var(--tmd-radius-sm, 14px);
padding: 12px 16px;
font-family: var(--tmd-font-display, inherit); font-size: 0.95rem;
font-weight: 800; cursor: pointer; width: 100%;
}
.roulette-btn:disabled { opacity: 0.55; cursor: default; }
.roulette-btn.is-spinning ha-icon { animation: tm-roulette-spin 0.6s linear infinite; }
@keyframes tm-roulette-spin { to { transform: rotate(360deg); } }
.roulette-result {
display: flex; align-items: center; gap: 8px;
background: color-mix(in srgb, var(--tmd-accent, #8e44ad) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--tmd-accent, #8e44ad) 35%, transparent);
border-radius: var(--tmd-radius-sm, 12px); padding: 8px 12px;
}
.roulette-mult {
background: var(--tmd-accent, #8e44ad); color: #fff;
border-radius: var(--tmd-radius-sm, 8px); padding: 2px 8px;
font-weight: 900; font-size: 0.85rem;
}
.roulette-name { flex: 1; font-weight: 700; font-size: 0.95rem; }
.roulette-worth {
display: inline-flex; align-items: center; gap: 4px;
font-weight: 800; color: var(--tmd-accent, #8e44ad);
}
.roulette-worth ha-icon { --mdc-icon-size: 16px; }
.roulette-spent {
text-align: center; font-size: 0.8rem;
color: var(--tmd-dim, var(--secondary-text-color)); opacity: 0.8;
}
/* Reactive-chore countdown (#674). Amber by default, red under 5 min.
On its own line, not inline in .chore-name — that name ellipsises, and
a long one clipped the badge down to just the bonus. */
.deadline-badge {
display: inline-flex;
align-items: center;
gap: 4px;
margin-top: 4px;
align-self: flex-start;
background: rgba(255, 152, 0, 0.18);
color: #ef6c00;
font-size: 0.62rem;
font-weight: 800;
letter-spacing: 0.02em;
text-transform: uppercase;
border-radius: 8px;
padding: 2px 7px;
white-space: nowrap;
width: fit-content;
}
.deadline-badge.deadline-urgent {
background: var(--fun-red);
color: #fff;
}
.deadline-bonus {
opacity: 0.85;
font-weight: 900;
}
.mandatory-badge {
display: inline-flex;
align-items: center;
@@ -2091,7 +2231,13 @@ class TaskMateChildCard extends LitElement {
` : '';
})() : ''}
</div>
${childChores.map((chore, index) => html`
${this._renderRoulette(child, childChores, pointsIcon)}
${this.config.pre_reader === true ? html`
<div class="pre-reader-grid">
${childChores.map((chore, index) =>
this._renderPreReaderTile(chore, child, todaysCompletions, index))}
</div>
` : childChores.map((chore, index) => html`
${chore.task_type === 'timed'
? this._renderTimedChoreCard(chore, child, pointsIcon, todaysCompletions, index)
: html`
@@ -2292,10 +2438,20 @@ class TaskMateChildCard extends LitElement {
<ha-icon icon="mdi:clock-outline"></ha-icon>${countdown.label}</span>` : ""}
</div>`;
const body =
design === "playroom" ? this._designPlayroom(child, rows, remaining, tone) :
design === "console" ? this._designConsole(child, rows, remaining, tone) :
this._designCleanpro(child, rows, remaining, tone);
// Pre-reader mode replaces the chore list with picture tiles. It has to be
// handled here as well as in the classic path, or `pre_reader: true` is
// silently ignored under every designed style — including accessible,
// which is the one a child who needs picture tiles is most likely on.
// The tiles keep the designed shell (header, tokens) around them.
const body = this.config.pre_reader === true
? html`
<div class="pre-reader-grid">
${childChores.map((chore, index) =>
this._renderPreReaderTile(chore, child, todaysCompletions, index))}
</div>`
: design === "playroom" ? this._designPlayroom(child, rows, remaining, tone) :
design === "console" ? this._designConsole(child, rows, remaining, tone) :
this._designCleanpro(child, rows, remaining, tone);
return html`<ha-card class="tmd" style="--hd:${hd}">
${this._designHeaderFull(child, design, remaining, rows.length, tone, pendingPoints, pointsIcon)}
@@ -2316,6 +2472,7 @@ class TaskMateChildCard extends LitElement {
${earnedBadges.length > 5 ? html`<span class="more">+${earnedBadges.length - 5} →</span>` : ""}
</div>` : ""}
${sectionLine}
${this._renderRoulette(child, childChores, pointsIcon)}
${body}
${this._renderSwappable(allChores, child, pointsIcon)}
</div>
@@ -2336,13 +2493,25 @@ class TaskMateChildCard extends LitElement {
? (child.level ? `${this._t("child.level_label", { level: child.level })} · ${remaining} ACTIVE` : `${remaining} ACTIVE`)
: design === "cleanpro" ? `${remaining} / ${total}`
: `${remaining} · ${this._t("child.todays_chores")}`;
// Avatar picker: classic makes the avatar tappable; the designed header
// must too, or the picker only ever works on classic. Same eligibility
// rule (allow_avatar_change + more than one unlocked option).
const opts = child.avatar_options || [];
const canChange = this.config.allow_avatar_change !== false
&& opts.filter(o => o.unlocked).length > 1;
return html`
<div class="tmd-hd">
${this._av(child, tone, 34)}
<div class="tmd-av-wrap ${canChange ? "avatar-clickable" : ""}"
@click=${canChange ? () => this._toggleAvatarPicker() : null}
title="${canChange ? this._t("child.avatar_change") : ""}">
${this._av(child, tone, 34)}
${canChange ? html`<span class="tmd-av-edit"><ha-icon icon="mdi:pencil"></ha-icon></span>` : ""}
</div>
<span class="tt">${title}<small>${sub}</small></span>
${remaining === 0 && total > 0 ? html`<span class="pill">🎉</span>` : ""}
${pendingPoints > 0 ? html`<span class="tmd-pending">
<ha-icon icon="mdi:timer-sand"></ha-icon>+${pendingPoints}</span>` : ""}
${canChange && this._avatarPickerOpen ? this._renderAvatarPicker(child, opts) : ""}
</div>`;
}
@@ -2982,6 +3151,144 @@ class TaskMateChildCard extends LitElement {
`;
}
/**
* Countdown badge for a reactive chore (#674) — "empty the washing machine
* within 30 minutes". Reads the deadline fresh on every render, so it ticks
* down with the coordinator's updates. Hidden once the chore is done.
*/
_renderDeadlineBadge(chore, isCompletedForToday) {
if (!chore.deadline_at || isCompletedForToday) return '';
const deadline = new Date(chore.deadline_at);
if (Number.isNaN(deadline.getTime())) return '';
const msLeft = deadline.getTime() - Date.now();
if (msLeft <= 0) return '';
const minsLeft = Math.ceil(msLeft / 60000);
const label = minsLeft >= 60
? this._t('child.deadline_hours', { hours: Math.floor(minsLeft / 60), mins: minsLeft % 60 })
: this._t('child.deadline_minutes', { mins: minsLeft });
// Under five minutes is the point at which it's worth shouting about.
const urgent = minsLeft <= 5;
const bonus = chore.speed_bonus_points || 0;
return html`
<span class="deadline-badge ${urgent ? 'deadline-urgent' : ''}"
title="${this._t('child.deadline_title')}">
⏱ ${label}${bonus ? html` <span class="deadline-bonus">+${bonus}</span>` : ''}
</span>
`;
}
/**
* Chore roulette (#677) — an opt-in nudge for the child who has stalled.
* Only renders when the parent enabled it in settings AND the card opts in,
* so an existing dashboard never sprouts a new button unasked.
*/
_renderRoulette(child, childChores, pointsIcon) {
if (this.config.show_roulette !== true) return '';
const roulette = child.roulette;
if (!roulette) return ''; // switched off in settings
const picked = roulette.chore_id
? childChores.find(c => String(c.id) === String(roulette.chore_id))
: null;
const spinsLeft = roulette.spins_left ?? 0;
const multiplier = roulette.multiplier || 2;
const spinning = this._spinning === child.id;
return html`
<div class="roulette">
${picked ? html`
<div class="roulette-result">
<span class="roulette-mult">×${multiplier}</span>
<span class="roulette-name">${picked.name}</span>
<span class="roulette-worth">
<ha-icon icon="${pointsIcon}"></ha-icon>
${Math.round((picked.effective_points ?? picked.points ?? 0) * multiplier)}
</span>
</div>
` : ''}
${spinsLeft > 0 ? html`
<button class="roulette-btn ${spinning ? 'is-spinning' : ''}"
?disabled=${spinning || childChores.length === 0}
@click=${() => this._spinRoulette(child)}>
<ha-icon icon="mdi:dice-multiple"></ha-icon>
${picked
? this._t('child.roulette_respin', { count: spinsLeft })
: this._t('child.roulette_spin', { multiplier })}
</button>
` : html`
<div class="roulette-spent">${this._t('child.roulette_no_spins')}</div>
`}
</div>
`;
}
async _spinRoulette(child) {
if (this._spinning) return;
this._spinning = child.id;
this.requestUpdate();
try {
await this.hass.callService('taskmate', 'spin_roulette', { child_id: child.id });
} catch (err) {
// "No spins left" / "Nothing left to spin for" arrive here as
// ServiceValidationError — show them rather than failing silently.
this.dispatchEvent(new CustomEvent('hass-notification', {
detail: { message: String(err?.message || err) }, bubbles: true, composed: true,
}));
} finally {
this._spinning = null;
this.requestUpdate();
}
}
/**
* Pre-reader mode (#683): a picture-only tile for children who can't read
* yet. No chore name, no numbers — a big icon, a row of stars for the
* points, and a huge tick when it's done.
*/
_renderPreReaderTile(chore, child, todaysCompletions = [], choreIndex = 0) {
const dailyLimit = chore.daily_limit || 1;
// Counted the same way the standard row does: pending completions hold a
// place too, and a parent completing on behalf lands under "__parent__".
const childCompletionsToday = todaysCompletions.filter(
(comp) => comp.chore_id === chore.id
&& (comp.child_id === child.id || comp.child_id === "__parent__")
&& !comp.bonus_subtask_id
);
const isDone = childCompletionsToday.length >= dailyLimit;
const isLoading = this._loading[chore.id];
const available = chore._isAvailableForChild !== false && !chore._isLockedPreview;
// Stars, not digits: a 4-year-old can count pictures, not read "+3".
const points = chore.effective_points ?? chore.points ?? 0;
const stars = Math.max(1, Math.min(5, Math.round(points / 2) || 1));
const icon = chore.icon
|| this._getTimeCategoryIcon(chore.time_category)
|| 'mdi:checkbox-marked-circle-outline';
return html`
<button
class="pre-tile ${isDone ? 'done' : ''} ${isLoading ? 'loading' : ''} ${available ? '' : 'locked'}"
?disabled=${isLoading || !available}
aria-label="${chore.name}"
title="${chore.name}"
@click=${() => (isDone
? this._handleUndo(chore, child, childCompletionsToday)
: this._handleComplete(chore, child))}
>
<div class="pre-tile-icon"><ha-icon icon="${icon}"></ha-icon></div>
${isDone ? html`<div class="pre-tile-tick"><ha-icon icon="mdi:check-bold"></ha-icon></div>` : ''}
<div class="pre-tile-stars">
${Array.from({ length: stars }, () => html`<ha-icon icon="mdi:star"></ha-icon>`)}
</div>
${this.config.pre_reader_labels === true
? html`<div class="pre-tile-label">${chore.name}</div>` : ''}
</button>
`;
}
_renderChoreCard(chore, child, pointsIcon, todaysCompletions = [], choreIndex = 0) {
const isLoading = this._loading[chore.id];
const isCelebrating = this._celebrating === chore.id;
@@ -3095,6 +3402,7 @@ class TaskMateChildCard extends LitElement {
</div>
<div class="chore-details">
<div class="chore-name">${chore.name}${chore.mandatory ? html`<span class="mandatory-badge">⚠ ${this._t('child.mandatory')}</span>` : ''}</div>
${this._renderDeadlineBadge(chore, isCompletedForToday)}
${chore.difficulty ? html`<span class="difficulty-badge difficulty-${chore.difficulty}">${this._t('child.difficulty_' + chore.difficulty) || chore.difficulty}</span>` : ''}
${this.config.show_description && chore.description ? html`
<div class="chore-description">${chore.description}</div>
@@ -4133,6 +4441,8 @@ class TaskMateChildCardEditor extends LitElement {
},
{ name: 'show_countdown', selector: { boolean: {} } },
{ name: 'show_description', selector: { boolean: {} } },
{ name: 'pre_reader', selector: { boolean: {} } },
{ name: 'pre_reader_labels', selector: { boolean: {} } },
{ name: 'debug', selector: { boolean: {} } },
];
}
@@ -4148,6 +4458,8 @@ class TaskMateChildCardEditor extends LitElement {
elapsed_time_mode: this._t('child.editor.missed_time_chores'),
show_countdown: this._t('child.editor.show_countdown'),
show_description: this._t('child.editor.show_description'),
pre_reader: this._t('child.editor.pre_reader'),
pre_reader_labels: this._t('child.editor.pre_reader_labels'),
debug: this._t('child.editor.show_debug'),
};
return labels[entry.name] ?? entry.name;
@@ -4178,6 +4490,8 @@ class TaskMateChildCardEditor extends LitElement {
elapsed_time_mode: this.config.elapsed_time_mode || 'dim',
show_countdown: this.config.show_countdown !== false,
show_description: this.config.show_description === true,
pre_reader: this.config.pre_reader === true,
pre_reader_labels: this.config.pre_reader_labels === true,
debug: this.config.debug === true,
};
@@ -12,16 +12,17 @@
* playroom — warm, rounded, picture-book
* console — dark gamified HUD
* cleanpro — calm productivity / SaaS
* accessible — high contrast, colour-blind safe, dyslexia-friendly type
*
* Mirrors window.__taskmate_localize: exposed globally, no ES module imports.
*/
(function () {
const IDS = ["classic", "playroom", "console", "cleanpro"];
const IDS = ["classic", "playroom", "console", "cleanpro", "accessible"];
// Fonts must be loaded at the document level (an @import inside a shadow
// stylesheet is honoured, but loading once globally avoids N duplicate fetches).
const FONT_IMPORT =
"@import url('https://fonts.googleapis.com/css2?family=Baloo+2:wght@500;600;700;800&family=Nunito:wght@400;600;700;800;900&family=Space+Grotesk:wght@400;500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700;800&family=Manrope:wght@500;600;700;800&display=swap');";
"@import url('https://fonts.googleapis.com/css2?family=Baloo+2:wght@500;600;700;800&family=Nunito:wght@400;600;700;800;900&family=Space+Grotesk:wght@400;500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700;800&family=Manrope:wght@500;600;700;800&family=Atkinson+Hyperlegible:wght@400;700&display=swap');";
// Design tokens. IMPORTANT: these are :host-scoped so they can be applied
// INSIDE each card's shadow root. A document-level [data-tm-design] rule
@@ -59,6 +60,22 @@
Cards stamp data-tm-dark on the host when HA is in dark mode. Each design has
a light base (above) and a dark override here, so all three follow HA's
light/dark setting. */
:host([data-tm-design="accessible"]),[data-tm-design="accessible"]{
/* Colours are the Okabe-Ito colour-blind-safe palette: distinguishable
under protanopia, deuteranopia and tritanopia. Text is near-black on
white (~19:1, comfortably past WCAG AAA) and borders are heavy enough to
carry meaning without relying on hue. Atkinson Hyperlegible is drawn for
low vision — its letterforms stay distinct where similar glyphs (I/l/1,
O/0) usually collapse. */
--tmd-bg:#FFFFFF;--tmd-surface:#FFFFFF;--tmd-surface-2:#F2F2F2;--tmd-border:#111111;
--tmd-text:#111111;--tmd-dim:#3D3D3D;
--tmd-accent:#0072B2;--tmd-accent2:#D55E00;--tmd-good:#009E73;--tmd-warn:#E69F00;--tmd-bad:#D55E00;--tmd-gold:#E69F00;
--tmd-radius:10px;--tmd-radius-sm:6px;--tmd-shadow:0 0 0 2px #111111;--tmd-hd-text:#fff;
--tmd-font-display:"Atkinson Hyperlegible",sans-serif;
--tmd-font-body:"Atkinson Hyperlegible",sans-serif;
--tmd-font-mono:"Atkinson Hyperlegible",monospace;
--tmd-c1:#0072B2;--tmd-c2:#D55E00;--tmd-c3:#009E73;--tmd-c4:#CC79A7;--tmd-c5:#56B4E9;--tmd-c6:#E69F00;
}
:host([data-tm-design="console"][data-tm-dark]),[data-tm-design="console"][data-tm-dark]{
--tmd-bg:#0E1320;--tmd-surface:#161D2E;--tmd-surface-2:#1E2740;--tmd-border:#2A3550;
--tmd-text:#EAF0FF;--tmd-dim:#8A97B8;
@@ -75,6 +92,16 @@
--tmd-bg:#0F141A;--tmd-surface:#181D25;--tmd-surface-2:#212733;--tmd-border:#2C3340;
--tmd-text:#E6EAF1;--tmd-dim:#97A1B0;--tmd-accent:#6E86F5;--tmd-accent2:#1FC7B8;
--tmd-shadow:0 1px 2px rgba(0,0,0,.4),0 8px 18px rgba(0,0,0,.35);
}
:host([data-tm-design="accessible"][data-tm-dark]),[data-tm-design="accessible"][data-tm-dark]{
/* Pure black would bloom on OLED and is harsh for astigmatism; #0A0A0A with
near-white text still clears AAA. Hues are the Okabe-Ito light variants,
which stay distinguishable against a dark ground. */
--tmd-bg:#0A0A0A;--tmd-surface:#0A0A0A;--tmd-surface-2:#1C1C1C;--tmd-border:#F5F5F5;
--tmd-text:#F5F5F5;--tmd-dim:#CFCFCF;
--tmd-accent:#56B4E9;--tmd-accent2:#E69F00;--tmd-good:#009E73;--tmd-warn:#F0E442;--tmd-bad:#E69F00;--tmd-gold:#F0E442;
--tmd-shadow:0 0 0 2px #F5F5F5;
--tmd-c1:#56B4E9;--tmd-c2:#E69F00;--tmd-c3:#009E73;--tmd-c4:#CC79A7;--tmd-c5:#F0E442;--tmd-c6:#0072B2;
}`;
// Shared component kit, themed by the tokens above. Every designed card
@@ -234,6 +261,7 @@
{ value: "playroom", label: label("playroom", "Playroom") },
{ value: "console", label: label("console", "Console") },
{ value: "cleanpro", label: label("cleanpro", "Clean Pro") },
{ value: "accessible", label: label("accessible", "Accessible") },
];
}
@@ -3,6 +3,11 @@
* Shows the shared family co-op goal (combined points across all children) with
* a progress bar toward the target and the reward. Reads the overview sensor's
* `family_goal` attribute.
*
* Single layout, so it consumes the design tokens directly (like the routine
* card) rather than carrying a second _renderDesigned() template. Every
* --tmd-* reference has the card's original value as its fallback, so classic
* — which defines no tokens — renders exactly as it did before.
*/
const LitElement = customElements.get("hui-masonry-view")
@@ -13,6 +18,7 @@ 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 DEFAULT_ACCENT = "#16a085";
class TaskMateFamilyGoalCard extends LitElement {
static get properties() {
@@ -39,18 +45,38 @@ class TaskMateFamilyGoalCard extends LitElement {
return fn ? fn(this.hass, key, params) : key;
}
/**
* Resolve the active design, stamp data-tm-design on the host (which is what
* makes the :host-scoped token block apply inside this shadow root), and set
* the accent. The accent is set as an inline host property, not from a
* <style> in the template: a shadow tree's <style> is ordered before
* adoptedStyleSheets, so it loses to the :host default in static styles().
*/
_applyDesign() {
if (window.__taskmate_design) {
window.__taskmate_design.apply(this, this.hass, this.config, this.config.entity);
}
const configured = this.config.header_color;
if (typeof configured === "string" && /^#[0-9a-fA-F]{3,8}$/.test(configured)) {
this.style.setProperty("--fg-accent", _safeColor(configured, DEFAULT_ACCENT));
} else {
this.style.removeProperty("--fg-accent");
}
}
render() {
if (!this.hass || !this.config) return html``;
this._applyDesign();
const entity = this.hass.states[this.config.entity];
if (!entity) {
return html`<ha-card><div class="empty"><ha-icon icon="mdi:alert-circle"></ha-icon><div>${this._t("common.entity_not_found", { entity: this.config.entity })}</div></div></ha-card>`;
return html`<ha-card><div class="fg-empty"><ha-icon icon="mdi:alert-circle"></ha-icon><div>${this._t("common.entity_not_found", { entity: this.config.entity })}</div></div></ha-card>`;
}
const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) || entity.attributes || {};
const goal = attrs.family_goal || {};
const pointsName = attrs.points_name || this._t("common.points");
if (!goal.enabled || !goal.target) {
return html`<ha-card><div class="empty"><ha-icon icon="mdi:flag-checkered"></ha-icon><div>${this._t("family_goal.disabled")}</div></div></ha-card>`;
return html`<ha-card><div class="fg-empty"><ha-icon icon="mdi:flag-checkered"></ha-icon><div>${this._t("family_goal.disabled")}</div></div></ha-card>`;
}
const progress = Number(goal.progress) || 0;
@@ -60,21 +86,20 @@ class TaskMateFamilyGoalCard extends LitElement {
return html`
<ha-card>
<style>:host { --tm-goal-accent: ${_safeColor(this.config.header_color, "#16a085")}; }</style>
<div class="head">
<div class="fg-head">
<ha-icon icon="${achieved ? "mdi:trophy" : "mdi:flag-checkered"}"></ha-icon>
<span class="title">${this.config.title || goal.name || this._t("family_goal.default_title")}</span>
<span class="fg-title">${this.config.title || goal.name || this._t("family_goal.default_title")}</span>
</div>
<div class="body">
<div class="bar"><div class="fill ${achieved ? "done" : ""}" style="width:${pct}%"></div></div>
<div class="stats">
<div class="fg-body">
<div class="fg-bar"><div class="fg-fill ${achieved ? "done" : ""}" style="width:${pct}%"></div></div>
<div class="fg-stats">
<span>${progress} / ${target} ${pointsName}</span>
<span>${pct}%</span>
</div>
${achieved
? html`<div class="reward done">🎉 ${this._t("family_goal.reached", { reward: goal.reward || "" })}</div>`
? html`<div class="fg-reward done">🎉 ${this._t("family_goal.reached", { reward: goal.reward || "" })}</div>`
: goal.reward
? html`<div class="reward">${this._t("family_goal.reward_label", { reward: goal.reward })}</div>`
? html`<div class="fg-reward">${this._t("family_goal.reward_label", { reward: goal.reward })}</div>`
: ""}
</div>
</ha-card>
@@ -82,18 +107,48 @@ class TaskMateFamilyGoalCard extends LitElement {
}
static get styles() {
return css`
.head { display: flex; align-items: center; gap: 8px; padding: 14px 16px 6px; font-weight: 600; }
.head ha-icon { color: var(--tm-goal-accent); }
.body { padding: 4px 16px 16px; }
.bar { height: 14px; border-radius: 8px; background: var(--secondary-background-color, #eee); overflow: hidden; }
.fill { height: 100%; background: var(--tm-goal-accent); transition: width .4s ease; }
.fill.done { background: #d4ac0d; }
.stats { display: flex; justify-content: space-between; font-size: 0.82rem; color: var(--secondary-text-color); margin-top: 6px; }
.reward { margin-top: 8px; font-size: 0.85rem; color: var(--primary-text-color); }
.reward.done { font-weight: 600; color: #d4ac0d; }
.empty { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 28px 16px; color: var(--secondary-text-color); }
// Layout classes carry an fg- prefix because the shared design kit defines
// its own .bar (and .stat / .grid / …); a bare .bar here would pick up the
// kit's rules under a designed style.
const base = css`
:host { --fg-accent: var(--tmd-accent, #16a085); }
ha-card {
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);
}
.fg-head {
display: flex; align-items: center; gap: 8px;
padding: 14px 16px 6px; font-weight: 600;
font-family: var(--tmd-font-display, inherit);
}
.fg-head ha-icon { color: var(--fg-accent); }
.fg-body { padding: 4px 16px 16px; }
.fg-bar {
height: 14px; border-radius: var(--tmd-radius-sm, 8px);
background: var(--tmd-surface-2, var(--secondary-background-color, #eee));
border: 1px solid var(--tmd-border, transparent);
box-sizing: border-box;
overflow: hidden;
}
.fg-fill { height: 100%; background: var(--fg-accent); transition: width .4s ease; }
.fg-fill.done { background: var(--tmd-gold, #d4ac0d); }
.fg-stats {
display: flex; justify-content: space-between;
font-size: 0.82rem; color: var(--tmd-dim, var(--secondary-text-color)); margin-top: 6px;
}
.fg-reward { margin-top: 8px; font-size: 0.85rem; color: var(--tmd-text, var(--primary-text-color)); }
.fg-reward.done { font-weight: 600; color: var(--tmd-gold, #d4ac0d); }
.fg-empty {
display: flex; flex-direction: column; align-items: center; gap: 8px;
padding: 28px 16px; color: var(--tmd-dim, var(--secondary-text-color));
}
`;
const tokens = window.__taskmate_design && window.__taskmate_design.styles
? window.__taskmate_design.styles() : null;
return tokens ? [tokens, base] : base;
}
}
@@ -319,7 +319,7 @@ class TaskMateLeaderboardCard extends LitElement {
if (entity.state === "unavailable" || entity.state === "unknown") return html`<ha-card><div class="error-state"><ha-icon icon="mdi:alert-circle"></ha-icon><div>${this._t('common.unavailable')}</div></div></ha-card>`;
const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) || entity.attributes || {};
const children = [...(attrs.children || [])];
const children = [...(attrs.children || [])].filter(c => !c.is_guest);
const pointsIcon = attrs.points_icon || "mdi:star";
const pointsName = attrs.points_name || this._t('common.points');
@@ -593,7 +593,7 @@ class TaskMateLeaderboardCard extends LitElement {
}
const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) || entity.attributes || {};
const children = [...(attrs.children || [])];
const children = [...(attrs.children || [])].filter(c => !c.is_guest);
const pointsName = attrs.points_name || this._t('common.points');
const tz = this.hass?.config?.time_zone || Intl.DateTimeFormat().resolvedOptions().timeZone;
@@ -411,7 +411,7 @@ class TaskMateOverviewCard extends LitElement {
// full pending list (resolved via companion) so the count keeps including
// completions left pending from a previous day; fall back to today's
// completions only on older backends.
let pendingApprovals = 0;
let pendingApprovals;
if (this.config.approvals_entity) {
const appEntity = this.hass.states[this.config.approvals_entity];
pendingApprovals = appEntity?.attributes?.chore_completions?.length || 0;
@@ -517,7 +517,7 @@ class TaskMateOverviewCard extends LitElement {
</ha-card>`;
}
let pendingApprovals = 0;
let pendingApprovals;
if (this.config.approvals_entity) {
const appEntity = this.hass.states[this.config.approvals_entity];
pendingApprovals = appEntity?.attributes?.chore_completions?.length || 0;
File diff suppressed because it is too large Load Diff
@@ -109,10 +109,19 @@ class TaskMateParentDashboardCard extends LitElement {
display: flex;
border-bottom: 1px solid var(--divider-color, #e0e0e0);
background: var(--secondary-background-color, #f5f5f5);
/* Flex items refuse to shrink below their content, so on a narrow card
the last tab used to be clipped off the edge with no way to reach it.
Wrapping keeps every tab visible without asking the parent to
discover a horizontal swipe on a card that doesn't look scrollable. */
flex-wrap: wrap;
}
.tab-btn {
flex: 1; padding: 10px 8px;
/* Grow to fill a wide card, but keep the label intact when narrow. */
flex: 1 0 auto;
min-width: 0;
white-space: nowrap;
padding: 10px 8px;
background: none; border: none;
font-size: 0.78rem; font-weight: 600;
color: var(--secondary-text-color);
@@ -4,6 +4,10 @@
* `photo_gallery` attribute and renders thumbnails. A card's <img> can't send a
* bearer token, so each photo path is signed via the `auth/sign_path` WS command
* before use.
*
* Single layout, so it consumes the design tokens directly (like the routine
* card). Every --tmd-* reference has the card's original value as its fallback,
* so classic — which defines no tokens — renders exactly as it did before.
*/
const LitElement = customElements.get("hui-masonry-view")
@@ -14,6 +18,7 @@ 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 DEFAULT_ACCENT = "#5d6d7e";
class TaskMatePhotoGalleryCard extends LitElement {
static get properties() {
@@ -46,6 +51,25 @@ class TaskMatePhotoGalleryCard extends LitElement {
return fn ? fn(this.hass, key, params) : key;
}
/**
* Resolve the active design, stamp data-tm-design on the host, and set the
* header accent. The accent is an inline host property, not a template
* <style>: a shadow tree's <style> is ordered before adoptedStyleSheets, so
* it loses to the :host default in static styles(). Header stays full-colour
* in every style per the house rule.
*/
_applyDesign() {
if (window.__taskmate_design) {
window.__taskmate_design.apply(this, this.hass, this.config, this.config.entity);
}
const configured = this.config.header_color;
if (typeof configured === "string" && /^#[0-9a-fA-F]{3,8}$/.test(configured)) {
this.style.setProperty("--pg-accent", _safeColor(configured, DEFAULT_ACCENT));
} else {
this.style.removeProperty("--pg-accent");
}
}
// Open the shared in-page lightbox; fall through to the href if it's absent.
_openPhoto(e, url, caption) {
if (window.__taskmate_lightbox) {
@@ -85,22 +109,22 @@ class TaskMatePhotoGalleryCard extends LitElement {
render() {
if (!this.hass || !this.config) return html``;
this._applyDesign();
const entity = this.hass.states[this.config.entity];
if (!entity) {
return html`<ha-card><div class="empty"><ha-icon icon="mdi:alert-circle"></ha-icon><div>${this._t("common.entity_not_found", { entity: this.config.entity })}</div></div></ha-card>`;
return html`<ha-card><div class="pg-empty"><ha-icon icon="mdi:alert-circle"></ha-icon><div>${this._t("common.entity_not_found", { entity: this.config.entity })}</div></div></ha-card>`;
}
const items = this._gallery();
return html`
<ha-card>
<style>:host { --tm-gallery-header: ${_safeColor(this.config.header_color, "#5d6d7e")}; }</style>
<div class="card-header" style="background: var(--tm-gallery-header);">
<div class="pg-head">
<ha-icon icon="mdi:image-multiple"></ha-icon>
<span>${this.config.title || this._t("gallery.default_title")}</span>
</div>
${items.length === 0
? html`<div class="empty"><ha-icon icon="mdi:camera-off"></ha-icon><div>${this._t("gallery.empty")}</div></div>`
? html`<div class="pg-empty"><ha-icon icon="mdi:camera-off"></ha-icon><div>${this._t("gallery.empty")}</div></div>`
: html`
<div class="grid">
<div class="pg-grid">
${items.map((it) => this._tile(it))}
</div>`}
</ha-card>
@@ -111,12 +135,12 @@ class TaskMatePhotoGalleryCard extends LitElement {
const src = this._signed[it.photo_url];
const when = this._formatDate(it.completed_at);
return html`
<div class="tile" title="${it.chore_name} — ${it.child_name}">
<div class="pg-tile" title="${it.chore_name} — ${it.child_name}">
${src
? html`<a href="${src}" target="_blank" rel="noopener"
@click="${(e) => this._openPhoto(e, src, [it.chore_name, it.child_name, when].filter(Boolean).join(" · "))}"><img src="${src}" alt="${it.chore_name}" loading="lazy"></a>`
: html`<div class="ph"><ha-icon icon="mdi:image"></ha-icon></div>`}
<div class="cap">
: html`<div class="pg-ph"><ha-icon icon="mdi:image"></ha-icon></div>`}
<div class="pg-cap">
<span class="who">${it.child_name}</span>
<span class="what">${it.chore_name}</span>
<span class="when">${when}${it.approved ? "" : " · " + this._t("gallery.pending")}</span>
@@ -134,27 +158,51 @@ class TaskMatePhotoGalleryCard extends LitElement {
}
static get styles() {
return css`
ha-card { overflow: hidden; }
.card-header {
display: flex; align-items: center; gap: 8px;
padding: 12px 16px; color: #fff; font-weight: 600;
// Layout classes carry a pg- prefix because the shared design kit defines
// its own .grid; a bare .grid here would pick up the kit's rules under a
// designed style. Base styles come after the kit in static styles(), so
// they win at equal specificity.
const base = css`
:host { --pg-accent: var(--tmd-accent, #5d6d7e); }
ha-card {
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);
}
.grid {
.pg-head {
display: flex; align-items: center; gap: 8px;
padding: 12px 16px; color: var(--tmd-hd-text, #fff); font-weight: 600;
background: var(--pg-accent);
font-family: var(--tmd-font-display, inherit);
}
.pg-grid {
display: grid; gap: 10px; padding: 14px;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
}
.tile { display: flex; flex-direction: column; border-radius: 8px; overflow: hidden; background: var(--secondary-background-color, #f5f5f5); }
.tile img, .tile .ph {
.pg-tile {
display: flex; flex-direction: column;
border-radius: var(--tmd-radius-sm, 8px); overflow: hidden;
background: var(--tmd-surface-2, var(--secondary-background-color, #f5f5f5));
border: 1px solid var(--tmd-border, transparent);
}
.pg-tile img, .pg-tile .pg-ph {
width: 100%; aspect-ratio: 1 / 1; object-fit: cover; display: block;
}
.tile .ph { display: flex; align-items: center; justify-content: center; color: var(--secondary-text-color); }
.cap { padding: 6px 8px; display: flex; flex-direction: column; gap: 1px; font-size: 0.72rem; }
.cap .who { font-weight: 600; }
.cap .what { color: var(--primary-text-color); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cap .when { color: var(--secondary-text-color); }
.empty { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 32px 16px; color: var(--secondary-text-color); }
.pg-tile .pg-ph { display: flex; align-items: center; justify-content: center; color: var(--tmd-dim, var(--secondary-text-color)); }
.pg-cap { padding: 6px 8px; display: flex; flex-direction: column; gap: 1px; font-size: 0.72rem; }
.pg-cap .who { font-weight: 600; }
.pg-cap .what { color: var(--tmd-text, var(--primary-text-color)); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pg-cap .when { color: var(--tmd-dim, var(--secondary-text-color)); }
.pg-empty {
display: flex; flex-direction: column; align-items: center; gap: 8px;
padding: 32px 16px; color: var(--tmd-dim, var(--secondary-text-color));
}
`;
const tokens = window.__taskmate_design && window.__taskmate_design.styles
? window.__taskmate_design.styles() : null;
return tokens ? [tokens, base] : base;
}
}
@@ -1725,7 +1725,7 @@ class TaskMateRewardsCard extends LitElement {
// Calculate weighted segments - each segment width = (child's share of goal) * (their progress %)
// This way, if a child has 50% share and is at 100% progress, they fill 50% of the bar
const segments = childContributions.map((contrib) => {
let width = 0;
let width;
if (hasWeightedData && contrib.shareOfGoal > 0) {
// Weighted: segment width = share of goal * progress percentage
// e.g., 40% share at 50% progress = 20% of bar filled