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