278 files

This commit is contained in:
Home Assistant Version Control
2026-09-13 20:38:58 +00:00
parent d7a0a08372
commit 1b4f5f68c6
278 changed files with 36892 additions and 7150 deletions
+63 -16
View File
@@ -18,6 +18,7 @@ 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 . import authz
from .const import (
ATTR_AS_PARENT,
ATTR_AWARDED_BADGE_ID,
@@ -62,7 +63,6 @@ from .const import (
ATTR_REWARD_ID,
ATTR_SOUND,
BADGE_TIERS,
COMPLETION_SOUND_OPTIONS,
CONF_TASK_GROUP_CHORE_IDS,
CONF_TASK_GROUP_ID,
CONF_TASK_GROUP_NAME,
@@ -384,6 +384,19 @@ async def _async_record_service_audit(hass: HomeAssistant, call: ServiceCall) ->
_LOGGER.debug("Failed to record service audit for %s", call.service, exc_info=True)
def _valid_sound(value: str) -> str:
"""Validate a preview_sound name: a built-in, or an uploaded custom sound (#856).
Replaces a plain ``vol.In(COMPLETION_SOUND_OPTIONS)``, which could not see
custom sounds because they are created at runtime.
"""
from .const import is_valid_completion_sound
if is_valid_completion_sound(value):
return value
raise vol.Invalid(f"Unknown completion sound: {value}")
def _safe(handler):
"""Surface coordinator validation errors as clean service errors.
@@ -433,6 +446,13 @@ async def _async_require_linked_child(hass: HomeAssistant, call: ServiceCall, co
others = coordinator.storage.get_children() or []
if any(getattr(c, "linked_user_id", "") == user_id for c in others):
raise Unauthorized(context=call.context)
# Strict mode: households where every child has their own HA login can
# require a link, so an unlinked child profile is no longer an open door
# for any authenticated user. Parents keep acting on any child's behalf.
if coordinator.storage.get_require_linked_child():
if user_id in (coordinator.storage.get_parent_user_ids() or []):
return
raise Unauthorized(context=call.context)
async def _async_register_services(hass: HomeAssistant) -> None:
@@ -452,6 +472,23 @@ async def _async_register_services(hass: HomeAssistant) -> None:
# Unauthorized (not ValueError), so it is unaffected and still 401s.
return _safe(wrapped)
def _audited(handler):
"""Wrap a child-facing handler so its mutation is recorded too.
Only the admin and parent wrappers used to audit. Everything a child
can drive — completing a chore, claiming a reward, allocating points —
therefore left no trail at all, which is exactly the set of actions
worth being able to review. The guards stay inside each handler; this
only adds the record.
"""
@wraps(handler)
async def wrapped(call: ServiceCall) -> None:
await handler(call)
await _async_record_service_audit(hass, call)
return _safe(wrapped)
def _parent(handler):
"""Like _admin, but also allows non-admin users in parent_user_ids (#661).
@@ -651,12 +688,20 @@ async def _async_register_services(hass: HomeAssistant) -> None:
_LOGGER.error("No TaskMate coordinator available")
return
await _async_require_linked_child(hass, call, coordinator, call.data[ATTR_CHILD_ID])
# A child may have their own chore list read out; choosing the words is
# a parent action. Otherwise any account that can act as a child could
# make a speaker in the house say anything, at any hour.
message = call.data.get("message", "")
if message and not await authz.async_context_is_parent(hass, coordinator, call.context):
_LOGGER.warning("Ignoring caller-supplied read_aloud message from a non-parent user")
message = ""
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", ""),
message=message,
context=call.context,
)
except ValueError as err:
raise ServiceValidationError(str(err)) from err
@@ -1075,7 +1120,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
hass.services.async_register(
DOMAIN,
SERVICE_COMPLETE_CHORE,
handle_complete_chore,
_audited(handle_complete_chore),
schema=vol.Schema(
{
vol.Required(ATTR_CHORE_ID): cv.string,
@@ -1089,7 +1134,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
hass.services.async_register(
DOMAIN,
SERVICE_COMPLETE_BONUS_SUBTASK,
_safe(handle_complete_bonus_subtask),
_audited(handle_complete_bonus_subtask),
schema=vol.Schema(
{
vol.Required(ATTR_CHORE_ID): cv.string,
@@ -1102,7 +1147,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
hass.services.async_register(
DOMAIN,
SERVICE_START_TIMED_TASK,
_safe(handle_start_timed_task),
_audited(handle_start_timed_task),
schema=vol.Schema(
{
vol.Required(ATTR_CHORE_ID): cv.string,
@@ -1114,7 +1159,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
hass.services.async_register(
DOMAIN,
SERVICE_PAUSE_TIMED_TASK,
_safe(handle_pause_timed_task),
_audited(handle_pause_timed_task),
schema=vol.Schema(
{
vol.Required(ATTR_CHORE_ID): cv.string,
@@ -1126,7 +1171,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
hass.services.async_register(
DOMAIN,
SERVICE_STOP_TIMED_TASK,
_safe(handle_stop_timed_task),
_audited(handle_stop_timed_task),
schema=vol.Schema(
{
vol.Required(ATTR_CHORE_ID): cv.string,
@@ -1249,7 +1294,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
hass.services.async_register(
DOMAIN,
SERVICE_REQUEST_SWAP,
_safe(handle_request_swap),
_audited(handle_request_swap),
schema=vol.Schema(
{
vol.Required("chore_id"): cv.string,
@@ -1265,9 +1310,11 @@ async def _async_register_services(hass: HomeAssistant) -> None:
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,
# Blank means "use the configured default"; anything else has to
# be an entity in the right domain, not an arbitrary string.
vol.Optional("media_player", default=""): vol.Any("", cv.entity_domain("media_player")),
vol.Optional("tts_entity", default=""): vol.Any("", cv.entity_domain("tts")),
vol.Optional("message", default=""): vol.All(cv.string, vol.Length(max=500)),
}
),
)
@@ -1275,14 +1322,14 @@ async def _async_register_services(hass: HomeAssistant) -> None:
hass.services.async_register(
DOMAIN,
SERVICE_SPIN_ROULETTE,
_safe(handle_spin_roulette),
_audited(handle_spin_roulette),
schema=vol.Schema({vol.Required(ATTR_CHILD_ID): cv.string}),
)
hass.services.async_register(
DOMAIN,
SERVICE_CHOOSE_AVATAR,
_safe(handle_choose_avatar),
_audited(handle_choose_avatar),
schema=vol.Schema(
{
vol.Required(ATTR_CHILD_ID): cv.string,
@@ -1294,7 +1341,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
hass.services.async_register(
DOMAIN,
SERVICE_CLAIM_REWARD,
_safe(handle_claim_reward),
_audited(handle_claim_reward),
schema=vol.Schema(
{
vol.Required(ATTR_REWARD_ID): cv.string,
@@ -1324,7 +1371,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
hass.services.async_register(
DOMAIN,
SERVICE_ALLOCATE_POINTS_TO_POOL,
_safe(handle_allocate_points_to_pool),
_audited(handle_allocate_points_to_pool),
schema=vol.Schema(
{
vol.Required(ATTR_CHILD_ID): cv.string,
@@ -1366,7 +1413,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
_safe(handle_preview_sound),
schema=vol.Schema(
{
vol.Required(ATTR_SOUND): vol.In(COMPLETION_SOUND_OPTIONS),
vol.Required(ATTR_SOUND): vol.All(str, _valid_sound),
}
),
)
+16
View File
@@ -198,6 +198,22 @@ COMPLETION_SOUND_OPTIONS: Final = [
"fart_random", # Random fart - picks a random fart sound each time!
]
def is_valid_completion_sound(value: str | None) -> bool:
"""True for a built-in sound name or a well-formed custom sound reference.
A chore's ``completion_sound`` is either one of ``COMPLETION_SOUND_OPTIONS``
or ``custom:<32 hex>.<ext>`` pointing at an uploaded file (#856). Kept here
rather than in ``sounds.py`` so schema code can validate the whole field
from one import. The custom check is pure, so it is safe on untrusted input.
"""
if value in COMPLETION_SOUND_OPTIONS:
return True
from .sounds import is_custom_sound
return is_custom_sound(value)
# --- Chore difficulty tiers ---
# Each chore carries a difficulty tier; the points it awards are the base
# points multiplied by the tier's multiplier. "medium" is the neutral baseline
+61 -16
View File
@@ -751,17 +751,16 @@ class ChoresMixin:
)
return None
# specific_days chores were historically only filtered by the child card
# (see get_due_chores_for_child), so a crafted service / entity / Dev
# Tools call could complete one that is disabled, not scheduled today, or
# Chores were historically only filtered by the child card (see
# get_due_chores_for_child), so a crafted service / entity / Dev Tools
# call could complete one that is disabled, not scheduled today, or
# assigned to a different child. Enforce the same eligibility the card
# uses, server-side. Parents completing on behalf are the authority and
# stay exempt.
if (
not as_parent
and getattr(chore, "schedule_mode", "specific_days") == "specific_days"
and not self._is_chore_completable_by_child(chore, child_id)
):
# uses, server-side, for EVERY schedule mode — the `assigned_to`
# membership test lives in _is_chore_completable_by_child, so gating
# this on specific_days left recurring/one_shot chores unchecked.
# Parents completing on behalf are the authority and stay exempt (the
# recurrence/one-shot window below still applies to them).
if not as_parent and not self._is_chore_completable_by_child(chore, child_id):
_LOGGER.debug(
"complete_chore no-op: '%s' not eligible for %s today (assignment/schedule/availability)",
chore.name,
@@ -845,13 +844,19 @@ class ChoresMixin:
photo_url=photo_url or "",
)
# Record the completion before awarding anything. Awarding can suspend
# (a level-up or streak milestone sends a notification), and the
# daily-limit check above counts stored completions — so with the write
# last, two calls that arrive together could both pass the check and
# both be paid. Writing the marker first closes that window.
self.storage.add_completion(completion)
if auto_approve:
total_awarded = await self._award_points(child, effective_points, chore_id=chore_id)
completion.approved = True
completion.approved_at = dt_util.now()
completion.points_awarded = total_awarded
self.storage.add_completion(completion)
self.storage.update_completion(completion)
self.hass.bus.async_fire(
"taskmate_chore_completed",
@@ -980,6 +985,14 @@ class ChoresMixin:
now = dt_util.now()
today = dt_util.as_local(now).date()
# A bonus sub-task belongs to its parent chore's assignment. Availability
# is deliberately NOT re-checked here — the parent chore being done today
# is what makes the bonus available, and for one_shot/recurring chores
# that same completion closes the availability window.
assigned = getattr(chore, "assigned_to", []) or []
if assigned and child_id not in assigned:
raise ValueError(f"Bonus sub-task '{subtask.name}' is not assigned to {child.name}.")
# Gate check: parent chore must be completed today
all_completions = self.storage.get_completions()
parent_done_today = any(
@@ -1015,13 +1028,17 @@ class ChoresMixin:
bonus_subtask_id=bonus_subtask_id,
)
# Written before the award for the same reason as the main completion
# path: the duplicate check above reads stored completions, and
# awarding can suspend.
self.storage.add_completion(completion)
if not chore.requires_approval:
total_awarded = await self._award_points(child, subtask.points, skip_streak=True)
completion.approved = True
completion.approved_at = dt_util.now()
completion.points_awarded = total_awarded
self.storage.add_completion(completion)
self.storage.update_completion(completion)
await self.storage.async_save()
if chore.requires_approval:
@@ -1068,6 +1085,16 @@ class ChoresMixin:
pts = self._apply_time_adjustment(
chore, self.effective_chore_points(chore), completion.completed_at
)
# Claim the completion before awarding. The "already
# approved" check above and the award are separated by an
# await that can suspend, so two approvals landing together
# (a double-tap, or Approve All overlapping a single
# approve) could otherwise both get through and pay twice.
completion.approved = True
completion.approved_at = dt_util.now()
completion.points_awarded = 0
self.storage.update_completion(completion)
total_awarded = await self._award_points(
child,
pts,
@@ -1075,8 +1102,6 @@ class ChoresMixin:
skip_streak=is_bonus,
chore_id=completion.chore_id,
)
completion.approved = True
completion.approved_at = dt_util.now()
completion.points_awarded = total_awarded
self.storage.update_completion(completion)
@@ -1265,6 +1290,11 @@ class ChoresMixin:
self.storage.remove_completion(bc.id)
self.storage.remove_completion(completion_id)
if target_completion and target_completion.approved and not target_completion.bonus_subtask_id:
# Same as undo: a rejected completion must not leave the quest
# chain standing on the step it unlocked.
if hasattr(self, "_async_rewind_quests"):
await self._async_rewind_quests(target_completion.child_id, target_completion.chore_id)
# The completion record is gone, so its evidence photo is now orphaned —
# delete it (best-effort; foreign/blank URLs are ignored).
if target_completion and getattr(target_completion, "photo_url", ""):
@@ -1292,6 +1322,15 @@ class ChoresMixin:
if getattr(self, "notifications", None):
await self.notifications.clear_approval("pending_chore_approval", completion_id)
# A rejected submission no longer counts as "done", so a mandatory
# chore whose period has already closed may now owe a miss that
# end-of-period detection skipped.
await self.async_recheck_mandatory_miss(
target_completion.chore_id,
target_completion.child_id,
dt_util.as_local(target_completion.completed_at).date(),
)
async def async_undo_chore_approval(self, completion_id: str) -> None:
"""Undo an accidental approval: reverse the awards and return the
completion to *pending* so it re-enters the approval queue.
@@ -1321,6 +1360,12 @@ class ChoresMixin:
target.points_awarded = 0
self.storage.update_completion(target)
# Quest progress advanced on approval, so it has to come back too —
# otherwise re-approving the same completion advances the chain a
# second time and pays a repeatable quest's bonus twice.
if not target.bonus_subtask_id and hasattr(self, "_async_rewind_quests"):
await self._async_rewind_quests(target.child_id, target.chore_id)
await self.storage.async_save()
await self.async_refresh()
@@ -63,6 +63,44 @@ class MandatoryMixin:
return True
return False
def _period_has_ended(self, period_id: str, day: date, now: datetime | None = None) -> bool:
"""True once ``period_id`` on ``day`` is over and a miss could be owed."""
now = now or dt_util.now()
today = dt_util.as_local(now).date()
if day < today:
return True
if day > today:
return False
for p in self.get_time_periods():
if p.get("id") != period_id:
continue
try:
eh, em = [int(x) for x in p["end"].split(":")]
except (ValueError, KeyError, TypeError):
return False
return dt_util.as_local(now).time() >= dt_time(eh, em)
# "anytime" and unknown periods only close at midnight, which the
# midnight sweep already handles.
return False
async def async_recheck_mandatory_miss(self, chore_id: str, child_id: str, day: date) -> int:
"""Re-run miss detection after a completion stops counting.
Detection accepts a *pending* completion as "done" on purpose — a child
who did the chore shouldn't be penalised because a parent hasn't got to
the approval yet. The gap is that detection only ran once, at the end of
the period: submitting just before the deadline and having it rejected
afterwards left no miss behind. Rejection re-opens the question, so ask
it again (detection is idempotent and dedupes existing misses).
"""
chore = self.get_chore(chore_id)
if not chore or not getattr(chore, "mandatory", False):
return 0
period_id = self._effective_period_for(chore, child_id, day)
if not self._period_has_ended(period_id, day):
return 0
return await self.async_detect_mandatory_misses(period_id, day)
async def async_detect_mandatory_misses(self, period_id: str, day: date) -> int:
"""Create misses for due+incomplete mandatory chores in `period_id`."""
existing = {(m.chore_id, m.child_id, m.due_date) for m in self.storage.get_mandatory_misses()}
@@ -41,6 +41,7 @@ from .const import (
NOTIF_TYPE_WEEKLY_DIGEST,
)
from .models import NotificationRoute
from .timewindow import is_within_window, parse_hhmm
_LOGGER = logging.getLogger(__name__)
@@ -127,37 +128,10 @@ def _validate_group(value: str) -> str:
return value
def _parse_hhmm(value: str) -> tuple[int, int] | None:
"""Parse "HH:MM" into (hour, minute); None if blank/malformed."""
if not value:
return None
try:
hour, minute = map(int, value.split(":", 1))
except (ValueError, AttributeError):
return None
if 0 <= hour <= 23 and 0 <= minute <= 59:
return hour, minute
return None
def _is_within_quiet_hours(start: str, end: str, now) -> bool:
"""True if ``now`` (a datetime) falls inside the [start, end) HH:MM window.
Both bounds must be set, else quiet hours are disabled. A start later than
the end denotes an overnight window (e.g. 20:00-07:00). The end bound is
exclusive so a window of 07:00-07:00 (equal bounds) is treated as disabled.
"""
s = _parse_hhmm(start)
e = _parse_hhmm(end)
if s is None or e is None or s == e:
return False
cur = now.hour * 60 + now.minute
start_m = s[0] * 60 + s[1]
end_m = e[0] * 60 + e[1]
if start_m < end_m:
return start_m <= cur < end_m
# Overnight window: active from start through midnight to end.
return cur >= start_m or cur < end_m
# Quiet hours reuse the shared time-of-day window helpers (also used by
# time-locked rewards, #857).
_parse_hhmm = parse_hhmm
_is_within_quiet_hours = is_within_window
class _SafeDict(dict):
@@ -122,6 +122,70 @@ class QuestsMixin:
await self.storage.async_save()
await self.async_refresh()
async def _async_rewind_quests(self, child_id: str, chore_id: str) -> None:
"""Undo a quest step that a now-reversed completion had advanced.
Progress used to be one-way: rejecting or un-approving a completion left
the quest sitting on the step it unlocked, so re-approving the same
completion advanced it again — and on a repeatable quest that meant
paying the completion bonus a second time for one piece of work.
"""
child = self.get_child(child_id)
if not child:
return
changed = False
for quest in self.storage.get_quests():
if not quest.active or not quest.steps:
continue
if not self._quest_applies_to(quest, child_id):
continue
prog = dict(self.storage.get_quest_child_progress(quest.id, child_id))
step = int(prog.get("step", 0))
completed = int(prog.get("completed_count", 0))
was_finished = False
if step > 0 and quest.steps[step - 1] == chore_id:
# Ordinary mid-quest step, or the final step of a one-shot
# quest (whose step stays at len(steps) once finished).
was_finished = step >= len(quest.steps)
prog["step"] = step - 1
elif step == 0 and completed > 0 and quest.steps[-1] == chore_id:
# A repeatable quest wrapped back to the start on this very
# completion: step back into its final step.
was_finished = True
prog["step"] = len(quest.steps) - 1
else:
continue
if was_finished:
prog["completed_count"] = max(0, completed - 1)
self._refund_quest_bonus(quest, child)
self.storage.set_quest_child_progress(quest.id, child_id, prog)
changed = True
if changed:
self.storage.update_child(child)
await self.storage.async_save()
await self.async_refresh()
def _refund_quest_bonus(self, quest: Quest, child) -> None:
"""Take back a quest completion bonus, logging the reversal."""
bonus = int(quest.bonus_points or 0)
if bonus <= 0:
return
child.points = max(0, child.points - bonus)
child.total_points_earned = max(0, child.total_points_earned - bonus)
child.career_score = child.total_points_earned - child.total_penalties_received
self.storage.add_points_transaction(
PointsTransaction(
child_id=child.id,
points=-bonus,
reason=f"Quest reversed: {quest.name}",
created_at=dt_util.now(),
)
)
async def _complete_quest(self, quest: Quest, child, prog: dict) -> None:
"""Award the quest bonus and reset/finalise progress."""
prog["completed_count"] = int(prog.get("completed_count", 0)) + 1
+78 -2
View File
@@ -3,18 +3,46 @@
from __future__ import annotations
import logging
from datetime import date
from datetime import date, datetime
from typing import TYPE_CHECKING
from homeassistant.util import dt as dt_util
from .models import PointsTransaction, PoolAllocation, Reward, RewardClaim
from .timewindow import has_window, is_within_window
if TYPE_CHECKING:
pass
_LOGGER = logging.getLogger(__name__)
# Upper bound on a single child's pending reward claims. Each claim writes a
# permanent record and pushes a notification to every routed parent, so an
# unbounded queue is a way to bury the approvals list and spam phones. Mirrors
# the pending-swap-request cap.
_MAX_PENDING_CLAIMS_PER_CHILD = 20
def reward_is_time_locked(reward: Reward, now: datetime | None = None) -> bool:
"""True if a time-locked reward is currently outside its allowed window (#857).
The lock has two independent parts, both optional: a set of weekdays and
a time-of-day window. Whatever is configured must pass. An empty day list
means every day; an unusable time window (blank, malformed, or equal
bounds) means any time. The weekday is read from the local day `now`
falls on, so an overnight window on a day-restricted reward only covers
the part of it before midnight.
"""
if not getattr(reward, "time_lock_enabled", False):
return False
moment = now or dt_util.now()
days = getattr(reward, "available_days", None) or []
if days and moment.weekday() not in days:
return True
start = getattr(reward, "available_from", "") or ""
end = getattr(reward, "available_until", "") or ""
return has_window(start, end) and not is_within_window(start, end, moment)
class RewardsMixin:
"""Mixin providing reward CRUD, claiming, and pool allocation logic."""
@@ -123,9 +151,29 @@ class RewardsMixin:
return False
return deadline <= dt_util.now().date()
# Module-level so sensor.py can reuse the same rule when building state.
_reward_is_time_locked = staticmethod(reward_is_time_locked)
@staticmethod
def _reward_is_for_child(reward: Reward, child_id: str) -> bool:
"""True if ``child_id`` is allowed this reward.
An empty ``assigned_to`` means "everyone", matching how the cards build
their reward list. The cards filter on this, so the coordinator has to
as well — otherwise a direct service call can claim or save towards a
reward meant for a sibling.
"""
assigned = reward.assigned_to if isinstance(reward.assigned_to, list) else []
return not assigned or child_id in assigned
@classmethod
def _reward_is_unavailable(cls, reward: Reward) -> bool:
"""True if the reward cannot currently be claimed or allocated to."""
"""True if the reward is permanently out of reach — sold out or expired.
Deliberately excludes the time lock: this predicate drives pool refunds,
and a time lock is temporary, so folding it in would refund every saver's
points the moment a window closed.
"""
return cls._reward_is_sold_out(reward) or cls._reward_is_expired(reward)
def _refund_all_pool_allocations(self, reward: Reward, reason: str) -> None:
@@ -222,10 +270,24 @@ class RewardsMixin:
if not child:
raise ValueError(f"Child {child_id} not found")
if not self._reward_is_for_child(reward, child_id):
raise ValueError(f"Reward '{reward.name}' is not available to {child.name}")
if self._reward_is_sold_out(reward):
raise ValueError(f"Reward '{reward.name}' is sold out")
if self._reward_is_expired(reward):
raise ValueError(f"Reward '{reward.name}' has expired")
if self._reward_is_time_locked(reward):
raise ValueError(f"Reward '{reward.name}' is not available right now")
# A pool-filled claim skips the wallet check below, and a zero-cost
# reward can never fail it, so without these two guards a child can
# queue unlimited claims — each one a stored record plus a push to
# every parent.
own_pending = [c for c in self.storage.get_pending_reward_claims() if c.child_id == child_id]
if any(c.reward_id == reward_id for c in own_pending):
raise ValueError(f"A claim for '{reward.name}' is already waiting for approval")
if len(own_pending) >= _MAX_PENDING_CLAIMS_PER_CHILD:
raise ValueError("Too many reward claims are already waiting for approval")
# Cost is always static
effective_cost = reward.cost
@@ -342,6 +404,18 @@ class RewardsMixin:
if not reward or not child:
raise ValueError(f"Reward or child not found for claim {claim_id}")
# Stock and expiry are checked when the claim is made, but a
# claim can sit in the queue for days and several can stack up
# against the same item. Re-check here or approving them in
# turn hands out more units than exist (the counter floors at
# zero, so it doesn't even show). The time lock is deliberately
# not re-checked: approval is the parent's call, not the
# child's, so an out-of-hours approval is legitimate.
if self._reward_is_sold_out(reward):
raise ValueError(f"Reward '{reward.name}' is sold out")
if self._reward_is_expired(reward):
raise ValueError(f"Reward '{reward.name}' has expired")
# Cost is always static
effective_cost = reward.cost
@@ -462,6 +536,8 @@ class RewardsMixin:
if not reward:
raise ValueError(f"Reward {reward_id} not found")
if not self._reward_is_for_child(reward, child_id):
raise ValueError(f"Reward '{reward.name}' is not available to {child.name}")
if self._reward_is_sold_out(reward):
raise ValueError(f"Reward '{reward.name}' is sold out")
if self._reward_is_expired(reward):
@@ -13,6 +13,31 @@ if TYPE_CHECKING:
_LOGGER = logging.getLogger(__name__)
# Numeric template-chore fields, with the default used when a pack supplies
# something that isn't a usable number.
_NUMERIC_TEMPLATE_FIELDS = {
"points": 10,
"daily_limit": 1,
"timed_rate_points": 10,
"timed_rate_minutes": 15,
"timed_max_daily_minutes": 0,
}
# These are "unset" when None, so they can't take a numeric default.
_OPTIONAL_NUMERIC_TEMPLATE_FIELDS = ("weather_temp_min", "weather_temp_max", "weather_wind_max")
def _pack_number(value, default):
"""Coerce a pack-supplied value to a finite number, else ``default``."""
if isinstance(value, bool) or isinstance(value, (dict, list)):
return default
try:
num = float(value)
except (TypeError, ValueError):
return default
if num != num or num in (float("inf"), float("-inf")):
return default
return int(num) if float(num).is_integer() else num
class TemplatesMixin:
"""Mixin providing template CRUD and application logic."""
@@ -224,6 +249,18 @@ class TemplatesMixin:
# 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]
# The whitelist controls which keys survive, not what they hold.
# A shared pack is arbitrary JSON, so a field the rest of the app
# treats as a number has to actually be one — otherwise it lands
# in a Chore and every later read of it (points arithmetic, the
# panel's number inputs, the sensor attribute build) inherits a
# string, inf or NaN.
for key, default in _NUMERIC_TEMPLATE_FIELDS.items():
if key in cleaned:
cleaned[key] = _pack_number(cleaned[key], default)
for key in _OPTIONAL_NUMERIC_TEMPLATE_FIELDS:
if cleaned.get(key) is not None:
cleaned[key] = _pack_number(cleaned[key], None)
chores.append(cleaned)
clean.append(
@@ -31,6 +31,7 @@ from .coord_reports import ReportsMixin
from .coord_rewards import RewardsMixin
from .coord_roulette import RouletteMixin
from .coord_scheduled import ScheduledChangesMixin
from .coord_sounds import SoundsMixin
from .coord_templates import TemplatesMixin
from .coord_timed import TimedMixin
from .coord_tts import ReadAloudMixin
@@ -59,6 +60,7 @@ class TaskMateCoordinator(
ReadAloudMixin,
GuestsMixin,
UnlocksMixin,
SoundsMixin,
DataUpdateCoordinator,
):
"""Coordinator to manage TaskMate data."""
+9
View File
@@ -27,6 +27,7 @@ CARDS: Final = [
"taskmate-attr-resolver.js",
"taskmate-localize.js",
"taskmate-design.js",
"taskmate-sounds.js",
"taskmate-badges-card.js",
"taskmate-child-card.js",
"taskmate-routine-card.js",
@@ -69,6 +70,9 @@ RETIRED_CARDS: Final = [
# no-op: the module is keyed by URL (loaded once) and only assigns idempotent
# window.__taskmate_localize globals.
GLOBAL_MODULES: Final = [
# Order matters: the bridge and the panel both call window.__taskmate_sounds,
# so the engine must be defined first.
"taskmate-sounds.js",
"taskmate-config-sounds.js",
"taskmate-localize.js",
# Like localize.js above, the admin panel (a panel_custom page, not a
@@ -128,6 +132,11 @@ async def async_register_frontend(hass: HomeAssistant) -> None:
async_register_image_views(hass)
# Admin-gated upload / authenticated serve for custom completion sounds (#856).
from .http_sounds import async_register_sound_views
async_register_sound_views(hass)
# Token-gated ICS calendar feed (FEAT-10).
from .http_calendar import async_register_calendar_view
+30 -1
View File
@@ -11,6 +11,7 @@ the thin aiohttp wrapper, verified on the dev HA instance.
from __future__ import annotations
import asyncio
import logging
import uuid
from http import HTTPStatus
@@ -71,14 +72,41 @@ class TaskMatePhotoUploadView(HomeAssistantView):
url = photos.URL_PREFIX
name = "api:taskmate:photo:upload"
# Unlike the image/sound uploads this one is deliberately open to any
# authenticated user — a child has to be able to post their own evidence.
# That makes it the one upload path a non-admin can drive, so it carries
# its own limits: a per-user rate cap, and a bound on how many bodies are
# buffered at once (each one can be MAX_UPLOAD_BYTES).
MAX_UPLOADS_PER_WINDOW = 20
RATE_WINDOW_SECONDS = 60
MAX_CONCURRENT_UPLOADS = 4
def __init__(self, hass: HomeAssistant) -> None:
self.hass = hass
self._limiter = photos.UploadRateLimiter(self.MAX_UPLOADS_PER_WINDOW, self.RATE_WINDOW_SECONDS)
self._slots = asyncio.Semaphore(self.MAX_CONCURRENT_UPLOADS)
async def post(self, request: web.Request) -> web.Response:
user = request.get("hass_user")
user_id = getattr(user, "id", "") or "anonymous"
if self._limiter.check(user_id):
_LOGGER.warning("Rejecting photo upload from %s: rate limit exceeded", user_id)
return self.json_message("Too many uploads, try again shortly", HTTPStatus.TOO_MANY_REQUESTS)
async with self._slots:
return await self._handle_upload(request)
async def _handle_upload(self, request: web.Request) -> web.Response:
# Cheap pre-check on the declared length before reading the body.
if request.content_length and request.content_length > photos.MAX_UPLOAD_BYTES:
return self.json_message("File too large", HTTPStatus.REQUEST_ENTITY_TOO_LARGE)
# Check the disk budget before accepting a body, not only after: there
# is no point buffering megabytes we are about to refuse.
used = await self.hass.async_add_executor_job(photos.total_photos_bytes, self.hass)
if used >= photos.MAX_TOTAL_BYTES:
return self.json_message("Photo storage full", HTTPStatus.INSUFFICIENT_STORAGE)
try:
reader = await request.multipart()
except (ValueError, AssertionError):
@@ -110,7 +138,8 @@ class TaskMatePhotoUploadView(HomeAssistantView):
if ext is None:
return self.json_message("Not a valid image", HTTPStatus.BAD_REQUEST)
# DoS guard: reject if the photo store is already at its disk budget.
# Re-check with the real size now the body is in hand (the pre-check
# above only knows the budget was not already full).
used = await self.hass.async_add_executor_job(photos.total_photos_bytes, self.hass)
if used + len(data) > photos.MAX_TOTAL_BYTES:
return self.json_message("Photo storage full", HTTPStatus.INSUFFICIENT_STORAGE)
+1 -1
View File
@@ -17,5 +17,5 @@
"iot_class": "calculated",
"issue_tracker": "https://github.com/tempus2016/taskmate/issues",
"requirements": [],
"version": "5.5.0"
"version": "5.6.0"
}
+63
View File
@@ -486,6 +486,21 @@ class Chore:
}
def _clean_weekdays(raw: Any) -> list[int]:
"""Coerce stored weekday numbers to a sorted, de-duplicated 0-6 list."""
if not isinstance(raw, (list, tuple, set)):
return []
out: set[int] = set()
for item in raw:
try:
day = int(item)
except (TypeError, ValueError):
continue
if 0 <= day <= 6:
out.add(day)
return sorted(out)
@dataclass
class Reward:
"""Represents a reward."""
@@ -509,6 +524,15 @@ class Reward:
# checked both at save time and again when it fires.
unlock_entity: str = ""
unlock_minutes: int = 0
# Time lock (#857): restrict claiming to certain weekdays and/or a
# time-of-day window. `available_days` holds Python weekday numbers
# (0=Monday .. 6=Sunday); empty means every day. The times are "HH:MM";
# a start later than the end is an overnight window. All of it is inert
# unless `time_lock_enabled` is set.
time_lock_enabled: bool = False
available_days: list[int] = field(default_factory=list)
available_from: str = ""
available_until: str = ""
id: str = field(default_factory=generate_id)
def __post_init__(self) -> None:
@@ -542,6 +566,10 @@ class Reward:
unlock_minutes=int(data.get("unlock_minutes", 0) or 0),
restock_period=data.get("restock_period", "weekly"),
restock_last=data.get("restock_last", ""),
time_lock_enabled=bool(data.get("time_lock_enabled", False)),
available_days=_clean_weekdays(data.get("available_days")),
available_from=str(data.get("available_from", "") or ""),
available_until=str(data.get("available_until", "") or ""),
id=data.get("id") or generate_id(),
)
@@ -563,6 +591,10 @@ class Reward:
"restock_last": self.restock_last,
"unlock_entity": self.unlock_entity,
"unlock_minutes": self.unlock_minutes,
"time_lock_enabled": self.time_lock_enabled,
"available_days": list(self.available_days),
"available_from": self.available_from,
"available_until": self.available_until,
"id": self.id,
}
@@ -1283,3 +1315,34 @@ class CustomNotification:
"recipient_ids": list(self.recipient_ids),
"enabled": self.enabled,
}
@dataclass
class CustomSound:
"""A user-uploaded completion sound (#856).
``file`` is the generated ``<32 hex>.<ext>`` name on disk under
``<config>/taskmate_sounds``; it doubles as the sound's identity, so a
chore references this row as ``custom:<file>``. There is no separate id:
a second id would let the row and the file drift apart, and the filename
is already unique and immutable.
"""
name: str
file: str
created_at: str = field(default_factory=dt_util_now_iso)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> CustomSound:
return cls(
name=data.get("name", ""),
file=data.get("file", ""),
created_at=data.get("created_at", "") or dt_util_now_iso(),
)
def to_dict(self) -> dict[str, Any]:
return {
"name": self.name,
"file": self.file,
"created_at": self.created_at,
}
+34
View File
@@ -45,6 +45,40 @@ CONTENT_TYPES = {
}
class UploadRateLimiter:
"""Sliding-window upload cap, keyed by HA user id.
The evidence-photo upload is the one upload path open to any authenticated
user (a child has to be able to post their own proof), so it needs a brake
that the admin-only uploads don't. Kept here, free of aiohttp, so it can be
unit-tested directly.
"""
def __init__(self, max_per_window: int = 20, window_seconds: float = 60.0) -> None:
self.max_per_window = max_per_window
self.window_seconds = window_seconds
self._seen: dict[str, list[float]] = {}
def check(self, user_id: str, now: float | None = None) -> bool:
"""Record an attempt. True when the caller is over its cap."""
now = time.monotonic() if now is None else now
cutoff = now - self.window_seconds
for uid in [u for u, stamps in list(self._seen.items()) if not any(t > cutoff for t in stamps)]:
self._seen.pop(uid, None)
stamps = [t for t in self._seen.get(user_id, []) if t > cutoff]
if len(stamps) >= self.max_per_window:
self._seen[user_id] = stamps
return True
stamps.append(now)
self._seen[user_id] = stamps
return False
@property
def tracked_users(self) -> int:
"""How many users currently hold state (for leak checks)."""
return len(self._seen)
def detect_image_ext(data: bytes) -> str | None:
"""Return a file extension for known image magic bytes, else None.
+38 -4
View File
@@ -18,6 +18,7 @@ from homeassistant.util import dt as dt_util
from . import images
from .const import DOMAIN
from .coord_rewards import reward_is_time_locked
from .coordinator import TaskMateCoordinator
from .entity import taskmate_device_info
from .models import Child
@@ -309,6 +310,12 @@ def _build_chores_list(coordinator: TaskMateCoordinator, common: dict) -> list[d
# `icon` above, to keep records under the 16KB recorder limit.
image_url = getattr(c, "image_url", "")
if image_url:
# Same trade-off as custom sounds, and unlike evidence photos
# (which deliberately emit a bare path for the card to sign per
# viewer): a chore picture is decorative and renders on first
# paint, so signing it here keeps that render synchronous. It does
# mean the signed URL is readable in this attribute — fine for a
# chore picture, not fine for anything personal.
record["image_url"] = images.sign_image_url(common["hass"], image_url)
completion_sound = getattr(c, "completion_sound", "coin")
if completion_sound and completion_sound != "coin":
@@ -453,7 +460,8 @@ def _build_rewards_list(common: dict) -> list[dict]:
children = common["children"]
pool_by_child_reward = common["pool_by_child_reward"]
pool_total_by_reward = common["pool_total_by_reward"]
today = dt_util.now().date()
now = dt_util.now()
today = now.date()
out = []
for r in rewards:
assigned = r.assigned_to if isinstance(r.assigned_to, list) and r.assigned_to else [c.id for c in children]
@@ -472,6 +480,7 @@ def _build_rewards_list(common: dict) -> list[dict]:
days_until_expiry = (deadline - today).days
except (TypeError, ValueError):
pass
is_time_locked = reward_is_time_locked(r, now)
out.append(
{
"id": r.id,
@@ -489,10 +498,21 @@ def _build_rewards_list(common: dict) -> list[dict]:
"expires_at": expires_at,
"is_sold_out": is_sold_out,
"is_expired": is_expired,
"is_available": not (is_sold_out or is_expired),
"is_available": not (is_sold_out or is_expired or is_time_locked),
"days_until_expiry": days_until_expiry,
}
)
# Time lock (#857): only carried for rewards that actually use it. This
# attribute slice is capped at the recorder's 16 KB limit, so unused
# feature fields must not cost every reward bytes.
if getattr(r, "time_lock_enabled", False):
out[-1]["time_lock"] = {
"days": list(getattr(r, "available_days", []) or []),
"from": getattr(r, "available_from", "") or "",
"until": getattr(r, "available_until", "") or "",
}
if is_time_locked:
out[-1]["is_time_locked"] = True
return out
@@ -849,7 +869,15 @@ class TaskMateOverallStatsSensor(_CachedAttrsSensor):
"points_icon": data.get("points_icon", "mdi:star"),
# Global default card-design style; cards read this when no per-card override (#design).
"card_design": settings.get("card_design", "classic"),
# Non-admin parent role (#661): cards unlock parent controls for these HA users.
# Non-admin parent role (#661): cards unlock parent controls for
# these HA users. Deliberately still published in the clear, and
# deliberately never trusted: every privileged path re-resolves the
# role server-side (`_async_require_parent` / `authz`), so this list
# only decides which buttons a card draws. Any authenticated user
# can read it, which tells them which accounts hold the role — worth
# knowing, but it grants nothing, and the alternatives (digests, or
# a per-connection lookup) make the check asynchronous and race the
# first paint in every card that gates controls on it.
"parent_user_ids": self.coordinator.storage.get_parent_user_ids(),
"children": _build_children_summary(self.coordinator, common),
"time_boundaries": time_boundaries,
@@ -876,7 +904,11 @@ class TaskMateOverallStatsSensor(_CachedAttrsSensor):
class TaskMateChoresSensor(_CachedAttrsSensor):
"""Chores catalog + today's completions."""
_unrecorded_attributes = frozenset({"chores", "todays_completions", "task_groups", "active_timed_sessions"})
# custom_sounds carries a freshly signed URL per sound, so it changes on every
# refresh — recording it would churn the DB for no history value (#856).
_unrecorded_attributes = frozenset(
{"chores", "todays_completions", "task_groups", "active_timed_sessions", "custom_sounds"}
)
def __init__(
self,
@@ -910,6 +942,8 @@ class TaskMateChoresSensor(_CachedAttrsSensor):
return {
"chores": _build_chores_list(self.coordinator, common),
"todays_completions": _build_todays_completions(common),
# Lookup table for chores whose completion_sound is "custom:<file>" (#856).
"custom_sounds": self.coordinator.custom_sounds_state(),
"task_groups": task_groups,
"active_timed_sessions": _build_active_timed_sessions(self.coordinator),
"vacation_active": active_vacation is not None,
+219 -5
View File
@@ -19,6 +19,7 @@ from .models import (
Chore,
ChoreCompletion,
CustomNotification,
CustomSound,
MandatoryMiss,
NotificationConfig,
NotificationRoute,
@@ -32,10 +33,30 @@ from .models import (
ScheduledChange,
TaskGroup,
TimedSession,
parse_datetime,
)
_LOGGER = logging.getLogger(__name__)
def _finite_number(value: Any, default: int | float) -> int | float:
"""Coerce an imported value to a real, finite number.
Returns ``default`` for anything that isn't usable — a string that doesn't
parse, a bool, a dict, or inf/NaN. Booleans are excluded deliberately:
``float(True)`` is 1.0, which would silently turn a flag into a score.
"""
if isinstance(value, bool) or isinstance(value, (dict, list)):
return default
try:
num = float(value)
except (TypeError, ValueError):
return default
if num != num or num in (float("inf"), float("-inf")):
return default
return int(num) if float(num).is_integer() else num
STORAGE_VERSION = 1
STORAGE_KEY = f"{DOMAIN}.storage"
@@ -117,6 +138,10 @@ class TaskMateStorage:
if "chore_display_order" not in self._data:
self._data["chore_display_order"] = []
# Ensure custom_sounds store exists (migration for custom sounds, #856)
if "custom_sounds" not in self._data:
self._data["custom_sounds"] = []
# Ensure scheduled_changes store exists (#675)
if "scheduled_changes" not in self._data:
self._data["scheduled_changes"] = []
@@ -558,6 +583,48 @@ class TaskMateStorage:
"""Remove a bonus."""
self._data["bonuses"] = [b for b in self._data.get("bonuses", []) if b.get("id") != bonus_id]
# Custom sounds management (#856)
def get_custom_sounds(self) -> list[CustomSound]:
"""Get all uploaded custom completion sounds."""
return [CustomSound.from_dict(s) for s in self._data.get("custom_sounds", [])]
def get_custom_sound(self, file: str) -> CustomSound | None:
"""Get a custom sound by its stored filename."""
for s in self._data.get("custom_sounds", []):
if s.get("file") == file:
return CustomSound.from_dict(s)
return None
def add_custom_sound(self, sound: CustomSound) -> None:
"""Register an uploaded custom sound."""
self._data.setdefault("custom_sounds", []).append(sound.to_dict())
def rename_custom_sound(self, file: str, name: str) -> bool:
"""Rename a custom sound. Returns False if it isn't registered."""
for s in self._data.get("custom_sounds", []):
if s.get("file") == file:
s["name"] = name
return True
return False
def remove_custom_sound(self, file: str) -> None:
"""Deregister a custom sound (the file itself is deleted by the caller)."""
self._data["custom_sounds"] = [s for s in self._data.get("custom_sounds", []) if s.get("file") != file]
def reset_chores_using_sound(self, sound_value: str, fallback: str = "coin") -> int:
"""Point any chore using ``sound_value`` back at ``fallback``.
Called when a custom sound is deleted. Without this the chore keeps a
dangling ``custom:`` reference and silently plays nothing on completion.
Returns the number of chores rewritten.
"""
changed = 0
for c in self._data.get("chores", []):
if c.get("completion_sound") == sound_value:
c["completion_sound"] = fallback
changed += 1
return changed
# Badges management
def get_badges(self) -> list[Badge]:
"""Get all badges."""
@@ -774,6 +841,17 @@ class TaskMateStorage:
return []
return [x for x in raw if isinstance(x, str) and x]
def get_require_linked_child(self) -> bool:
"""True when acting *as* a child requires that child to be linked.
Off by default: the shared-tablet/kiosk setup (nobody linked, one
household account drives every child) is the common deployment and
must keep working on upgrade. Households where each child has their
own Home Assistant login can switch this on to stop one child acting
through another's profile.
"""
return bool((self._data.get("settings", {}) or {}).get("require_linked_child", False))
def set_parent_user_ids(self, ids: list[str]) -> None:
"""Replace the parent role list (deduped, order-preserving, strings only)."""
seen: list[str] = []
@@ -956,15 +1034,30 @@ class TaskMateStorage:
return list(reversed(self._data.get("audit_log", [])))
def add_audit_entry(self, entry: dict) -> None:
"""Append an admin audit entry, capping the log at 500 (oldest dropped)."""
"""Append an audit entry, capping the log at 500 (oldest dropped).
Truncation is counted, not silent: without a marker, generating 500
routine entries quietly pushes an earlier one out of the log and the
reader has no way to tell that anything is missing.
"""
log = self._data.setdefault("audit_log", [])
log.append(entry)
if len(log) > 500:
dropped = len(log) - 500
del log[:-500]
self._data["audit_log_dropped"] = int(self._data.get("audit_log_dropped", 0) or 0) + dropped
def get_audit_dropped_count(self) -> int:
"""How many audit entries have aged out of the capped log."""
try:
return int(self._data.get("audit_log_dropped", 0) or 0)
except (TypeError, ValueError):
return 0
def clear_audit_log(self) -> None:
"""Remove all audit entries."""
self._data["audit_log"] = []
self._data["audit_log_dropped"] = 0
# ── Chore swap requests ──────────────────────────────────────────────
def get_swap_requests(self) -> list[dict]:
@@ -1185,6 +1278,48 @@ class TaskMateStorage:
_STREAK_MODES = ("reset", "pause")
_CARD_DESIGNS = ("classic", "playroom", "console", "cleanpro", "accessible")
# Per-record numeric fields. The WebSocket schemas coerce these on every
# normal write; import bypasses the schemas entirely, so a crafted
# backup could leave a string (or inf/NaN) where the rest of the code —
# and the panel's number inputs — expect a number.
_NUMERIC_RECORD_FIELDS = {
# NB: streak_milestones_achieved is a list[int], not a scalar — it
# must not be coerced here.
"children": (
"points",
"total_points_earned",
"total_chores_completed",
"current_streak",
"best_streak",
"career_score",
"total_penalties_received",
),
"chores": (
"points",
"claim_allowance_minutes",
"daily_limit",
"mandatory_penalty_points",
"speed_bonus_points",
"skip_count",
"timed_rate_points",
"timed_rate_minutes",
"timed_max_daily_minutes",
),
"rewards": ("cost", "restock_amount", "unlock_minutes"),
"penalties": ("points",),
"bonuses": ("points",),
"completions": ("points_awarded", "timed_duration_seconds"),
"points_transactions": ("points",),
"mandatory_misses": ("penalty_points", "postpone_count", "escalation_stage"),
"pool_allocations": ("allocated_points",),
}
_DATETIME_RECORD_FIELDS = {
"completions": ("completed_at", "approved_at"),
"reward_claims": ("claimed_at", "approved_at"),
"points_transactions": ("created_at",),
"mandatory_misses": ("created_at",),
"allowance_payouts": ("created_at",),
}
_NUMERIC_SETTINGS = (
"history_days",
"weekend_multiplier",
@@ -1204,6 +1339,45 @@ class TaskMateStorage:
"perfect_week_bonus",
)
for bucket, keys in _NUMERIC_RECORD_FIELDS.items():
for record in self._data.get(bucket, []) or []:
if not isinstance(record, dict):
continue
for key in keys:
if key in record:
record[key] = _finite_number(record[key], 0)
for sub in record.get("bonus_subtasks", []) or []:
if isinstance(sub, dict) and "points" in sub:
sub["points"] = _finite_number(sub["points"], 0)
# quantity is "unlimited" when absent/None, so it can't share the loop
# above — but a string or NaN there breaks the sold-out comparison.
for reward in self._data.get("rewards", []) or []:
if isinstance(reward, dict) and reward.get("quantity") is not None:
reward["quantity"] = _finite_number(reward["quantity"], 0)
for badge in self._data.get("badges", []) or []:
if not isinstance(badge, dict):
continue
if "points" in badge:
badge["points"] = _finite_number(badge["points"], 0)
for crit in badge.get("criteria", []) or []:
if isinstance(crit, dict) and "value" in crit:
crit["value"] = _finite_number(crit["value"], 0)
# A timestamp that doesn't parse is dropped: models already turn it into
# None on load, and the panel renders a blank as an em dash rather than
# echoing whatever the string happened to be.
for bucket, keys in _DATETIME_RECORD_FIELDS.items():
for record in self._data.get(bucket, []) or []:
if not isinstance(record, dict):
continue
for key in keys:
raw = record.get(key)
if raw and parse_datetime(raw) is None:
_LOGGER.warning("Import: dropped unparseable %s on a %s record", key, bucket)
record[key] = ""
for comp in self._data.get("completions", []):
if not isinstance(comp, dict):
continue
@@ -1230,6 +1404,34 @@ class TaskMateStorage:
)
chore["image_url"] = ""
# Custom sounds: `file` is concatenated into a path and handed to HA's
# URL signer, and `name` is rendered in the panel and the chore
# dropdown. Neither goes through the upload validation on an import.
from .sounds import FILENAME_RE as _SOUND_FILENAME_RE
from .sounds import clean_sound_name
sounds = self._data.get("custom_sounds")
if isinstance(sounds, list):
kept = []
for snd in sounds:
if not isinstance(snd, dict):
continue
if not _SOUND_FILENAME_RE.match(str(snd.get("file", "") or "")):
_LOGGER.warning("Import: dropped custom sound with an invalid file name")
continue
snd["name"] = clean_sound_name(snd.get("name"))
kept.append(snd)
self._data["custom_sounds"] = kept
# A chore's completion_sound is an enum or a reference to one of those
# sounds; anything else would be rendered into the panel as-is.
from .const import is_valid_completion_sound
for chore in self._data.get("chores", []) or []:
if isinstance(chore, dict) and "completion_sound" in chore:
if not is_valid_completion_sound(str(chore.get("completion_sound") or "")):
chore["completion_sound"] = "coin"
for grp in self._data.get("task_groups", []):
if isinstance(grp, dict) and grp.get("policy") not in TASK_GROUP_POLICIES:
grp["policy"] = "sticky"
@@ -1253,11 +1455,23 @@ class TaskMateStorage:
if key not in settings:
continue
val = settings[key]
if isinstance(val, bool) or not isinstance(val, (int, float)):
try:
settings[key] = float(val)
except (TypeError, ValueError):
if (
isinstance(val, bool)
or not isinstance(val, (int, float))
or val != val
or val
in (
float("inf"),
float("-inf"),
)
):
# inf/NaN parse happily via float() but blow up later in
# arithmetic (e.g. a multiplier), so drop them to the default.
coerced = _finite_number(val, None)
if coerced is None:
settings.pop(key, None)
else:
settings[key] = coerced
def replace_completions(self, completions: list[ChoreCompletion]) -> None:
"""Replace all completions with the given list."""
+130 -19
View File
@@ -68,9 +68,11 @@ from .const import (
MAX_TIME_PERIODS,
SCHEDULE_MODES,
TIME_CATEGORY_ICONS,
is_valid_completion_sound,
)
from .coordinator import TaskMateCoordinator
from .models import BonusSubTask, Reward
from .sounds import MAX_NAME_LEN as MAX_SOUND_NAME_LEN
_LOGGER = logging.getLogger(__name__)
@@ -125,6 +127,10 @@ WS_ADD_TASK_GROUP: Final = "taskmate/add_task_group"
WS_UPDATE_TASK_GROUP: Final = "taskmate/update_task_group"
WS_REMOVE_TASK_GROUP: Final = "taskmate/remove_task_group"
WS_ADD_CUSTOM_SOUND: Final = "taskmate/add_custom_sound"
WS_RENAME_CUSTOM_SOUND: Final = "taskmate/rename_custom_sound"
WS_REMOVE_CUSTOM_SOUND: Final = "taskmate/remove_custom_sound"
WS_UPDATE_SETTINGS: Final = "taskmate/update_settings"
# Operational
@@ -368,6 +374,7 @@ def _build_state_snapshot(coordinator: TaskMateCoordinator) -> dict[str, Any]:
"points_transactions": transactions[-100:], # most recent 100 for audit log
"badges": list(data.get("badges", [])),
"awarded_badges": list(data.get("awarded_badges", [])),
"custom_sounds": coordinator.custom_sounds_state(), # uploaded completion sounds (#856)
"audit_log": coordinator.storage.get_audit_log()[:100], # newest 100 for the panel
"swap_requests": [r for r in coordinator.storage.get_swap_requests() if r.get("status") == "pending"],
"allowance_payouts": list(reversed(coordinator.storage.get_allowance_payouts()))[:50], # newest first (FEAT-3)
@@ -521,6 +528,20 @@ async def _ws_list_ha_users(hass, connection, msg, coordinator):
# ---------------------------------------------------------------------------
def _completion_sound(value):
"""Accept a built-in sound name or a ``custom:<file>`` reference (#856).
This field was previously an unvalidated ``str``, so a typo'd or stale name
persisted happily and then played nothing. Like ``_image_url_or_blank``
below, it raises rather than returning False — voluptuous treats a callable
as a coercer, so a falsy return would be stored as the value.
"""
text = str(value or "")
if is_valid_completion_sound(text):
return text
raise vol.Invalid(f"Unknown completion sound: {text}")
def _image_url_or_blank(value):
"""Accept a blank string (clears the picture) or one of our image URLs.
@@ -607,7 +628,7 @@ def _chore_payload_schema(*, require_name: bool):
vol.Optional("time_category"): str,
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("completion_sound"): _completion_sound,
vol.Optional("icon"): str,
vol.Optional("image_url"): _image_url_or_blank,
vol.Optional("difficulty"): vol.In(DIFFICULTY_TIERS),
@@ -938,6 +959,22 @@ async def _ws_scheduled_remove(hass, connection, msg, coordinator):
# Rewards
# ---------------------------------------------------------------------------
def _validate_hhmm_or_empty(value):
"""voluptuous validator: '' (disabled) or a valid 'HH:MM' string."""
if value in (None, ""):
return ""
if not isinstance(value, str):
raise vol.Invalid("must be a string")
parts = value.split(":")
if len(parts) != 2 or not (parts[0].isdigit() and parts[1].isdigit()):
raise vol.Invalid("must be HH:MM")
hour, minute = int(parts[0]), int(parts[1])
if not (0 <= hour <= 23 and 0 <= minute <= 59):
raise vol.Invalid("HH:MM out of range")
return f"{hour:02d}:{minute:02d}"
_REWARD_FIELDS = {
"name",
"cost",
@@ -953,6 +990,10 @@ _REWARD_FIELDS = {
"restock_period",
"unlock_entity",
"unlock_minutes",
"time_lock_enabled",
"available_days",
"available_from",
"available_until",
}
@@ -974,6 +1015,10 @@ def _reward_payload_schema(*, require_name: bool):
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)),
vol.Optional("time_lock_enabled"): bool,
vol.Optional("available_days"): [vol.All(int, vol.Range(min=0, max=6))],
vol.Optional("available_from"): _validate_hhmm_or_empty,
vol.Optional("available_until"): _validate_hhmm_or_empty,
}
@@ -1014,6 +1059,10 @@ async def _ws_add_reward(hass, connection, msg, coordinator):
restock_period=msg.get("restock_period", "weekly"),
unlock_entity=unlock_entity,
unlock_minutes=unlock_minutes,
time_lock_enabled=msg.get("time_lock_enabled", False),
available_days=sorted(set(msg.get("available_days", []) or [])),
available_from=msg.get("available_from", "") or "",
available_until=msg.get("available_until", "") or "",
)
coordinator.storage.add_reward(reward)
await coordinator.storage.async_save()
@@ -1051,6 +1100,8 @@ async def _ws_update_reward(hass, connection, msg, coordinator):
value = value.strip()
if field == "expires_at":
value = value or None
if field == "available_days":
value = sorted(set(value or []))
if isinstance(value, list):
value = list(value)
setattr(existing, field, value)
@@ -1517,6 +1568,69 @@ async def _ws_remove_task_group(hass, connection, msg, coordinator):
connection.send_result(msg["id"], {"id": msg["group_id"]})
# ---------------------------------------------------------------------------
# Custom completion sounds (#856)
#
# The audio file is uploaded separately to the admin-only HTTP view in
# http_sounds.py, which returns its generated filename. These commands manage
# the registry entry that gives that file a display name and makes it
# selectable. All three are admin-only, matching the upload view.
# ---------------------------------------------------------------------------
@websocket_api.websocket_command(
{
vol.Required("type"): WS_ADD_CUSTOM_SOUND,
vol.Required("file"): str,
vol.Required("name"): vol.All(str, vol.Length(min=1, max=MAX_SOUND_NAME_LEN)),
}
)
@websocket_api.async_response
@_admin_only
async def _ws_add_custom_sound(hass, connection, msg, coordinator):
try:
sound = await coordinator.async_add_custom_sound(msg["file"], msg["name"])
except ValueError as err:
connection.send_error(msg["id"], "invalid_sound", str(err))
return
connection.send_result(msg["id"], {"file": sound.file, "name": sound.name})
@websocket_api.websocket_command(
{
vol.Required("type"): WS_RENAME_CUSTOM_SOUND,
vol.Required("file"): str,
vol.Required("name"): vol.All(str, vol.Length(min=1, max=MAX_SOUND_NAME_LEN)),
}
)
@websocket_api.async_response
@_admin_only
async def _ws_rename_custom_sound(hass, connection, msg, coordinator):
try:
sound = await coordinator.async_rename_custom_sound(msg["file"], msg["name"])
except ValueError as err:
connection.send_error(msg["id"], "not_found", str(err))
return
connection.send_result(msg["id"], {"file": sound.file, "name": sound.name})
@websocket_api.websocket_command(
{
vol.Required("type"): WS_REMOVE_CUSTOM_SOUND,
vol.Required("file"): str,
}
)
@websocket_api.async_response
@_admin_only
async def _ws_remove_custom_sound(hass, connection, msg, coordinator):
try:
reset = await coordinator.async_remove_custom_sound(msg["file"])
except ValueError as err:
connection.send_error(msg["id"], "not_found", str(err))
return
connection.send_result(msg["id"], {"file": msg["file"], "chores_reset": reset})
# ---------------------------------------------------------------------------
# Settings — partial update of currency + the "settings" subkey
# ---------------------------------------------------------------------------
@@ -1527,6 +1641,7 @@ _TOP_LEVEL_SETTINGS = {"points_name", "points_icon"}
_ALLOWED_CARD_DESIGNS = {"classic", "playroom", "console", "cleanpro", "accessible"}
# Settings stored under storage._data["settings"][key]
_SUBKEY_SETTINGS = {
"require_linked_child",
"history_days",
"streak_reset_mode",
"card_design",
@@ -1734,6 +1849,7 @@ _UPDATE_SETTINGS_SCHEMA = {
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("require_linked_child"): bool,
vol.Optional("parent_routing"): vol.In(["all", "home", "round_robin"]),
vol.Optional("read_aloud_media_player"): str,
vol.Optional("read_aloud_tts_entity"): str,
@@ -1980,7 +2096,7 @@ async def _ws_set_global_chore_order(hass, connection, msg, coordinator):
vol.Optional("schedule_mode"): vol.In(SCHEDULE_MODES),
vol.Optional("due_days"): [str],
vol.Optional("daily_limit"): vol.All(int, vol.Range(min=1)),
vol.Optional("completion_sound"): str,
vol.Optional("completion_sound"): _completion_sound,
}
)
@websocket_api.async_response
@@ -2044,7 +2160,7 @@ async def _ws_templates_get(hass, connection, msg, coordinator):
vol.Optional("requires_approval"): bool,
vol.Optional("time_category"): str,
vol.Optional("daily_limit"): vol.All(int, vol.Range(min=1)),
vol.Optional("completion_sound"): str,
vol.Optional("completion_sound"): _completion_sound,
vol.Optional("schedule_mode"): vol.In(SCHEDULE_MODES),
vol.Optional("due_days"): [str],
vol.Optional("recurrence"): str,
@@ -2248,21 +2364,6 @@ async def ws_notif_set_child_notify(hass, connection, msg, coordinator):
connection.send_result(msg["id"], {"ok": True})
def _validate_hhmm_or_empty(value):
"""voluptuous validator: '' (disabled) or a valid 'HH:MM' string."""
if value in (None, ""):
return ""
if not isinstance(value, str):
raise vol.Invalid("must be a string")
parts = value.split(":")
if len(parts) != 2 or not (parts[0].isdigit() and parts[1].isdigit()):
raise vol.Invalid("must be HH:MM")
hour, minute = int(parts[0]), int(parts[1])
if not (0 <= hour <= 23 and 0 <= minute <= 59):
raise vol.Invalid("HH:MM out of range")
return f"{hour:02d}:{minute:02d}"
@websocket_api.websocket_command(
{
vol.Required("type"): WS_NOTIF_SET_CHILD_QUIET,
@@ -2505,7 +2606,14 @@ async def ws_cal_regen_token(hass, connection, msg, coordinator):
@websocket_api.async_response
@_admin_only
async def _ws_audit_list(hass, connection, msg, coordinator):
connection.send_result(msg["id"], {"entries": coordinator.storage.get_audit_log()})
connection.send_result(
msg["id"],
{
"entries": coordinator.storage.get_audit_log(),
# Non-zero means older entries have aged out of the capped log.
"dropped": coordinator.storage.get_audit_dropped_count(),
},
)
@websocket_api.websocket_command({vol.Required("type"): WS_AUDIT_CLEAR})
@@ -2693,6 +2801,9 @@ _COMMANDS = (
_ws_add_task_group,
_ws_update_task_group,
_ws_remove_task_group,
_ws_add_custom_sound,
_ws_rename_custom_sound,
_ws_remove_custom_sound,
_ws_update_settings,
_ws_complete_bonus_subtask,
_ws_approve_chore,
+34 -1
View File
@@ -764,6 +764,15 @@
"panel.reward_cost_label": "Kosten ({points_name})",
"panel.reward_description_label": "Beschreibung (optional)",
"panel.reward_expires_hint": "Gesammelte Punkte werden um Mitternacht zurückerstattet.",
"panel.reward_timelock_section": "Erweitert — Zeitsperre",
"panel.reward_timelock_label": "Einlösen zeitlich begrenzen",
"panel.reward_timelock_hint": "Die Belohnung bleibt sichtbar, kann aber nur innerhalb des festgelegten Zeitfensters eingelöst werden. Punkte ansparen ist nie blockiert.",
"panel.reward_timelock_days_label": "Tage",
"panel.reward_timelock_days_hint": "Alle Tage aus lassen, wenn es keine Tagesbeschränkung geben soll.",
"panel.reward_timelock_from_label": "Von",
"panel.reward_timelock_until_label": "Bis",
"panel.reward_timelock_times_hint": "Beide Felder leer lassen für jede Uhrzeit. Die Endzeit ist exklusiv, 07:00–19:00 endet also um 19:00 Uhr. Ist der Start später als das Ende, läuft das Fenster über Mitternacht.",
"panel.reward_timelock_badge": "Zeitgesperrt",
"panel.reward_expires_label": "Läuft ab (leer = nie)",
"panel.reward_icon_label": "Symbol",
"panel.reward_is_jackpot_hint": "Beiträge aller Kinder für eine gemeinsame Belohnung zusammenlegen.",
@@ -799,6 +808,8 @@
"panel.settings_parents_title": "Eltern (ohne Admin-Rechte)",
"panel.settings_parents_hint": "Geben Sie einem Haushaltsmitglied ohne Admin-Rechte die tägliche Kontrolle — Aufgaben bestätigen, Punkte anpassen, Belohnungen freigeben — ohne Home-Assistant-Administratorrechte. Dieses Admin-Panel können sie weiterhin nicht öffnen und die Konfiguration nicht ändern.",
"panel.settings_parents_empty": "Keine Home-Assistant-Benutzer ohne Admin-Rechte gefunden. Erstellen Sie zuerst einen normalen HA-Benutzer und wählen Sie ihn dann hier aus.",
"panel.settings_require_linked_child_label": "Verknüpftes Konto pro Kind verlangen",
"panel.settings_require_linked_child_hint": "Nur das eigene Home-Assistant-Konto eines Kindes darf für dieses Kind handeln. Für ein gemeinsames Familien-Tablet, auf dem ein Konto für alle genutzt wird, ausgeschaltet lassen. Eltern und Admins können immer für jedes Kind handeln.",
"panel.settings_evening_hint": "Standard 17:00 – 21:00",
"panel.settings_evening_label": "Abend",
"panel.settings_history_title": "Verlauf & Serien",
@@ -1217,6 +1228,8 @@
"rewards.reward_count_plural": "{count} Belohnungen",
"rewards.reward_count_singular": "{count} Belohnung",
"rewards.sold_out": "Ausverkauft",
"rewards.available_when": "Verfügbar {window}",
"rewards.locked_now": "Derzeit nicht verfügbar",
"rewards.spendable_balance": "Ausgebbar",
"rewards.unavailable_hint": "Diese Belohnung kann nicht mehr beansprucht werden",
"streak.achievement.fifty_desc": "Erledige 50 Aufgaben",
@@ -1761,5 +1774,25 @@
"child.editor.pre_reader_points": "Punktzahl statt Sterne im Bildmodus anzeigen",
"common.design.accessible": "Barrierefrei",
"child.editor.include_anytime_chores": "Jederzeit-Aufgaben einbeziehen",
"child.editor.include_anytime_chores_helper": "Jederzeit-Aufgaben erscheinen normalerweise in jeder Zeitkategorie – deaktivieren, um sie auszublenden"
"child.editor.include_anytime_chores_helper": "Jederzeit-Aufgaben erscheinen normalerweise in jeder Zeitkategorie – deaktivieren, um sie auszublenden",
"panel.settings_sounds_title": "Abschlussgeräusche",
"panel.settings_sounds_hint": "Eigene Soundclips hochladen und pro Aufgabe auswählen.",
"panel.settings_sounds_empty": "Noch keine eigenen Geräusche.",
"panel.settings_sounds_formats": "MP3, OGG, WAV oder M4A. Max. 1 MB pro Datei.",
"panel.sound_upload": "Geräusch hochladen",
"panel.sound_uploading": "Wird hochgeladen…",
"panel.sound_uploaded": "Geräusch hinzugefügt",
"panel.sound_upload_failed": "Upload fehlgeschlagen",
"panel.sound_too_large": "Dieses Geräusch ist zu groß (max. 1 MB).",
"panel.sound_bad_type": "Wähle eine MP3-, OGG-, WAV- oder M4A-Datei.",
"panel.sound_storage_full": "Der Speicher für Geräusche ist voll. Lösche ein Geräusch und versuche es erneut.",
"panel.sound_default_name": "Mein Geräusch",
"panel.sound_preview": "Abspielen",
"panel.sound_rename": "Umbenennen",
"panel.sound_rename_prompt": "Name für dieses Geräusch:",
"panel.sound_name_required": "Gib dem Geräusch einen Namen.",
"panel.sound_delete": "Löschen",
"panel.sound_delete_confirm": "Geräusch „{name}“ löschen? Aufgaben, die es nutzen, erhalten wieder das Standardgeräusch.",
"panel.sound_deleted": "Geräusch gelöscht",
"panel.sound_deleted_reset": "Geräusch gelöscht. {count} Aufgabe(n) auf das Standardgeräusch zurückgesetzt."
}
@@ -802,6 +802,15 @@
"panel.reward_cost_label": "Cost ({points_name})",
"panel.reward_description_label": "Description (optional)",
"panel.reward_expires_hint": "Pooled points are refunded at midnight.",
"panel.reward_timelock_section": "Advanced — time lock",
"panel.reward_timelock_label": "Limit when this can be claimed",
"panel.reward_timelock_hint": "The reward stays visible, but children can only claim it inside the window you set. Saving points into a pool reward is never blocked.",
"panel.reward_timelock_days_label": "Days",
"panel.reward_timelock_days_hint": "Leave every day off for no day restriction.",
"panel.reward_timelock_from_label": "From",
"panel.reward_timelock_until_label": "Until",
"panel.reward_timelock_times_hint": "Leave both blank for any time of day. The end time is exclusive, so 07:00–19:00 stops at 19:00. A start later than the end spans midnight.",
"panel.reward_timelock_badge": "Time-locked",
"panel.reward_expires_label": "Expires (blank = never)",
"panel.reward_icon_label": "Icon",
"panel.reward_is_jackpot_hint": "Combine all children's contributions toward one shared reward.",
@@ -837,6 +846,8 @@
"panel.settings_parents_title": "Parents (no admin rights)",
"panel.settings_parents_hint": "Give a non-admin household member day-to-day control — approve chores, adjust points, confirm rewards — without Home Assistant admin rights. They still can't open this admin panel or change configuration.",
"panel.settings_parents_empty": "No non-admin Home Assistant users found. Create a standard HA user first, then tick them here.",
"panel.settings_require_linked_child_label": "Require a linked account per child",
"panel.settings_require_linked_child_hint": "Only let a child's own Home Assistant account act as that child. Leave off for a shared family tablet where one account is used for everyone. Parents and admins can always act for any child.",
"panel.settings_evening_hint": "Default 17:00 – 21:00",
"panel.settings_evening_label": "Evening",
"panel.settings_history_title": "History & streaks",
@@ -1270,6 +1281,8 @@
"rewards.reward_count_plural": "{count} rewards",
"rewards.reward_count_singular": "{count} reward",
"rewards.sold_out": "Sold Out",
"rewards.available_when": "Available {window}",
"rewards.locked_now": "Not available right now",
"rewards.spendable_balance": "Spendable",
"rewards.unavailable_hint": "This reward can no longer be claimed",
"streak.achievement.fifty_desc": "Complete 50 chores",
@@ -1761,5 +1774,25 @@
"child.editor.pre_reader_points": "Show point values instead of stars in picture mode",
"common.design.accessible": "Accessible",
"child.editor.include_anytime_chores": "Include anytime chores",
"child.editor.include_anytime_chores_helper": "Anytime chores normally appear under every time category — turn this off to hide them"
"child.editor.include_anytime_chores_helper": "Anytime chores normally appear under every time category — turn this off to hide them",
"panel.settings_sounds_title": "Completion sounds",
"panel.settings_sounds_hint": "Upload your own sound clips and pick them per chore.",
"panel.settings_sounds_empty": "No custom sounds yet.",
"panel.settings_sounds_formats": "MP3, OGG, WAV or M4A. Max 1 MB each.",
"panel.sound_upload": "Upload sound",
"panel.sound_uploading": "Uploading…",
"panel.sound_uploaded": "Sound added",
"panel.sound_upload_failed": "Upload failed",
"panel.sound_too_large": "That sound is too large (max 1 MB).",
"panel.sound_bad_type": "Choose an MP3, OGG, WAV or M4A file.",
"panel.sound_storage_full": "Sound storage is full. Delete a sound and try again.",
"panel.sound_default_name": "My sound",
"panel.sound_preview": "Play",
"panel.sound_rename": "Rename",
"panel.sound_rename_prompt": "Name for this sound:",
"panel.sound_name_required": "Give the sound a name.",
"panel.sound_delete": "Delete",
"panel.sound_delete_confirm": "Delete the sound “{name}”? Any chore using it goes back to the default sound.",
"panel.sound_deleted": "Sound deleted",
"panel.sound_deleted_reset": "Sound deleted. {count} chore(s) reset to the default sound."
}
+34 -1
View File
@@ -802,6 +802,15 @@
"panel.reward_cost_label": "Cost ({points_name})",
"panel.reward_description_label": "Description (optional)",
"panel.reward_expires_hint": "Pooled points are refunded at midnight.",
"panel.reward_timelock_section": "Advanced — time lock",
"panel.reward_timelock_label": "Limit when this can be claimed",
"panel.reward_timelock_hint": "The reward stays visible, but children can only claim it inside the window you set. Saving points into a pool reward is never blocked.",
"panel.reward_timelock_days_label": "Days",
"panel.reward_timelock_days_hint": "Leave every day off for no day restriction.",
"panel.reward_timelock_from_label": "From",
"panel.reward_timelock_until_label": "Until",
"panel.reward_timelock_times_hint": "Leave both blank for any time of day. The end time is exclusive, so 07:00–19:00 stops at 19:00. A start later than the end spans midnight.",
"panel.reward_timelock_badge": "Time-locked",
"panel.reward_expires_label": "Expires (blank = never)",
"panel.reward_icon_label": "Icon",
"panel.reward_is_jackpot_hint": "Combine all children's contributions toward one shared reward.",
@@ -837,6 +846,8 @@
"panel.settings_parents_title": "Parents (no admin rights)",
"panel.settings_parents_hint": "Give a non-admin household member day-to-day control — approve chores, adjust points, confirm rewards — without Home Assistant admin rights. They still can't open this admin panel or change configuration.",
"panel.settings_parents_empty": "No non-admin Home Assistant users found. Create a standard HA user first, then tick them here.",
"panel.settings_require_linked_child_label": "Require a linked account per child",
"panel.settings_require_linked_child_hint": "Only let a child's own Home Assistant account act as that child. Leave off for a shared family tablet where one account is used for everyone. Parents and admins can always act for any child.",
"panel.settings_evening_hint": "Default 17:00 – 21:00",
"panel.settings_evening_label": "Evening",
"panel.settings_history_title": "History & streaks",
@@ -1270,6 +1281,8 @@
"rewards.reward_count_plural": "{count} rewards",
"rewards.reward_count_singular": "{count} reward",
"rewards.sold_out": "Sold Out",
"rewards.available_when": "Available {window}",
"rewards.locked_now": "Not available right now",
"rewards.spendable_balance": "Spendable",
"rewards.unavailable_hint": "This reward can no longer be claimed",
"streak.achievement.fifty_desc": "Complete 50 chores",
@@ -1761,5 +1774,25 @@
"child.editor.pre_reader_points": "Show point values instead of stars in picture mode",
"common.design.accessible": "Accessible",
"child.editor.include_anytime_chores": "Include anytime chores",
"child.editor.include_anytime_chores_helper": "Anytime chores normally appear under every time category — turn this off to hide them"
"child.editor.include_anytime_chores_helper": "Anytime chores normally appear under every time category — turn this off to hide them",
"panel.settings_sounds_title": "Completion sounds",
"panel.settings_sounds_hint": "Upload your own sound clips and pick them per chore.",
"panel.settings_sounds_empty": "No custom sounds yet.",
"panel.settings_sounds_formats": "MP3, OGG, WAV or M4A. Max 1 MB each.",
"panel.sound_upload": "Upload sound",
"panel.sound_uploading": "Uploading…",
"panel.sound_uploaded": "Sound added",
"panel.sound_upload_failed": "Upload failed",
"panel.sound_too_large": "That sound is too large (max 1 MB).",
"panel.sound_bad_type": "Choose an MP3, OGG, WAV or M4A file.",
"panel.sound_storage_full": "Sound storage is full. Delete a sound and try again.",
"panel.sound_default_name": "My sound",
"panel.sound_preview": "Play",
"panel.sound_rename": "Rename",
"panel.sound_rename_prompt": "Name for this sound:",
"panel.sound_name_required": "Give the sound a name.",
"panel.sound_delete": "Delete",
"panel.sound_delete_confirm": "Delete the sound “{name}”? Any chore using it goes back to the default sound.",
"panel.sound_deleted": "Sound deleted",
"panel.sound_deleted_reset": "Sound deleted. {count} chore(s) reset to the default sound."
}
+34 -1
View File
@@ -764,6 +764,15 @@
"panel.reward_cost_label": "Coût ({points_name})",
"panel.reward_description_label": "Description (optionnel)",
"panel.reward_expires_hint": "Les points en cagnotte sont remboursés à minuit.",
"panel.reward_timelock_section": "Avancé — verrouillage horaire",
"panel.reward_timelock_label": "Limiter quand elle peut être réclamée",
"panel.reward_timelock_hint": "La récompense reste visible, mais ne peut être réclamée que pendant la plage définie. Mettre des points en cagnotte n'est jamais bloqué.",
"panel.reward_timelock_days_label": "Jours",
"panel.reward_timelock_days_hint": "Laissez tous les jours désactivés pour ne restreindre aucun jour.",
"panel.reward_timelock_from_label": "De",
"panel.reward_timelock_until_label": "À",
"panel.reward_timelock_times_hint": "Laissez les deux champs vides pour autoriser toute heure. L'heure de fin est exclue : 07:00–19:00 s'arrête à 19:00. Si le début est après la fin, la plage passe minuit.",
"panel.reward_timelock_badge": "Horaire limité",
"panel.reward_expires_label": "Expire (vide = jamais)",
"panel.reward_icon_label": "Icône",
"panel.reward_is_jackpot_hint": "Combiner les contributions de tous les enfants vers une récompense partagée.",
@@ -799,6 +808,8 @@
"panel.settings_parents_title": "Parents (sans droits admin)",
"panel.settings_parents_hint": "Donnez à un membre du foyer non-administrateur le contrôle quotidien — valider les tâches, ajuster les points, confirmer les récompenses — sans droits d’administrateur Home Assistant. Il ne pourra toujours pas ouvrir ce panneau d’administration ni modifier la configuration.",
"panel.settings_parents_empty": "Aucun utilisateur Home Assistant non-administrateur trouvé. Créez d’abord un utilisateur HA standard, puis cochez-le ici.",
"panel.settings_require_linked_child_label": "Exiger un compte lié par enfant",
"panel.settings_require_linked_child_hint": "Seul le compte Home Assistant d'un enfant peut agir en son nom. Laissez désactivé pour une tablette familiale partagée où un seul compte sert à tous. Les parents et les administrateurs peuvent toujours agir pour n'importe quel enfant.",
"panel.settings_evening_hint": "Par défaut 17:00 – 21:00",
"panel.settings_evening_label": "Soir",
"panel.settings_history_title": "Historique et séries",
@@ -1217,6 +1228,8 @@
"rewards.reward_count_plural": "{count} récompenses",
"rewards.reward_count_singular": "{count} récompense",
"rewards.sold_out": "Épuisée",
"rewards.available_when": "Disponible {window}",
"rewards.locked_now": "Indisponible pour le moment",
"rewards.spendable_balance": "Disponible",
"rewards.unavailable_hint": "Cette récompense ne peut plus être réclamée",
"streak.achievement.fifty_desc": "Accomplis 50 tâches",
@@ -1761,5 +1774,25 @@
"child.editor.pre_reader_points": "Afficher les points au lieu des étoiles en mode images",
"common.design.accessible": "Accessible",
"child.editor.include_anytime_chores": "Inclure les tâches « n'importe quand »",
"child.editor.include_anytime_chores_helper": "Les tâches « n'importe quand » apparaissent normalement dans toutes les catégories de temps — désactivez pour les masquer"
"child.editor.include_anytime_chores_helper": "Les tâches « n'importe quand » apparaissent normalement dans toutes les catégories de temps — désactivez pour les masquer",
"panel.settings_sounds_title": "Sons de fin de tâche",
"panel.settings_sounds_hint": "Importez vos propres extraits sonores et choisissez-les par tâche.",
"panel.settings_sounds_empty": "Aucun son personnalisé pour le moment.",
"panel.settings_sounds_formats": "MP3, OGG, WAV ou M4A. 1 Mo maximum par fichier.",
"panel.sound_upload": "Importer un son",
"panel.sound_uploading": "Import en cours…",
"panel.sound_uploaded": "Son ajouté",
"panel.sound_upload_failed": "Échec de l’import",
"panel.sound_too_large": "Ce son est trop volumineux (1 Mo maximum).",
"panel.sound_bad_type": "Choisissez un fichier MP3, OGG, WAV ou M4A.",
"panel.sound_storage_full": "L’espace de stockage des sons est plein. Supprimez un son et réessayez.",
"panel.sound_default_name": "Mon son",
"panel.sound_preview": "Écouter",
"panel.sound_rename": "Renommer",
"panel.sound_rename_prompt": "Nom de ce son :",
"panel.sound_name_required": "Donnez un nom au son.",
"panel.sound_delete": "Supprimer",
"panel.sound_delete_confirm": "Supprimer le son « {name} » ? Les tâches qui l’utilisent reviendront au son par défaut.",
"panel.sound_deleted": "Son supprimé",
"panel.sound_deleted_reset": "Son supprimé. {count} tâche(s) remise(s) au son par défaut."
}
+34 -1
View File
@@ -764,6 +764,15 @@
"panel.reward_cost_label": "Kostnad ({points_name})",
"panel.reward_description_label": "Beskrivelse (valgfritt)",
"panel.reward_expires_hint": "Sparede poeng tilbakebetales ved midnatt.",
"panel.reward_timelock_section": "Avansert — tidslås",
"panel.reward_timelock_label": "Begrens når den kan kreves",
"panel.reward_timelock_hint": "Belønningen vises fortsatt, men kan bare kreves innenfor tidsrommet du angir. Å spare poeng blir aldri blokkert.",
"panel.reward_timelock_days_label": "Dager",
"panel.reward_timelock_days_hint": "La alle dagene være av for ingen dagsbegrensning.",
"panel.reward_timelock_from_label": "Fra",
"panel.reward_timelock_until_label": "Til",
"panel.reward_timelock_times_hint": "La begge feltene stå tomme for når som helst på døgnet. Sluttidspunktet er ekskludert, så 07:00–19:00 stopper 19:00. Er starten senere enn slutten, går tidsrommet over midnatt.",
"panel.reward_timelock_badge": "Tidslåst",
"panel.reward_expires_label": "Utløper (tomt = aldri)",
"panel.reward_icon_label": "Ikon",
"panel.reward_is_jackpot_hint": "Kombiner alle barnas bidrag mot én felles belønning.",
@@ -799,6 +808,8 @@
"panel.settings_parents_title": "Foreldre (uten adminrettigheter)",
"panel.settings_parents_hint": "Gi et husstandsmedlem uten adminrettigheter daglig kontroll — godkjenne oppgaver, justere poeng, bekrefte belønninger — uten Home Assistant-adminrettigheter. De kan fortsatt ikke åpne dette adminpanelet eller endre konfigurasjonen.",
"panel.settings_parents_empty": "Fant ingen Home Assistant-brukere uten adminrettigheter. Opprett en vanlig HA-bruker først, og huk deretter av her.",
"panel.settings_require_linked_child_label": "Krev en koblet konto per barn",
"panel.settings_require_linked_child_hint": "La bare barnets egen Home Assistant-konto handle som det barnet. La stå av for et delt familienettbrett der én konto brukes av alle. Foreldre og administratorer kan alltid handle for alle barn.",
"panel.settings_evening_hint": "Standard 17:00 – 21:00",
"panel.settings_evening_label": "Kveld",
"panel.settings_history_title": "Historikk og serier",
@@ -1217,6 +1228,8 @@
"rewards.reward_count_plural": "{count} belønninger",
"rewards.reward_count_singular": "{count} belønning",
"rewards.sold_out": "Utsolgt",
"rewards.available_when": "Tilgjengelig {window}",
"rewards.locked_now": "Ikke tilgjengelig akkurat nå",
"rewards.spendable_balance": "Disponibelt",
"rewards.unavailable_hint": "Denne belønningen kan ikke lenger kreves",
"streak.achievement.fifty_desc": "Fullfør 50 oppgaver",
@@ -1761,5 +1774,25 @@
"child.editor.pre_reader_points": "Vis poengverdier i stedet for stjerner i bildemodus",
"common.design.accessible": "Tilgjengelig",
"child.editor.include_anytime_chores": "Ta med «når som helst»-oppgaver",
"child.editor.include_anytime_chores_helper": "«Når som helst»-oppgaver vises normalt i alle tidskategorier — slå av for å skjule dem"
"child.editor.include_anytime_chores_helper": "«Når som helst»-oppgaver vises normalt i alle tidskategorier — slå av for å skjule dem",
"panel.settings_sounds_title": "Fullføringslyder",
"panel.settings_sounds_hint": "Last opp egne lydklipp og velg dem per oppgave.",
"panel.settings_sounds_empty": "Ingen egne lyder ennå.",
"panel.settings_sounds_formats": "MP3, OGG, WAV eller M4A. Maks 1 MB per fil.",
"panel.sound_upload": "Last opp lyd",
"panel.sound_uploading": "Laster opp…",
"panel.sound_uploaded": "Lyd lagt til",
"panel.sound_upload_failed": "Opplasting mislyktes",
"panel.sound_too_large": "Denne lyden er for stor (maks 1 MB).",
"panel.sound_bad_type": "Velg en MP3-, OGG-, WAV- eller M4A-fil.",
"panel.sound_storage_full": "Lagringsplassen for lyder er full. Slett en lyd og prøv igjen.",
"panel.sound_default_name": "Min lyd",
"panel.sound_preview": "Spill av",
"panel.sound_rename": "Gi nytt navn",
"panel.sound_rename_prompt": "Navn på denne lyden:",
"panel.sound_name_required": "Gi lyden et navn.",
"panel.sound_delete": "Slett",
"panel.sound_delete_confirm": "Slette lyden «{name}»? Oppgaver som bruker den, går tilbake til standardlyden.",
"panel.sound_deleted": "Lyd slettet",
"panel.sound_deleted_reset": "Lyd slettet. {count} oppgave(r) tilbakestilt til standardlyden."
}
+34 -1
View File
@@ -764,6 +764,15 @@
"panel.reward_cost_label": "Kostnad ({points_name})",
"panel.reward_description_label": "Skildring (valfri)",
"panel.reward_expires_hint": "Samla poeng vert refunderte ved midnatt.",
"panel.reward_timelock_section": "Avansert — tidslås",
"panel.reward_timelock_label": "Avgrens når ho kan krevjast",
"panel.reward_timelock_hint": "Løninga er framleis synleg, men kan berre krevjast innanfor tidsrommet du set. Å spare poeng vert aldri blokkert.",
"panel.reward_timelock_days_label": "Dagar",
"panel.reward_timelock_days_hint": "Lat alle dagane vere av for inga dagsavgrensing.",
"panel.reward_timelock_from_label": "Frå",
"panel.reward_timelock_until_label": "Til",
"panel.reward_timelock_times_hint": "Lat begge felta stå tomme for kva tid som helst på døgnet. Slutt-tidspunktet er ekskludert, så 07:00–19:00 stoppar 19:00. Er starten seinare enn slutten, går tidsrommet over midnatt.",
"panel.reward_timelock_badge": "Tidslåst",
"panel.reward_expires_label": "Går ut (tomt = aldri)",
"panel.reward_icon_label": "Ikon",
"panel.reward_is_jackpot_hint": "Slå saman bidrag frå alle born mot éin felles premie.",
@@ -799,6 +808,8 @@
"panel.settings_parents_title": "Foreldre (utan adminrettar)",
"panel.settings_parents_hint": "Gi eit husstandsmedlem utan adminrettar dagleg kontroll — godkjenne oppgåver, justere poeng, stadfeste løningar — utan Home Assistant-adminrettar. Dei kan framleis ikkje opne dette adminpanelet eller endre konfigurasjonen.",
"panel.settings_parents_empty": "Fann ingen Home Assistant-brukarar utan adminrettar. Opprett ein vanleg HA-brukar først, og hak deretter av her.",
"panel.settings_require_linked_child_label": "Krev ein kopla konto per barn",
"panel.settings_require_linked_child_hint": "Lat berre barnet sin eigen Home Assistant-konto handle som det barnet. Lat stå av for eit delt familienettbrett der éin konto vert brukt av alle. Foreldre og administratorar kan alltid handle for alle barn.",
"panel.settings_evening_hint": "Standard 17:00 – 21:00",
"panel.settings_evening_label": "Kveld",
"panel.settings_history_title": "Historikk og rekkjer",
@@ -1217,6 +1228,8 @@
"rewards.reward_count_plural": "{count} løningar",
"rewards.reward_count_singular": "{count} løning",
"rewards.sold_out": "Utseld",
"rewards.available_when": "Tilgjengeleg {window}",
"rewards.locked_now": "Ikkje tilgjengeleg akkurat no",
"rewards.spendable_balance": "Tilgjengeleg",
"rewards.unavailable_hint": "Denne løninga kan ikkje lenger krevjast",
"streak.achievement.fifty_desc": "Fullfør 50 oppgåver",
@@ -1761,5 +1774,25 @@
"child.editor.pre_reader_points": "Vis poengverdiar i staden for stjerner i biletemodus",
"common.design.accessible": "Tilgjengeleg",
"child.editor.include_anytime_chores": "Ta med «kva tid som helst»-oppgåver",
"child.editor.include_anytime_chores_helper": "«Kva tid som helst»-oppgåver vert normalt viste i alle tidskategoriar — slå av for å skjule dei"
"child.editor.include_anytime_chores_helper": "«Kva tid som helst»-oppgåver vert normalt viste i alle tidskategoriar — slå av for å skjule dei",
"panel.settings_sounds_title": "Fullføringslydar",
"panel.settings_sounds_hint": "Last opp eigne lydklipp og vel dei per oppgåve.",
"panel.settings_sounds_empty": "Ingen eigne lydar ennå.",
"panel.settings_sounds_formats": "MP3, OGG, WAV eller M4A. Maks 1 MB per fil.",
"panel.sound_upload": "Last opp lyd",
"panel.sound_uploading": "Lastar opp…",
"panel.sound_uploaded": "Lyd lagt til",
"panel.sound_upload_failed": "Opplasting mislukkast",
"panel.sound_too_large": "Denne lyden er for stor (maks 1 MB).",
"panel.sound_bad_type": "Vel ei MP3-, OGG-, WAV- eller M4A-fil.",
"panel.sound_storage_full": "Lagringsplassen for lydar er full. Slett ein lyd og prøv igjen.",
"panel.sound_default_name": "Lyden min",
"panel.sound_preview": "Spel av",
"panel.sound_rename": "Gi nytt namn",
"panel.sound_rename_prompt": "Namn på denne lyden:",
"panel.sound_name_required": "Gi lyden eit namn.",
"panel.sound_delete": "Slett",
"panel.sound_delete_confirm": "Slette lyden «{name}»? Oppgåver som brukar han, går tilbake til standardlyden.",
"panel.sound_deleted": "Lyd sletta",
"panel.sound_deleted_reset": "Lyd sletta. {count} oppgåve(r) tilbakestilt til standardlyden."
}
@@ -764,6 +764,15 @@
"panel.reward_cost_label": "Custo ({points_name})",
"panel.reward_description_label": "Descrição (opcional)",
"panel.reward_expires_hint": "Pontos acumulados são devolvidos à meia-noite.",
"panel.reward_timelock_section": "Avançado — bloqueio por horário",
"panel.reward_timelock_label": "Limitar quando pode ser resgatada",
"panel.reward_timelock_hint": "A recompensa continua visível, mas só pode ser resgatada dentro do período definido. Acumular pontos nunca é bloqueado.",
"panel.reward_timelock_days_label": "Dias",
"panel.reward_timelock_days_hint": "Deixe todos os dias desligados para não restringir dias.",
"panel.reward_timelock_from_label": "Das",
"panel.reward_timelock_until_label": "Até",
"panel.reward_timelock_times_hint": "Deixe ambos em branco para qualquer horário do dia. O horário final é exclusivo, então 07:00–19:00 termina às 19:00. Se o início for depois do fim, o período atravessa a meia-noite.",
"panel.reward_timelock_badge": "Com bloqueio de horário",
"panel.reward_expires_label": "Expira (vazio = nunca)",
"panel.reward_icon_label": "Ícone",
"panel.reward_is_jackpot_hint": "Combina contribuições de todas as crianças para uma recompensa compartilhada.",
@@ -799,6 +808,8 @@
"panel.settings_parents_title": "Pais (sem direitos de administrador)",
"panel.settings_parents_hint": "Dê a um membro da família sem permissões de administrador o controle do dia a dia — aprovar tarefas, ajustar pontos, confirmar recompensas — sem direitos de administrador do Home Assistant. Ele ainda não poderá abrir este painel de administração nem alterar a configuração.",
"panel.settings_parents_empty": "Nenhum usuário do Home Assistant sem permissões de administrador foi encontrado. Crie primeiro um usuário HA padrão e depois marque-o aqui.",
"panel.settings_require_linked_child_label": "Exigir uma conta vinculada por criança",
"panel.settings_require_linked_child_hint": "Só permite que a conta do Home Assistant da própria criança atue como essa criança. Deixe desligado em um tablet familiar compartilhado em que uma conta serve para todos. Pais e administradores sempre podem atuar por qualquer criança.",
"panel.settings_evening_hint": "Padrão 17:00 – 21:00",
"panel.settings_evening_label": "Noite",
"panel.settings_history_title": "Histórico e sequências",
@@ -1217,6 +1228,8 @@
"rewards.reward_count_plural": "{count} recompensas",
"rewards.reward_count_singular": "{count} recompensa",
"rewards.sold_out": "Esgotada",
"rewards.available_when": "Disponível {window}",
"rewards.locked_now": "Indisponível no momento",
"rewards.spendable_balance": "Disponível",
"rewards.unavailable_hint": "Essa recompensa não pode mais ser reclamada",
"streak.achievement.fifty_desc": "Conclui 50 tarefas",
@@ -1761,5 +1774,25 @@
"child.editor.pre_reader_points": "Mostrar pontos em vez de estrelas no modo imagens",
"common.design.accessible": "Acessível",
"child.editor.include_anytime_chores": "Incluir tarefas de qualquer hora",
"child.editor.include_anytime_chores_helper": "As tarefas de qualquer hora aparecem normalmente em todas as categorias de hora — desative para ocultá-las"
"child.editor.include_anytime_chores_helper": "As tarefas de qualquer hora aparecem normalmente em todas as categorias de hora — desative para ocultá-las",
"panel.settings_sounds_title": "Sons de conclusão",
"panel.settings_sounds_hint": "Envie seus próprios clipes de som e escolha-os por tarefa.",
"panel.settings_sounds_empty": "Ainda não há sons personalizados.",
"panel.settings_sounds_formats": "MP3, OGG, WAV ou M4A. Máximo de 1 MB cada.",
"panel.sound_upload": "Enviar som",
"panel.sound_uploading": "Enviando…",
"panel.sound_uploaded": "Som adicionado",
"panel.sound_upload_failed": "Falha no envio",
"panel.sound_too_large": "Esse som é muito grande (máx. 1 MB).",
"panel.sound_bad_type": "Escolha um arquivo MP3, OGG, WAV ou M4A.",
"panel.sound_storage_full": "O armazenamento de sons está cheio. Exclua um som e tente novamente.",
"panel.sound_default_name": "Meu som",
"panel.sound_preview": "Reproduzir",
"panel.sound_rename": "Renomear",
"panel.sound_rename_prompt": "Nome deste som:",
"panel.sound_name_required": "Dê um nome ao som.",
"panel.sound_delete": "Excluir",
"panel.sound_delete_confirm": "Excluir o som “{name}”? As tarefas que o usam voltam ao som padrão.",
"panel.sound_deleted": "Som excluído",
"panel.sound_deleted_reset": "Som excluído. {count} tarefa(s) redefinida(s) para o som padrão."
}
+34 -1
View File
@@ -769,6 +769,15 @@
"panel.reward_cost_label": "Custo ({points_name})",
"panel.reward_description_label": "Descrição (opcional)",
"panel.reward_expires_hint": "Os pontos acumulados são devolvidos à meia-noite.",
"panel.reward_timelock_section": "Avançado — bloqueio horário",
"panel.reward_timelock_label": "Limitar quando pode ser reclamada",
"panel.reward_timelock_hint": "A recompensa continua visível, mas só pode ser reclamada dentro do período definido. Acumular pontos nunca é bloqueado.",
"panel.reward_timelock_days_label": "Dias",
"panel.reward_timelock_days_hint": "Deixe todos os dias desligados para não restringir dias.",
"panel.reward_timelock_from_label": "Das",
"panel.reward_timelock_until_label": "Até",
"panel.reward_timelock_times_hint": "Deixe ambos em branco para qualquer hora do dia. A hora final é exclusiva, por isso 07:00–19:00 termina às 19:00. Se o início for depois do fim, o período passa a meia-noite.",
"panel.reward_timelock_badge": "Com bloqueio horário",
"panel.reward_expires_label": "Expira (vazio = nunca)",
"panel.reward_icon_label": "Ícone",
"panel.reward_is_jackpot_hint": "Combinar as contribuições de todas as crianças para uma recompensa partilhada.",
@@ -804,6 +813,8 @@
"panel.settings_parents_title": "Pais (sem direitos de administrador)",
"panel.settings_parents_hint": "Dê a um membro do agregado sem permissões de administrador o controlo do dia a dia — aprovar tarefas, ajustar pontos, confirmar recompensas — sem direitos de administrador do Home Assistant. Continuará sem poder abrir este painel de administração nem alterar a configuração.",
"panel.settings_parents_empty": "Não foram encontrados utilizadores do Home Assistant sem permissões de administrador. Crie primeiro um utilizador HA normal e depois selecione-o aqui.",
"panel.settings_require_linked_child_label": "Exigir uma conta associada por criança",
"panel.settings_require_linked_child_hint": "Só permite que a conta do Home Assistant da própria criança atue como essa criança. Deixe desligado num tablet familiar partilhado em que uma conta serve todos. Os pais e administradores podem sempre atuar por qualquer criança.",
"panel.settings_evening_hint": "Predefinição 17:00 – 21:00",
"panel.settings_evening_label": "Noite",
"panel.settings_history_title": "Histórico e sequências",
@@ -1222,6 +1233,8 @@
"rewards.reward_count_plural": "{count} recompensas",
"rewards.reward_count_singular": "{count} recompensa",
"rewards.sold_out": "Esgotada",
"rewards.available_when": "Disponível {window}",
"rewards.locked_now": "Indisponível neste momento",
"rewards.spendable_balance": "Disponível",
"rewards.unavailable_hint": "Esta recompensa já não pode ser reclamada",
"streak.achievement.fifty_desc": "Conclui 50 tarefas",
@@ -1761,5 +1774,25 @@
"child.editor.pre_reader_points": "Mostrar pontos em vez de estrelas no modo imagens",
"common.design.accessible": "Acessível",
"child.editor.include_anytime_chores": "Incluir tarefas de qualquer hora",
"child.editor.include_anytime_chores_helper": "As tarefas de qualquer hora aparecem normalmente em todas as categorias de hora — desative para as ocultar"
"child.editor.include_anytime_chores_helper": "As tarefas de qualquer hora aparecem normalmente em todas as categorias de hora — desative para as ocultar",
"panel.settings_sounds_title": "Sons de conclusão",
"panel.settings_sounds_hint": "Carregue os seus próprios clipes de som e escolha-os por tarefa.",
"panel.settings_sounds_empty": "Ainda não há sons personalizados.",
"panel.settings_sounds_formats": "MP3, OGG, WAV ou M4A. Máximo de 1 MB cada.",
"panel.sound_upload": "Carregar som",
"panel.sound_uploading": "A carregar…",
"panel.sound_uploaded": "Som adicionado",
"panel.sound_upload_failed": "Falha ao carregar",
"panel.sound_too_large": "Esse som é demasiado grande (máx. 1 MB).",
"panel.sound_bad_type": "Escolha um ficheiro MP3, OGG, WAV ou M4A.",
"panel.sound_storage_full": "O armazenamento de sons está cheio. Elimine um som e tente novamente.",
"panel.sound_default_name": "O meu som",
"panel.sound_preview": "Reproduzir",
"panel.sound_rename": "Mudar o nome",
"panel.sound_rename_prompt": "Nome deste som:",
"panel.sound_name_required": "Dê um nome ao som.",
"panel.sound_delete": "Eliminar",
"panel.sound_delete_confirm": "Eliminar o som “{name}”? As tarefas que o usam voltam ao som predefinido.",
"panel.sound_deleted": "Som eliminado",
"panel.sound_deleted_reset": "Som eliminado. {count} tarefa(s) reposta(s) para o som predefinido."
}
@@ -216,353 +216,26 @@ class TaskMateChildCard extends LitElement {
}
/**
* Get or create the AudioContext (lazy initialization)
* Must be called after user interaction due to browser autoplay policies
*/
_getAudioContext() {
if (!this._audioContext) {
this._audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
// Resume if suspended (required after user interaction)
if (this._audioContext.state === 'suspended') {
this._audioContext.resume();
}
return this._audioContext;
}
/**
* Play a completion sound using Web Audio API
* Generates fun synthesized sounds - no external files needed!
* @param {string} soundName - Name of the sound to play (coin, levelup, fanfare, chime, powerup)
* Play a completion sound.
*
* Delegates to the shared engine in taskmate-sounds.js — this card used to
* carry its own copy of every synthesised sound, which drifted out of sync
* with the other copy in taskmate-config-sounds.js.
*
* @param {string} soundName - built-in name, or "custom:<file>" (#856)
*/
_playSound(soundName) {
// Don't play if sound is "none" or not specified
if (!soundName || soundName === 'none') {
return;
}
try {
const ctx = this._getAudioContext();
const now = ctx.currentTime;
switch (soundName) {
case 'coin':
this._playCoinSound(ctx, now);
break;
case 'levelup':
this._playLevelUpSound(ctx, now);
break;
case 'fanfare':
this._playFanfareSound(ctx, now);
break;
case 'chime':
this._playChimeSound(ctx, now);
break;
case 'powerup':
this._playPowerUpSound(ctx, now);
break;
case 'undo':
this._playUndoSound(ctx, now);
break;
case 'fart1':
this._playAudioFile('fart1.mp3');
break;
case 'fart2':
this._playAudioFile('fart2.mp3');
break;
case 'fart3':
this._playAudioFile('fart3.mp3');
break;
case 'fart4':
this._playAudioFile('fart4.mp3');
break;
case 'fart5':
this._playAudioFile('fart5.mp3');
break;
case 'fart6':
this._playAudioFile('fart6.mp3');
break;
case 'fart7':
this._playAudioFile('fart7.mp3');
break;
case 'fart8':
this._playAudioFile('fart8.mp3');
break;
case 'fart9':
this._playAudioFile('fart9.mp3');
break;
case 'fart10':
this._playAudioFile('fart10.mp3');
break;
case 'fart_random': {
// Pick a random fart sound (1-10)
const randomFartNum = Math.floor(Math.random() * 10) + 1;
this._playAudioFile(`fart${randomFartNum}.mp3`);
break;
}
default:
this._playCoinSound(ctx, now);
}
} catch (e) {
}
const engine = window.__taskmate_sounds;
if (!engine) return;
engine.play(soundName, this._customSounds());
}
/**
* Coin collect sound - classic video game coin pickup
* Two quick ascending tones
*/
_playCoinSound(ctx, startTime) {
const masterGain = ctx.createGain();
masterGain.connect(ctx.destination);
masterGain.gain.value = 0.3;
// First tone (E6)
const osc1 = ctx.createOscillator();
const gain1 = ctx.createGain();
osc1.connect(gain1);
gain1.connect(masterGain);
osc1.frequency.value = 1318.5; // E6
osc1.type = 'square';
gain1.gain.setValueAtTime(0.5, startTime);
gain1.gain.exponentialRampToValueAtTime(0.01, startTime + 0.1);
osc1.start(startTime);
osc1.stop(startTime + 0.1);
// Second tone (B6) - higher
const osc2 = ctx.createOscillator();
const gain2 = ctx.createGain();
osc2.connect(gain2);
gain2.connect(masterGain);
osc2.frequency.value = 1975.5; // B6
osc2.type = 'square';
gain2.gain.setValueAtTime(0.5, startTime + 0.08);
gain2.gain.exponentialRampToValueAtTime(0.01, startTime + 0.25);
osc2.start(startTime + 0.08);
osc2.stop(startTime + 0.25);
}
/**
* Level up sound - triumphant ascending arpeggio
*/
_playLevelUpSound(ctx, startTime) {
const masterGain = ctx.createGain();
masterGain.connect(ctx.destination);
masterGain.gain.value = 0.25;
// C major arpeggio going up: C5, E5, G5, C6
const notes = [523.25, 659.25, 783.99, 1046.5];
const duration = 0.12;
notes.forEach((freq, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(masterGain);
osc.frequency.value = freq;
osc.type = 'square';
const noteStart = startTime + i * duration;
gain.gain.setValueAtTime(0.6, noteStart);
gain.gain.exponentialRampToValueAtTime(0.01, noteStart + duration + 0.1);
osc.start(noteStart);
osc.stop(noteStart + duration + 0.15);
});
// Final sustained chord
const chordNotes = [523.25, 659.25, 783.99]; // C major chord
const chordStart = startTime + notes.length * duration;
chordNotes.forEach((freq) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(masterGain);
osc.frequency.value = freq;
osc.type = 'triangle';
gain.gain.setValueAtTime(0.3, chordStart);
gain.gain.exponentialRampToValueAtTime(0.01, chordStart + 0.5);
osc.start(chordStart);
osc.stop(chordStart + 0.55);
});
}
/**
* Fanfare sound - celebratory trumpet-like fanfare
*/
_playFanfareSound(ctx, startTime) {
const masterGain = ctx.createGain();
masterGain.connect(ctx.destination);
masterGain.gain.value = 0.2;
// Fanfare pattern: G4, G4, G4, E4, G4, C5 (classic celebration pattern)
const pattern = [
{ freq: 392.00, duration: 0.1, delay: 0 }, // G4
{ freq: 392.00, duration: 0.1, delay: 0.12 }, // G4
{ freq: 392.00, duration: 0.15, delay: 0.24 }, // G4
{ freq: 329.63, duration: 0.15, delay: 0.42 }, // E4
{ freq: 392.00, duration: 0.15, delay: 0.6 }, // G4
{ freq: 523.25, duration: 0.4, delay: 0.78 }, // C5 (long final note)
];
pattern.forEach(({ freq, duration, delay }) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(masterGain);
osc.frequency.value = freq;
osc.type = 'sawtooth';
const noteStart = startTime + delay;
gain.gain.setValueAtTime(0.5, noteStart);
gain.gain.setValueAtTime(0.5, noteStart + duration * 0.8);
gain.gain.exponentialRampToValueAtTime(0.01, noteStart + duration);
osc.start(noteStart);
osc.stop(noteStart + duration + 0.05);
});
}
/**
* Chime sound - simple pleasant bell chime
*/
_playChimeSound(ctx, startTime) {
const masterGain = ctx.createGain();
masterGain.connect(ctx.destination);
masterGain.gain.value = 0.3;
// Bell-like sound using multiple harmonics
const fundamental = 880; // A5
const harmonics = [1, 2, 3, 4.2]; // Slight inharmonicity for bell-like quality
harmonics.forEach((harmonic, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(masterGain);
osc.frequency.value = fundamental * harmonic;
osc.type = 'sine';
// Higher harmonics decay faster
const amplitude = 0.5 / (i + 1);
const decayTime = 0.8 / (i + 1);
gain.gain.setValueAtTime(amplitude, startTime);
gain.gain.exponentialRampToValueAtTime(0.001, startTime + decayTime);
osc.start(startTime);
osc.stop(startTime + decayTime + 0.1);
});
}
/**
* Power up sound - ascending sweep with sparkle
*/
_playPowerUpSound(ctx, startTime) {
const masterGain = ctx.createGain();
masterGain.connect(ctx.destination);
masterGain.gain.value = 0.25;
// Ascending sweep
const osc1 = ctx.createOscillator();
const gain1 = ctx.createGain();
osc1.connect(gain1);
gain1.connect(masterGain);
osc1.type = 'sawtooth';
osc1.frequency.setValueAtTime(200, startTime);
osc1.frequency.exponentialRampToValueAtTime(1200, startTime + 0.3);
gain1.gain.setValueAtTime(0.4, startTime);
gain1.gain.exponentialRampToValueAtTime(0.01, startTime + 0.35);
osc1.start(startTime);
osc1.stop(startTime + 0.4);
// Sparkle notes at the end
const sparkleNotes = [1318.5, 1567.98, 1975.5]; // E6, G6, B6
sparkleNotes.forEach((freq, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(masterGain);
osc.frequency.value = freq;
osc.type = 'sine';
const noteStart = startTime + 0.25 + i * 0.05;
gain.gain.setValueAtTime(0.3, noteStart);
gain.gain.exponentialRampToValueAtTime(0.01, noteStart + 0.2);
osc.start(noteStart);
osc.stop(noteStart + 0.25);
});
}
/**
* Undo sound - sad descending "womp womp" style
* Two descending tones that sound disappointed/sad
*/
_playUndoSound(ctx, startTime) {
const masterGain = ctx.createGain();
masterGain.connect(ctx.destination);
masterGain.gain.value = 0.25;
// First "womp" - descending tone
const osc1 = ctx.createOscillator();
const gain1 = ctx.createGain();
osc1.connect(gain1);
gain1.connect(masterGain);
osc1.type = 'triangle';
osc1.frequency.setValueAtTime(311.13, startTime); // Eb4
osc1.frequency.exponentialRampToValueAtTime(233.08, startTime + 0.25); // Bb3
gain1.gain.setValueAtTime(0.6, startTime);
gain1.gain.exponentialRampToValueAtTime(0.3, startTime + 0.2);
gain1.gain.exponentialRampToValueAtTime(0.01, startTime + 0.3);
osc1.start(startTime);
osc1.stop(startTime + 0.35);
// Second "womp" - even lower descending tone (the sad part)
const osc2 = ctx.createOscillator();
const gain2 = ctx.createGain();
osc2.connect(gain2);
gain2.connect(masterGain);
osc2.type = 'triangle';
osc2.frequency.setValueAtTime(233.08, startTime + 0.3); // Bb3
osc2.frequency.exponentialRampToValueAtTime(155.56, startTime + 0.7); // Eb3
gain2.gain.setValueAtTime(0.5, startTime + 0.3);
gain2.gain.exponentialRampToValueAtTime(0.25, startTime + 0.55);
gain2.gain.exponentialRampToValueAtTime(0.01, startTime + 0.75);
osc2.start(startTime + 0.3);
osc2.stop(startTime + 0.8);
// Optional: add a subtle low vibrato for extra sadness
const osc3 = ctx.createOscillator();
const gain3 = ctx.createGain();
osc3.connect(gain3);
gain3.connect(masterGain);
osc3.type = 'sine';
osc3.frequency.setValueAtTime(116.54, startTime + 0.5); // Bb2 (sub bass)
gain3.gain.setValueAtTime(0.15, startTime + 0.5);
gain3.gain.exponentialRampToValueAtTime(0.01, startTime + 0.8);
osc3.start(startTime + 0.5);
osc3.stop(startTime + 0.85);
}
/**
* Play an audio file from the www folder
* Used for fart sounds (real audio files, not synthesized)
* CC0 public domain from BigSoundBank.com
* @param {string} filename - The audio file name (e.g., 'fart1.mp3')
*/
_playAudioFile(filename) {
try {
// Build the URL to the audio file in the www folder
const audio = new Audio(`/local/taskmate/${filename}`);
audio.volume = 1.0;
audio.play().catch(e => {
console.warn(`[TaskMate] Failed to play ${filename}:`, e);
});
} catch (e) {
console.warn(`[TaskMate] Error preparing audio ${filename}:`, e);
}
/** Uploaded custom sounds (#856). Lives on the chores sensor; the attr
* resolver merges it in, so the card reads it off the primary entity. */
_customSounds() {
const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config?.entity))
|| this.hass?.states?.[this.config?.entity]?.attributes || {};
return attrs.custom_sounds || [];
}
static get styles() {
@@ -1,647 +1,35 @@
/**
* TaskMate Config Flow Sound Preview
* Plays sound preview when the completion_sound dropdown value changes
* in the config flow (add/edit chore screens).
* TaskMate sound preview bridge.
*
* Loaded on every HA frontend page (see GLOBAL_MODULES in frontend.py) so the
* `taskmate.preview_sound` service can make a browser actually play something.
*
* This file used to carry a second, hand-maintained copy of every synthesised
* sound plus a MutationObserver that scanned the document for anything looking
* like a sound dropdown. Both had rotted: the copy still only knew the
* long-removed "fart"/"fart_long" names while the real list had moved on to
* fart1..fart10, and the scan could never reach the admin panel's sound picker
* because that lives inside a shadow root. The panel now previews sounds
* directly through window.__taskmate_sounds, so all that is gone; playback
* lives in taskmate-sounds.js and this file is just the event bridge.
*/
(function() {
'use strict';
(function () {
"use strict";
// Debug logging gate. Set window.__taskmate_config_sounds_debug = true in the
// browser console to enable the chatty console.debug/warn output.
const debugLog = (...args) => {
if (window.__taskmate_config_sounds_debug) console.debug(...args);
};
const debugWarn = (...args) => {
if (window.__taskmate_config_sounds_debug) console.warn(...args);
};
// Audio context for sound preview (lazy initialized)
let audioContext = null;
/**
* Get or create AudioContext
*/
function getAudioContext() {
if (!audioContext) {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
if (audioContext.state === 'suspended') {
audioContext.resume();
}
return audioContext;
function play(sound) {
const engine = window.__taskmate_sounds;
if (engine && sound) engine.play(sound, window.__taskmate_custom_sounds || []);
}
// ============== SOUND IMPLEMENTATIONS ==============
// These are copies from taskmate-child-card.js to keep this file standalone
function playCoinSound(ctx, startTime) {
const masterGain = ctx.createGain();
masterGain.connect(ctx.destination);
masterGain.gain.value = 0.3;
const osc1 = ctx.createOscillator();
const gain1 = ctx.createGain();
osc1.connect(gain1);
gain1.connect(masterGain);
osc1.frequency.value = 1318.5;
osc1.type = 'square';
gain1.gain.setValueAtTime(0.5, startTime);
gain1.gain.exponentialRampToValueAtTime(0.01, startTime + 0.1);
osc1.start(startTime);
osc1.stop(startTime + 0.1);
const osc2 = ctx.createOscillator();
const gain2 = ctx.createGain();
osc2.connect(gain2);
gain2.connect(masterGain);
osc2.frequency.value = 1975.5;
osc2.type = 'square';
gain2.gain.setValueAtTime(0.5, startTime + 0.08);
gain2.gain.exponentialRampToValueAtTime(0.01, startTime + 0.25);
osc2.start(startTime + 0.08);
osc2.stop(startTime + 0.25);
function onPreview(e) {
const sound = (e.detail && (e.detail.sound || (e.detail.data && e.detail.data.sound))) || null;
if (sound) play(sound);
}
function playLevelUpSound(ctx, startTime) {
const masterGain = ctx.createGain();
masterGain.connect(ctx.destination);
masterGain.gain.value = 0.25;
const notes = [523.25, 659.25, 783.99, 1046.5];
const duration = 0.12;
notes.forEach((freq, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(masterGain);
osc.frequency.value = freq;
osc.type = 'square';
const noteStart = startTime + i * duration;
gain.gain.setValueAtTime(0.6, noteStart);
gain.gain.exponentialRampToValueAtTime(0.01, noteStart + duration + 0.1);
osc.start(noteStart);
osc.stop(noteStart + duration + 0.15);
});
const chordNotes = [523.25, 659.25, 783.99];
const chordStart = startTime + notes.length * duration;
chordNotes.forEach((freq) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(masterGain);
osc.frequency.value = freq;
osc.type = 'triangle';
gain.gain.setValueAtTime(0.3, chordStart);
gain.gain.exponentialRampToValueAtTime(0.01, chordStart + 0.5);
osc.start(chordStart);
osc.stop(chordStart + 0.55);
});
}
function playFanfareSound(ctx, startTime) {
const masterGain = ctx.createGain();
masterGain.connect(ctx.destination);
masterGain.gain.value = 0.2;
const pattern = [
{ freq: 392.00, duration: 0.1, delay: 0 },
{ freq: 392.00, duration: 0.1, delay: 0.12 },
{ freq: 392.00, duration: 0.15, delay: 0.24 },
{ freq: 329.63, duration: 0.15, delay: 0.42 },
{ freq: 392.00, duration: 0.15, delay: 0.6 },
{ freq: 523.25, duration: 0.4, delay: 0.78 },
];
pattern.forEach(({ freq, duration, delay }) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(masterGain);
osc.frequency.value = freq;
osc.type = 'sawtooth';
const noteStart = startTime + delay;
gain.gain.setValueAtTime(0.5, noteStart);
gain.gain.setValueAtTime(0.5, noteStart + duration * 0.8);
gain.gain.exponentialRampToValueAtTime(0.01, noteStart + duration);
osc.start(noteStart);
osc.stop(noteStart + duration + 0.05);
});
}
function playChimeSound(ctx, startTime) {
const masterGain = ctx.createGain();
masterGain.connect(ctx.destination);
masterGain.gain.value = 0.3;
const fundamental = 880;
const harmonics = [1, 2, 3, 4.2];
harmonics.forEach((harmonic, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(masterGain);
osc.frequency.value = fundamental * harmonic;
osc.type = 'sine';
const amplitude = 0.5 / (i + 1);
const decayTime = 0.8 / (i + 1);
gain.gain.setValueAtTime(amplitude, startTime);
gain.gain.exponentialRampToValueAtTime(0.001, startTime + decayTime);
osc.start(startTime);
osc.stop(startTime + decayTime + 0.1);
});
}
function playPowerUpSound(ctx, startTime) {
const masterGain = ctx.createGain();
masterGain.connect(ctx.destination);
masterGain.gain.value = 0.25;
const osc1 = ctx.createOscillator();
const gain1 = ctx.createGain();
osc1.connect(gain1);
gain1.connect(masterGain);
osc1.type = 'sawtooth';
osc1.frequency.setValueAtTime(200, startTime);
osc1.frequency.exponentialRampToValueAtTime(1200, startTime + 0.3);
gain1.gain.setValueAtTime(0.4, startTime);
gain1.gain.exponentialRampToValueAtTime(0.01, startTime + 0.35);
osc1.start(startTime);
osc1.stop(startTime + 0.4);
const sparkleNotes = [1318.5, 1567.98, 1975.5];
sparkleNotes.forEach((freq, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(masterGain);
osc.frequency.value = freq;
osc.type = 'sine';
const noteStart = startTime + 0.25 + i * 0.05;
gain.gain.setValueAtTime(0.3, noteStart);
gain.gain.exponentialRampToValueAtTime(0.01, noteStart + 0.2);
osc.start(noteStart);
osc.stop(noteStart + 0.25);
});
}
function playUndoSound(ctx, startTime) {
const masterGain = ctx.createGain();
masterGain.connect(ctx.destination);
masterGain.gain.value = 0.25;
const osc1 = ctx.createOscillator();
const gain1 = ctx.createGain();
osc1.connect(gain1);
gain1.connect(masterGain);
osc1.type = 'triangle';
osc1.frequency.setValueAtTime(311.13, startTime);
osc1.frequency.exponentialRampToValueAtTime(233.08, startTime + 0.25);
gain1.gain.setValueAtTime(0.6, startTime);
gain1.gain.exponentialRampToValueAtTime(0.3, startTime + 0.2);
gain1.gain.exponentialRampToValueAtTime(0.01, startTime + 0.3);
osc1.start(startTime);
osc1.stop(startTime + 0.35);
const osc2 = ctx.createOscillator();
const gain2 = ctx.createGain();
osc2.connect(gain2);
gain2.connect(masterGain);
osc2.type = 'triangle';
osc2.frequency.setValueAtTime(233.08, startTime + 0.3);
osc2.frequency.exponentialRampToValueAtTime(155.56, startTime + 0.7);
gain2.gain.setValueAtTime(0.5, startTime + 0.3);
gain2.gain.exponentialRampToValueAtTime(0.25, startTime + 0.55);
gain2.gain.exponentialRampToValueAtTime(0.01, startTime + 0.75);
osc2.start(startTime + 0.3);
osc2.stop(startTime + 0.8);
const osc3 = ctx.createOscillator();
const gain3 = ctx.createGain();
osc3.connect(gain3);
gain3.connect(masterGain);
osc3.type = 'sine';
osc3.frequency.setValueAtTime(116.54, startTime + 0.5);
gain3.gain.setValueAtTime(0.15, startTime + 0.5);
gain3.gain.exponentialRampToValueAtTime(0.01, startTime + 0.8);
osc3.start(startTime + 0.5);
osc3.stop(startTime + 0.85);
}
function playFartSound(ctx, startTime) {
const masterGain = ctx.createGain();
masterGain.connect(ctx.destination);
masterGain.gain.value = 0.9;
const noiseLength = ctx.sampleRate * 0.5;
const noiseBuffer = ctx.createBuffer(1, noiseLength, ctx.sampleRate);
const noiseData = noiseBuffer.getChannelData(0);
let lastOut = 0;
for (let i = 0; i < noiseLength; i++) {
const white = Math.random() * 2 - 1;
noiseData[i] = (lastOut + (0.04 * white)) / 1.04;
lastOut = noiseData[i];
noiseData[i] *= 8;
}
const noiseSource = ctx.createBufferSource();
noiseSource.buffer = noiseBuffer;
const filter = ctx.createBiquadFilter();
filter.type = 'bandpass';
filter.frequency.setValueAtTime(200, startTime);
filter.frequency.exponentialRampToValueAtTime(80, startTime + 0.4);
filter.Q.value = 2;
const noiseGain = ctx.createGain();
noiseGain.gain.setValueAtTime(0, startTime);
noiseGain.gain.linearRampToValueAtTime(1.0, startTime + 0.01);
noiseGain.gain.setValueAtTime(1.0, startTime + 0.15);
noiseGain.gain.exponentialRampToValueAtTime(0.01, startTime + 0.45);
noiseSource.connect(filter);
filter.connect(noiseGain);
noiseGain.connect(masterGain);
noiseSource.start(startTime);
noiseSource.stop(startTime + 0.5);
const bass = ctx.createOscillator();
const bassGain = ctx.createGain();
bass.type = 'sawtooth';
bass.frequency.setValueAtTime(80, startTime);
bass.frequency.exponentialRampToValueAtTime(40, startTime + 0.4);
bassGain.gain.setValueAtTime(0, startTime);
bassGain.gain.linearRampToValueAtTime(0.8, startTime + 0.02);
bassGain.gain.exponentialRampToValueAtTime(0.01, startTime + 0.45);
bass.connect(bassGain);
bassGain.connect(masterGain);
bass.start(startTime);
bass.stop(startTime + 0.5);
const flap = ctx.createOscillator();
const flapGain = ctx.createGain();
flap.type = 'square';
flap.frequency.setValueAtTime(25, startTime);
flap.frequency.exponentialRampToValueAtTime(12, startTime + 0.4);
flapGain.gain.setValueAtTime(0.6, startTime);
flapGain.gain.exponentialRampToValueAtTime(0.01, startTime + 0.4);
flap.connect(flapGain);
flapGain.connect(masterGain);
flap.start(startTime);
flap.stop(startTime + 0.45);
const sub = ctx.createOscillator();
const subGain = ctx.createGain();
sub.type = 'sine';
sub.frequency.setValueAtTime(50, startTime);
sub.frequency.exponentialRampToValueAtTime(25, startTime + 0.3);
subGain.gain.setValueAtTime(0.7, startTime);
subGain.gain.exponentialRampToValueAtTime(0.01, startTime + 0.35);
sub.connect(subGain);
subGain.connect(masterGain);
sub.start(startTime);
sub.stop(startTime + 0.4);
}
function playFartLongSound(ctx, startTime) {
const masterGain = ctx.createGain();
masterGain.connect(ctx.destination);
masterGain.gain.value = 0.85;
const noiseLength = ctx.sampleRate * 2.0;
const noiseBuffer = ctx.createBuffer(1, noiseLength, ctx.sampleRate);
const noiseData = noiseBuffer.getChannelData(0);
let lastOut = 0;
for (let i = 0; i < noiseLength; i++) {
const white = Math.random() * 2 - 1;
noiseData[i] = (lastOut + (0.05 * white)) / 1.05;
lastOut = noiseData[i];
noiseData[i] *= 7;
}
const noiseSource = ctx.createBufferSource();
noiseSource.buffer = noiseBuffer;
const filter = ctx.createBiquadFilter();
filter.type = 'bandpass';
filter.frequency.setValueAtTime(250, startTime);
filter.frequency.linearRampToValueAtTime(180, startTime + 0.3);
filter.frequency.linearRampToValueAtTime(280, startTime + 0.5);
filter.frequency.linearRampToValueAtTime(150, startTime + 0.9);
filter.frequency.linearRampToValueAtTime(220, startTime + 1.2);
filter.frequency.exponentialRampToValueAtTime(60, startTime + 1.8);
filter.Q.value = 3;
const noiseGain = ctx.createGain();
noiseGain.gain.setValueAtTime(0, startTime);
noiseGain.gain.linearRampToValueAtTime(1.0, startTime + 0.02);
noiseGain.gain.linearRampToValueAtTime(0.7, startTime + 0.4);
noiseGain.gain.linearRampToValueAtTime(1.0, startTime + 0.6);
noiseGain.gain.linearRampToValueAtTime(0.5, startTime + 1.0);
noiseGain.gain.linearRampToValueAtTime(0.9, startTime + 1.3);
noiseGain.gain.exponentialRampToValueAtTime(0.01, startTime + 1.9);
noiseSource.connect(filter);
filter.connect(noiseGain);
noiseGain.connect(masterGain);
noiseSource.start(startTime);
noiseSource.stop(startTime + 2.0);
const bass = ctx.createOscillator();
const bassGain = ctx.createGain();
bass.type = 'sawtooth';
bass.frequency.setValueAtTime(90, startTime);
bass.frequency.linearRampToValueAtTime(70, startTime + 0.5);
bass.frequency.linearRampToValueAtTime(100, startTime + 0.8);
bass.frequency.linearRampToValueAtTime(50, startTime + 1.3);
bass.frequency.exponentialRampToValueAtTime(30, startTime + 1.8);
bassGain.gain.setValueAtTime(0.8, startTime);
bassGain.gain.exponentialRampToValueAtTime(0.01, startTime + 1.85);
bass.connect(bassGain);
bassGain.connect(masterGain);
bass.start(startTime);
bass.stop(startTime + 1.9);
const flutter = ctx.createOscillator();
const flutterGain = ctx.createGain();
flutter.type = 'square';
flutter.frequency.setValueAtTime(35, startTime);
flutter.frequency.linearRampToValueAtTime(20, startTime + 0.8);
flutter.frequency.linearRampToValueAtTime(28, startTime + 1.0);
flutter.frequency.exponentialRampToValueAtTime(6, startTime + 1.8);
flutterGain.gain.setValueAtTime(0.7, startTime);
flutterGain.gain.exponentialRampToValueAtTime(0.01, startTime + 1.8);
flutter.connect(flutterGain);
flutterGain.connect(masterGain);
flutter.start(startTime);
flutter.stop(startTime + 1.85);
const sub = ctx.createOscillator();
const subGain = ctx.createGain();
sub.type = 'sine';
sub.frequency.setValueAtTime(45, startTime);
sub.frequency.exponentialRampToValueAtTime(20, startTime + 1.7);
subGain.gain.setValueAtTime(0.6, startTime);
subGain.gain.exponentialRampToValueAtTime(0.01, startTime + 1.75);
sub.connect(subGain);
subGain.connect(masterGain);
sub.start(startTime);
sub.stop(startTime + 1.8);
const squeakTimes = [0.5, 1.2];
squeakTimes.forEach((delay) => {
const squeak = ctx.createOscillator();
const squeakGain = ctx.createGain();
squeak.type = 'triangle';
squeak.frequency.setValueAtTime(180, startTime + delay);
squeak.frequency.exponentialRampToValueAtTime(120, startTime + delay + 0.15);
squeakGain.gain.setValueAtTime(0.4, startTime + delay);
squeakGain.gain.exponentialRampToValueAtTime(0.01, startTime + delay + 0.15);
squeak.connect(squeakGain);
squeakGain.connect(masterGain);
squeak.start(startTime + delay);
squeak.stop(startTime + delay + 0.2);
});
}
/**
* Play a sound by name
*/
function playSound(soundName) {
if (!soundName || soundName === 'none') {
debugLog('[TaskMate Config] No sound to play');
return;
}
try {
const ctx = getAudioContext();
const now = ctx.currentTime;
switch (soundName) {
case 'coin': playCoinSound(ctx, now); break;
case 'levelup': playLevelUpSound(ctx, now); break;
case 'fanfare': playFanfareSound(ctx, now); break;
case 'chime': playChimeSound(ctx, now); break;
case 'powerup': playPowerUpSound(ctx, now); break;
case 'undo': playUndoSound(ctx, now); break;
case 'fart': playFartSound(ctx, now); break;
case 'fart_long': playFartLongSound(ctx, now); break;
default:
debugWarn(`[TaskMate Config] Unknown sound: ${soundName}`);
playCoinSound(ctx, now);
}
} catch (e) {
debugWarn('[TaskMate Config] Error playing sound:', e);
}
}
// Valid sound names
const VALID_SOUNDS = ['coin', 'levelup', 'fanfare', 'chime', 'powerup', 'undo', 'fart', 'fart_long', 'none'];
/**
* Map display text to sound value
*/
function textToSoundValue(text) {
if (!text) return null;
const normalized = text.toLowerCase().trim().replace(/\s+/g, '_');
const map = {
'no_sound': 'none',
'no sound': 'none',
'coin': 'coin',
'levelup': 'levelup',
'level_up': 'levelup',
'fanfare': 'fanfare',
'chime': 'chime',
'powerup': 'powerup',
'power_up': 'powerup',
'undo': 'undo',
'fart': 'fart',
'fart_long': 'fart_long',
};
return map[normalized] || (VALID_SOUNDS.includes(normalized) ? normalized : null);
}
/**
* Attach change listener to a select element
*/
function attachSoundChangeListener(element) {
if (element.dataset.taskmateSoundListener) return;
element.dataset.taskmateSoundListener = 'true';
// Listen for various change events
const handleChange = (e) => {
const value = e.target?.value || e.detail?.value;
const soundValue = textToSoundValue(value) || value;
debugLog('[TaskMate Config] Sound changed to:', soundValue);
if (soundValue && soundValue !== 'none' && VALID_SOUNDS.includes(soundValue)) {
playSound(soundValue);
}
};
element.addEventListener('change', handleChange);
element.addEventListener('value-changed', handleChange);
element.addEventListener('selected', handleChange);
element.addEventListener('click', (e) => {
// For dropdown items being clicked
const item = e.target.closest('mwc-list-item, ha-list-item, [role="option"]');
if (item) {
const value = item.value || item.getAttribute('value') || item.textContent;
const soundValue = textToSoundValue(value);
if (soundValue && soundValue !== 'none') {
setTimeout(() => playSound(soundValue), 50);
}
}
});
debugLog('[TaskMate Config] Attached sound change listener to element');
}
/**
* Find sound selectors and attach listeners
*/
function findAndEnhanceSoundSelectors() {
// Look for any select/dropdown that might be for sounds
const selectors = document.querySelectorAll('ha-select, mwc-select, ha-combo-box, select');
selectors.forEach(selector => {
// Check if this selector has sound-related options
const options = selector.querySelectorAll('mwc-list-item, ha-list-item, option, [role="option"]');
let isSoundSelector = false;
options.forEach(opt => {
const text = (opt.value || opt.getAttribute('value') || opt.textContent || '').toLowerCase();
if (VALID_SOUNDS.some(s => text.includes(s))) {
isSoundSelector = true;
}
});
// Also check the selector's current value
const currentValue = (selector.value || '').toLowerCase();
if (VALID_SOUNDS.some(s => currentValue.includes(s))) {
isSoundSelector = true;
}
if (isSoundSelector) {
attachSoundChangeListener(selector);
// Also attach to inner elements if present
const innerSelect = selector.shadowRoot?.querySelector('select, mwc-menu, mwc-list');
if (innerSelect) {
attachSoundChangeListener(innerSelect);
}
}
});
// Also look for ha-selector elements (HA's custom form selectors)
document.querySelectorAll('ha-selector').forEach(haSelector => {
const innerSelect = haSelector.shadowRoot?.querySelector('ha-select, mwc-select, select') ||
haSelector.querySelector('ha-select, mwc-select, select');
if (innerSelect) {
const options = innerSelect.querySelectorAll('mwc-list-item, [role="option"]');
let isSoundSelector = false;
options.forEach(opt => {
const text = (opt.value || opt.textContent || '').toLowerCase();
if (VALID_SOUNDS.some(s => text.includes(s))) {
isSoundSelector = true;
}
});
if (isSoundSelector) {
attachSoundChangeListener(innerSelect);
}
}
});
}
/**
* Watch for DOM changes and enhance new sound selectors
*/
function startObserver() {
const observer = new MutationObserver((mutations) => {
let shouldScan = false;
mutations.forEach((mutation) => {
if (mutation.addedNodes.length > 0) {
shouldScan = true;
}
});
if (shouldScan) {
// Debounce scanning
clearTimeout(window._taskmateScanTimeout);
window._taskmateScanTimeout = setTimeout(findAndEnhanceSoundSelectors, 100);
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
window._taskmateSoundObserver = observer;
debugLog('[TaskMate Config] Sound change observer started');
}
// Initialize
function init() {
if (window._taskmateConfigSoundsInit) return;
if (!window._taskmateConfigSoundsInit) {
window._taskmateConfigSoundsInit = true;
findAndEnhanceSoundSelectors();
startObserver();
// Slow periodic scan as a safety net for dynamically loaded content the
// MutationObserver doesn't catch (e.g. dialogs rendered outside body's light DOM)
window._taskmateConfigSoundsPoll = setInterval(findAndEnhanceSoundSelectors, 10000);
const cleanup = () => {
if (window._taskmateConfigSoundsPoll) {
clearInterval(window._taskmateConfigSoundsPoll);
window._taskmateConfigSoundsPoll = null;
}
if (window._taskmateScanTimeout) {
clearTimeout(window._taskmateScanTimeout);
window._taskmateScanTimeout = null;
}
if (window._taskmateSoundObserver) {
window._taskmateSoundObserver.disconnect();
window._taskmateSoundObserver = null;
}
};
// pagehide covers SPA navigation in HA; beforeunload covers full reloads.
window.addEventListener('pagehide', cleanup, { once: true });
window.addEventListener('beforeunload', cleanup, { once: true });
document.addEventListener("taskmate_preview_sound", onPreview);
window.addEventListener("taskmate_preview_sound", onPreview);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
window.addEventListener('load', () => setTimeout(init, 500));
// Listen for taskmate_preview_sound events fired by the preview_sound service
document.addEventListener('taskmate_preview_sound', (e) => {
const sound = e.detail?.sound || e.detail?.data?.sound;
if (sound) playSound(sound);
});
// Also listen via window for HA event bus forwarding
window.addEventListener('taskmate_preview_sound', (e) => {
const sound = e.detail?.sound || e.detail?.data?.sound;
if (sound) playSound(sound);
});
console.info('[TaskMate] Config sound preview module loaded - sounds will play on selection change');
})();
@@ -145,7 +145,32 @@ function _refreshTaskMateCards() {
}
}
const _DAY_KEYS = [
'weekly.day_mon', 'weekly.day_tue', 'weekly.day_wed', 'weekly.day_thu',
'weekly.day_fri', 'weekly.day_sat', 'weekly.day_sun',
];
/**
* Human summary of a reward's time lock (#857), e.g. "Fri, Sat · 18:00–21:00".
* Returns "" when nothing is restricted, so callers can skip the badge.
*
* @param {object} lock - { days: number[], from: string, until: string }
* @param {function} t - the caller's translate function (key, params) => string
*/
function timeLockLabel(lock, t) {
if (!lock) return '';
const days = (lock.days || []).map(Number).filter(n => Number.isInteger(n) && n >= 0 && n <= 6).sort((a, b) => a - b);
const parts = [];
// All seven days selected is the same as no day restriction — don't spell it out.
if (days.length && days.length < 7) parts.push(days.map(d => t(_DAY_KEYS[d])).join(', '));
const from = lock.from || '';
const until = lock.until || '';
if (from && until && from !== until) parts.push(`${from}\u2013${until}`);
return parts.join(' \u00b7 ');
}
// Expose globally so cards can access without ES module imports
window.__taskmate_time_lock_label = timeLockLabel;
window.__taskmate_localize = localize;
window.__taskmate_loadTranslations = loadTranslations;
+393 -49
View File
@@ -122,6 +122,8 @@ const WEATHER_CONDITIONS = [
{ v: "exceptional", icon: "mdi:alert-outline" },
];
// Built-in sounds, mirroring COMPLETION_SOUND_OPTIONS in const.py. User-uploaded
// sounds are appended per render by _soundOptions() (#856).
const COMPLETION_SOUNDS = [
"none", "coin", "levelup", "fanfare", "chime", "powerup", "undo",
"fart1", "fart2", "fart3", "fart4", "fart5", "fart6", "fart7",
@@ -506,6 +508,15 @@ class TaskMatePanel extends HTMLElement {
if (act === "save-gift") { this._doGiftPoints(); return; }
if (act === "adjust-points") { this._doAdjustPoints(t.dataset.id, Number(t.dataset.delta)); return; }
if (act === "adjust-points-custom") { this._openAdjustDialog(t.dataset.id); return; }
if (act === "sound-preview-field") {
const sel = t.closest(".tm-sound-row")?.querySelector('select[data-field="completion_sound"]');
if (sel) this._previewSound(sel.value);
return;
}
if (act === "sound-preview") { this._previewSound(t.dataset.value); return; }
if (act === "sound-upload") { this._pickCustomSound(); return; }
if (act === "sound-rename") { this._renameCustomSound(t.dataset.file, t.dataset.name); return; }
if (act === "sound-delete") { this._deleteCustomSound(t.dataset.file, t.dataset.name); return; }
if (act === "chore-image-pick") { this._pickChoreImage(); return; }
if (act === "chore-image-remove") {
// Clears the dialog value only — the file is deleted on save by the
@@ -605,6 +616,7 @@ class TaskMatePanel extends HTMLElement {
if (act === "delete-reward") { this._confirmDelete("reward", t.dataset.id); return; }
if (act === "save-reward") { this._doSaveReward(); return; }
if (act === "toggle-reward-assigned") { this._toggleArrayField("assigned_to", t.dataset.id); return; }
if (act === "toggle-reward-day") { this._toggleArrayField("available_days", Number(t.dataset.day)); return; }
// Quests (chore chains)
if (act === "add-quest") { this._openQuestDialog(null); return; }
@@ -1449,6 +1461,100 @@ class TaskMatePanel extends HTMLElement {
}
}
// --- Custom completion sounds (#856) ------------------------------------
_pickCustomSound() {
const input = document.createElement("input");
input.type = "file";
input.accept = "audio/mpeg,audio/ogg,audio/wav,audio/mp4,.mp3,.ogg,.wav,.m4a";
input.addEventListener("change", () => {
const file = input.files && input.files[0];
if (file) this._uploadCustomSound(file);
});
input.click();
}
/**
* Upload the file, then register it under a display name.
*
* Two steps because the bytes go over HTTP (multipart) while the registry
* entry is a WebSocket command — same split as chore images. The default
* name is the filename without its extension, which is nearly always what
* the user wants.
*/
async _uploadCustomSound(file) {
if (this._soundBusy) return;
this._soundBusy = true;
this._render();
try {
const post = () => {
const token = this._hass && this._hass.auth && this._hass.auth.data
&& this._hass.auth.data.access_token;
const fd = new FormData();
fd.append("file", file, file.name || "sound");
return fetch("/api/taskmate/sound", {
method: "POST",
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: fd,
});
};
// The cached access token can go stale mid-session, so refresh and retry
// once on a 401 before giving up. Mirrors the chore-image upload.
if (this._hass && this._hass.auth && this._hass.auth.expired
&& this._hass.auth.refreshAccessToken) {
try { await this._hass.auth.refreshAccessToken(); } catch (e) { /* surfaced below */ }
}
let resp = await post();
if (resp.status === 401 && this._hass && this._hass.auth
&& this._hass.auth.refreshAccessToken) {
await this._hass.auth.refreshAccessToken();
resp = await post();
}
if (resp.status === 413) { this._showToast("err", this._t("panel.sound_too_large")); return; }
if (resp.status === 400) { this._showToast("err", this._t("panel.sound_bad_type")); return; }
if (resp.status === 507) { this._showToast("err", this._t("panel.sound_storage_full")); return; }
if (!resp.ok) { this._showToast("err", this._t("panel.sound_upload_failed")); return; }
const body = await resp.json();
const name = (file.name || "").replace(/\.[^.]+$/, "").slice(0, 40) || this._t("panel.sound_default_name");
const { ok, err } = await this._callWS({
type: "taskmate/add_custom_sound", file: body.file, name,
});
if (!ok) { this._showToast("err", this._t("panel.sound_upload_failed", { error: err })); return; }
this._showToast("ok", this._t("panel.sound_uploaded"));
await this._fetchState();
} catch (err) {
this._showToast("err", this._t("panel.sound_upload_failed"));
} finally {
this._soundBusy = false;
this._render();
}
}
async _renameCustomSound(file, current) {
const name = prompt(this._t("panel.sound_rename_prompt"), current || "");
if (name === null) return;
if (!name.trim()) { this._showToast("err", this._t("panel.sound_name_required")); return; }
const { ok, err } = await this._callWS({
type: "taskmate/rename_custom_sound", file, name: name.trim().slice(0, 40),
});
if (!ok) { this._showToast("err", err); return; }
await this._fetchState();
}
async _deleteCustomSound(file, name) {
if (!confirm(this._t("panel.sound_delete_confirm", { name: name || file }))) return;
const { ok, err, res } = await this._callWS({ type: "taskmate/remove_custom_sound", file });
if (!ok) { this._showToast("err", err); return; }
// Chores that used it were reset to the default sound server-side; say so
// rather than letting the change be discovered later.
const reset = (res && res.chores_reset) || 0;
this._showToast("ok", reset
? this._t("panel.sound_deleted_reset", { count: reset })
: this._t("panel.sound_deleted"));
await this._fetchState();
}
_pickChoreImage() {
const input = document.createElement("input");
input.type = "file";
@@ -1619,7 +1725,8 @@ class TaskMatePanel extends HTMLElement {
assigned_to: [], is_jackpot: false, pool_enabled: false,
quantity_str: "", expires_at: "",
restock_enabled: false, restock_amount: 0, restock_period: "weekly",
unlock_entity: "", unlock_minutes: 30 };
unlock_entity: "", unlock_minutes: 30,
time_lock_enabled: false, available_days: [], available_from: "", available_until: "" };
if (id) {
const r = (this._state.rewards || []).find(x => x.id === id);
if (!r) return;
@@ -1628,6 +1735,7 @@ class TaskMatePanel extends HTMLElement {
assigned_to: [...(r.assigned_to || [])],
quantity_str: r.quantity == null ? "" : String(r.quantity),
expires_at: r.expires_at || "",
available_days: (r.available_days || []).map(Number),
} });
} else {
this._openDialog({ kind: "reward", mode: "add", data: blank });
@@ -1655,6 +1763,10 @@ class TaskMatePanel extends HTMLElement {
unlock_minutes: Math.max(0, Number(d.unlock_minutes) || 0),
restock_amount: Math.max(0, Number(d.restock_amount) || 0),
restock_period: d.restock_period || "weekly",
time_lock_enabled: !!d.time_lock_enabled,
available_days: (d.available_days || []).map(Number).filter(n => Number.isInteger(n) && n >= 0 && n <= 6),
available_from: d.available_from || "",
available_until: d.available_until || "",
};
const payload = wasAdd ? { type: "taskmate/add_reward", ...base } : { type: "taskmate/update_reward", reward_id: d.id, ...base };
const { ok, err } = await this._callWS(payload);
@@ -2368,7 +2480,9 @@ class TaskMatePanel extends HTMLElement {
_timeAgo(iso) {
if (!iso) return "—";
const then = new Date(iso);
if (isNaN(then)) return iso;
// Echoing an unparseable value straight back would put stored text into
// markup, so escape it — the panel builds its HTML as strings.
if (isNaN(then)) return this._esc(iso);
const diffSec = Math.floor((Date.now() - then.getTime()) / 1000);
if (diffSec < 60) return this._t("panel.time_ago_just_now");
if (diffSec < 3600) return this._t("panel.time_ago_minutes", {count: Math.floor(diffSec / 60)});
@@ -2832,7 +2946,7 @@ class TaskMatePanel extends HTMLElement {
<td><strong>${this._t("panel.day_" + d.weekday.slice(0, 3))}</strong> <span class="tm-proj-date">${d.date.slice(5)}</span></td>
${kids.map(k => {
const cell = d.children.find(c => c.id === k.id) || { points: 0, chores: 0 };
return `<td>${cell.chores ? `${cell.points} <span class="tm-proj-count">(${cell.chores})</span>` : "—"}</td>`;
return `<td>${cell.chores ? `${this._num(cell.points)} <span class="tm-proj-count">(${this._num(cell.chores)})</span>` : "—"}</td>`;
}).join("")}
</tr>
`).join("")}
@@ -2976,7 +3090,7 @@ class TaskMatePanel extends HTMLElement {
</div>
<div class="tm-fair-figs">
<strong>${c.completions}</strong> ${this._t(c.completions === 1 ? "panel.insights_chore" : "panel.insights_chores")}
· ${c.points} ${this._esc(this._state.settings?.points_name || this._t("common.points"))}
· ${this._num(c.points)} ${this._esc(this._state.settings?.points_name || this._t("common.points"))}
· ${c.share_completions}%
</div>
<div class="tm-fair-status tm-fair-${c.status}">${this._t("panel.insights_status_" + c.status)}</div>
@@ -3191,7 +3305,7 @@ class TaskMatePanel extends HTMLElement {
<div class="tm-approval-icon"><ha-icon icon="mdi:gift-outline"></ha-icon></div>
<div class="tm-approval-body">
<div class="tm-approval-line">${this._t("panel.activity_claimed_text", {child: this._esc((child && child.name) || "?"), reward: this._esc((reward && reward.name) || this._t("panel.activity_deleted_reward"))})}</div>
<div class="tm-meta">${this._timeAgo(c.claimed_at)}${reward ? ` · ${reward.cost} ${this._t("panel.activity_points")}` : ""}</div>
<div class="tm-meta">${this._timeAgo(c.claimed_at)}${reward ? ` · ${this._num(reward.cost)} ${this._t("panel.activity_points")}` : ""}</div>
</div>
<div class="tm-approval-actions">
<button type="button" class="tm-btn tm-btn-sm" data-act="reject-reward" data-id="${this._esc(c.id)}">${this._t("panel.activity_reject")}</button>
@@ -3234,7 +3348,7 @@ class TaskMatePanel extends HTMLElement {
<div class="tm-timeline-body">
<div><strong>${this._esc(ev.child)}</strong> · ${this._esc(ev.label)}</div>
</div>
<div class="tm-timeline-points ${ev.points >= 0 ? 'tm-pos' : 'tm-neg'} tm-numeric">${ev.points >= 0 ? '+' : ''}${ev.points || 0}</div>
<div class="tm-timeline-points ${ev.points >= 0 ? 'tm-pos' : 'tm-neg'} tm-numeric">${ev.points >= 0 ? '+' : ''}${this._num(ev.points)}</div>
</div>
`).join("")}
</div>
@@ -3258,7 +3372,7 @@ class TaskMatePanel extends HTMLElement {
<tr class="tm-row">
<td class="tm-meta">${this._esc(this._timeAgo(t.created_at))}</td>
<td>${this._esc((child && child.name) || "?")}</td>
<td><strong class="tm-numeric ${t.points >= 0 ? 'tm-pos' : 'tm-neg'}">${t.points >= 0 ? '+' : ''}${t.points}</strong></td>
<td><strong class="tm-numeric ${t.points >= 0 ? 'tm-pos' : 'tm-neg'}">${t.points >= 0 ? '+' : ''}${this._num(t.points)}</strong></td>
<td>${this._esc(this._translateReason(t.reason) || "—")}</td>
<td>${undoable ? `<button type="button" class="tm-icon-btn" data-act="undo-tx" data-id="${this._esc(t.id)}" title="${this._esc(this._t("panel.activity_undo"))}"><ha-icon icon="mdi:undo-variant"></ha-icon></button>` : ""}</td>
</tr>
@@ -3365,7 +3479,7 @@ class TaskMatePanel extends HTMLElement {
<button type="button" class="tm-icon-btn" data-act="toggle-row-menu" data-id="${this._esc(c.id)}" title="${this._t("panel.row_menu_more")}" aria-haspopup="menu" aria-label="${this._t("panel.row_menu_more")}">⋮</button>
</span>`}
</div></td>
<td><strong class="tm-numeric">${c.task_type === "timed" ? `${c.timed_rate_points || 0}/${c.timed_rate_minutes || 1} min` : c.points}</strong></td>
<td><strong class="tm-numeric">${c.task_type === "timed" ? `${this._num(c.timed_rate_points)}/${this._num(c.timed_rate_minutes, 1)} min` : this._num(c.points)}</strong></td>
<td><span class="tm-pill">${this._esc(this._timeCategoryLabel(c.time_category))}</span></td>
<td>${assignedNames} ${modeBadge}</td>
<td>${currentName}</td>
@@ -3396,6 +3510,18 @@ class TaskMatePanel extends HTMLElement {
`;
}
/** Reward-list pill summarising a time lock (#857), or "" when unrestricted. */
_rewardTimeLockPill(r) {
if (!r.time_lock_enabled) return "";
const summary = window.__taskmate_time_lock_label
? window.__taskmate_time_lock_label(
{ days: r.available_days, from: r.available_from, until: r.available_until },
(k, pr) => this._t(k, pr))
: "";
const label = summary || this._t("panel.reward_timelock_badge");
return `<span class="tm-pill tm-pill-timelock">${this._mdi("mdi:clock-outline")} ${this._esc(label)}</span>`;
}
_renderRewardCard(r, pointsName) {
const totalPooled = (this._state.pool_allocations || []).filter(a => a.reward_id === r.id).reduce((s, a) => s + (a.allocated_points || 0), 0);
const showProgress = (r.pool_enabled || r.is_jackpot) && r.cost > 0;
@@ -3405,14 +3531,14 @@ class TaskMatePanel extends HTMLElement {
<div class="tm-child-head">
<div class="tm-avatar tm-avatar-reward">${this._mdi(r.icon || "mdi:gift")}</div>
<div class="tm-child-name">
<h3>${this._esc(r.name)} ${r.is_jackpot ? `<span class="tm-pill tm-pill-jackpot">🏆 ${this._t("panel.reward_badge_jackpot")}</span>` : ""} ${r.pool_enabled ? `<span class="tm-pill tm-pill-pool">${this._t("panel.reward_badge_pool")}</span>` : ""}</h3>
<h3>${this._esc(r.name)} ${r.is_jackpot ? `<span class="tm-pill tm-pill-jackpot">🏆 ${this._t("panel.reward_badge_jackpot")}</span>` : ""} ${r.pool_enabled ? `<span class="tm-pill tm-pill-pool">${this._t("panel.reward_badge_pool")}</span>` : ""} ${this._rewardTimeLockPill(r)}</h3>
${this._idBadge(r.id)}
<div class="tm-meta">${this._esc(r.description || "")}</div>
</div>
</div>
<div class="tm-stats-row" style="grid-template-columns: 1fr 1fr ${r.expires_at ? "1fr" : ""};">
<div class="tm-stat"><div class="tm-stat-value tm-numeric tm-cost">${this._fmtNum(r.cost)}</div><div class="tm-stat-label">${this._t("panel.reward_stat_cost", {points_name: pointsName})}</div></div>
${r.quantity != null ? `<div class="tm-stat"><div class="tm-stat-value tm-numeric">${r.quantity}</div><div class="tm-stat-label">${this._t("panel.reward_stat_remaining")}</div></div>` : `<div class="tm-stat"><div class="tm-stat-value">∞</div><div class="tm-stat-label">${this._t("panel.reward_stat_unlimited")}</div></div>`}
${r.quantity != null ? `<div class="tm-stat"><div class="tm-stat-value tm-numeric">${this._num(r.quantity)}</div><div class="tm-stat-label">${this._t("panel.reward_stat_remaining")}</div></div>` : `<div class="tm-stat"><div class="tm-stat-value">∞</div><div class="tm-stat-label">${this._t("panel.reward_stat_unlimited")}</div></div>`}
${r.expires_at ? `<div class="tm-stat"><div class="tm-stat-value" style="font-size: 14px;">${this._esc(r.expires_at)}</div><div class="tm-stat-label">${this._t("panel.reward_stat_expires")}</div></div>` : ""}
</div>
${showProgress ? `
@@ -3533,7 +3659,7 @@ class TaskMatePanel extends HTMLElement {
</div>
</div>
<div class="tm-stats-row" style="grid-template-columns: 1fr 1fr;">
<div class="tm-stat"><div class="tm-stat-value tm-numeric">${c.target}</div><div class="tm-stat-label">${this._esc(metricLabel)}</div></div>
<div class="tm-stat"><div class="tm-stat-value tm-numeric">${this._num(c.target)}</div><div class="tm-stat-label">${this._esc(metricLabel)}</div></div>
<div class="tm-stat"><div class="tm-stat-value tm-numeric">${this._fmtNum(c.bonus_points || 0)}</div><div class="tm-stat-label">${this._t("panel.quest_stat_bonus", {points_name: pointsName})}</div></div>
</div>
<div class="tm-meta">${this._t("panel.quest_assigned_to")}: ${this._esc(assigned.join(", "))}</div>
@@ -3577,7 +3703,7 @@ class TaskMatePanel extends HTMLElement {
_criteriaLabel(criteria, combinator = "AND") {
if (!criteria || !criteria.length) return this._t("badge.manual_award_only");
const joinKey = combinator === "OR" ? "badge.criteria_or" : "badge.criteria_and";
return criteria.map(c => `${this._t("badge.criteria_" + c.metric)} ${c.operator} ${c.value}`).join(` ${this._t(joinKey)} `);
return criteria.map(c => `${this._t("badge.criteria_" + c.metric)} ${this._esc(c.operator)} ${this._num(c.value)}`).join(` ${this._t(joinKey)} `);
}
_badgeName(b) {
@@ -3870,7 +3996,7 @@ class TaskMatePanel extends HTMLElement {
<select class="tm-select tm-badge-op-select" data-badge-criterion-field="operator" data-badge-criterion-idx="${idx}">
${BADGE_OPERATORS.map(sym => { const v = BADGE_OP_VALUES[sym]; return `<option value="${v}"${(c.operator || ">=") === v ? " selected" : ""}>${sym}</option>`; }).join("")}
</select>`}
${isBool ? `<span class="tm-field-hint" style="flex:1">true</span>` : `<input class="tm-input" type="number" min="0" value="${c.value || 1}" data-badge-criterion-field="value" data-badge-criterion-idx="${idx}">`}
${isBool ? `<span class="tm-field-hint" style="flex:1">true</span>` : `<input class="tm-input" type="number" min="0" value="${this._num(c.value, 1)}" data-badge-criterion-field="value" data-badge-criterion-idx="${idx}">`}
<button type="button" class="tm-icon-btn" data-act="badge-remove-criterion" data-idx="${idx}" title="${this._t("panel.badge_remove_criterion_btn")}">✕</button>
</div>
`;
@@ -3935,7 +4061,7 @@ class TaskMatePanel extends HTMLElement {
return `
<tr class="tm-row">
<td><span class="tm-row-icon">${this._mdi(item.icon)}</span><strong>${this._esc(item.name)}</strong>${this._idBadge(item.id)}${item.description ? `<div class="tm-meta">${this._esc(item.description)}</div>` : ""}</td>
<td><strong class="tm-numeric ${kind === "penalty" ? "tm-neg" : "tm-pos"}">${kind === "penalty" ? "−" : "+"}${item.points}</strong></td>
<td><strong class="tm-numeric ${kind === "penalty" ? "tm-neg" : "tm-pos"}">${kind === "penalty" ? "−" : "+"}${this._num(item.points)}</strong></td>
<td>${assignedNames}</td>
<td class="tm-row-actions"><div>
<button type="button" class="tm-btn tm-btn-sm" data-act="apply-${kind}" data-id="${this._esc(item.id)}">${this._t("panel.btn_apply")}</button>
@@ -4120,7 +4246,7 @@ class TaskMatePanel extends HTMLElement {
<div class="tm-tpl-preview-body">
<div class="tm-field-row">
<div class="tm-field"><span class="tm-field-label">${this._t("panel.common_name")}</span><input class="tm-input" type="text" data-tpl-field="name" data-tpl-idx="${idx}" value="${this._esc(chore.name)}"></div>
<div class="tm-field"><span class="tm-field-label">${this._t("panel.common_points")}</span><input class="tm-input" type="number" data-tpl-field="points" data-tpl-idx="${idx}" value="${chore.points || 0}" min="0"></div>
<div class="tm-field"><span class="tm-field-label">${this._t("panel.common_points")}</span><input class="tm-input" type="number" data-tpl-field="points" data-tpl-idx="${idx}" value="${this._num(chore.points)}" min="0"></div>
</div>
<div class="tm-field-row">
<div class="tm-field"><span class="tm-field-label">${this._t("panel.chore_time_category_label")}</span>
@@ -4147,7 +4273,7 @@ class TaskMatePanel extends HTMLElement {
</select>
</div>
<div class="tm-field"><span class="tm-field-label">${this._t("panel.template_daily_limit_label")}</span>
<input class="tm-input" type="number" data-tpl-field="daily_limit" data-tpl-idx="${idx}" value="${chore.daily_limit || 1}" min="1">
<input class="tm-input" type="number" data-tpl-field="daily_limit" data-tpl-idx="${idx}" value="${this._num(chore.daily_limit, 1)}" min="1">
</div>
</div>
</div>
@@ -4336,6 +4462,50 @@ class TaskMatePanel extends HTMLElement {
}
// -- Settings tab ------------------------------------------------------
/**
* Custom completion sounds (#856).
*
* Uploaded clips are listed with preview / rename / delete. Deleting one
* resets any chore using it back to the default sound server-side, so the
* confirm copy warns about that rather than leaving it to be discovered.
*/
_renderSoundsSection() {
const list = this._customSounds();
const busy = this._soundBusy;
return `
<div class="tm-section">
<div class="tm-section-head"><div>
<h3>${this._t("panel.settings_sounds_title")}</h3>
<p class="tm-meta">${this._t("panel.settings_sounds_hint")}</p>
</div></div>
<div class="tm-section-body">
${list.length ? `
<div class="tm-sound-list">
${list.map(s => `
<div class="tm-sound-item">
<span class="tm-sound-name">${this._esc(s.name)}</span>
<div class="tm-sound-item-actions">
<button type="button" class="tm-btn tm-btn-sm" data-act="sound-preview"
data-value="${this._esc(s.value)}"
aria-label="${this._esc(this._t("panel.sound_preview"))}"
title="${this._esc(this._t("panel.sound_preview"))}">▶</button>
<button type="button" class="tm-btn tm-btn-sm" data-act="sound-rename"
data-file="${this._esc(s.file)}" data-name="${this._esc(s.name)}">${this._t("panel.sound_rename")}</button>
<button type="button" class="tm-btn tm-btn-sm tm-btn-danger" data-act="sound-delete"
data-file="${this._esc(s.file)}" data-name="${this._esc(s.name)}">${this._t("panel.sound_delete")}</button>
</div>
</div>`).join("")}
</div>` : `<p class="tm-meta">${this._t("panel.settings_sounds_empty")}</p>`}
<div class="tm-sound-upload">
<button type="button" class="tm-btn tm-btn-raised" data-act="sound-upload" ${busy ? "disabled" : ""}>
${busy ? this._t("panel.sound_uploading") : this._t("panel.sound_upload")}
</button>
<span class="tm-field-hint">${this._t("panel.settings_sounds_formats")}</span>
</div>
</div>
</div>`;
}
_renderSettingsTab() {
const s = this._state.settings || {};
const notifyServices = Object.keys((this._hass && this._hass.services && this._hass.services.notify) || {});
@@ -4377,6 +4547,8 @@ class TaskMatePanel extends HTMLElement {
</div>
</div>
${this._renderSoundsSection()}
<div class="tm-section">
<div class="tm-section-head"><div>
<h3>${this._t("panel.settings_parents_title")}</h3>
@@ -4389,6 +4561,10 @@ class TaskMatePanel extends HTMLElement {
<input type="checkbox" class="tm-role-check" data-parent-user="${this._esc(u.id)}" ${(s.parent_user_ids || []).includes(u.id) ? "checked" : ""}>
</label>`).join("")
|| `<p class="tm-meta">${this._t("panel.settings_parents_empty")}</p>`}
<div class="tm-setting-row">
<div class="tm-setting-label">${this._t("panel.settings_require_linked_child_label")}<small>${this._t("panel.settings_require_linked_child_hint")}</small></div>
<ha-switch data-setting="require_linked_child" ${s.require_linked_child ? "checked" : ""}></ha-switch>
</div>
</div>
</div>
@@ -4397,7 +4573,7 @@ class TaskMatePanel extends HTMLElement {
<div class="tm-section-body">
<div class="tm-setting-row">
<div class="tm-setting-label">${this._t("panel.settings_retention_label")}<small>${this._t("panel.settings_retention_hint")}</small></div>
<input type="number" class="tm-input" min="30" max="365" data-setting="history_days" value="${s.history_days || 90}">
<input type="number" class="tm-input" min="30" max="365" data-setting="history_days" value="${this._num(s.history_days, 90)}">
</div>
<div class="tm-setting-row">
<div class="tm-setting-label">${this._t("panel.settings_streak_reset_label")}<small>${this._t("panel.settings_streak_reset_hint")}</small></div>
@@ -4411,19 +4587,19 @@ class TaskMatePanel extends HTMLElement {
</div>
<div class="tm-setting-row">
<div class="tm-setting-label">${this._t("panel.settings_weekend_multiplier_label")}<small>${this._t("panel.settings_weekend_multiplier_hint")}</small></div>
<input type="number" class="tm-input" step="0.1" min="1" max="5" data-setting="weekend_multiplier" value="${s.weekend_multiplier || 1.0}">
<input type="number" class="tm-input" step="0.1" min="1" max="5" data-setting="weekend_multiplier" value="${this._num(s.weekend_multiplier, 1.0)}">
</div>
<div class="tm-setting-row">
<div class="tm-setting-label">${this._t("panel.settings_difficulty_multipliers_label")}<small>${this._t("panel.settings_difficulty_multipliers_hint")}</small></div>
<div class="tm-difficulty-mults">
<label>${this._t("panel.difficulty_easy")}<input type="number" class="tm-input" step="0.1" min="0" max="10" data-setting="difficulty_multiplier_easy" value="${s.difficulty_multiplier_easy ?? 0.5}"></label>
<label>${this._t("panel.difficulty_medium")}<input type="number" class="tm-input" step="0.1" min="0" max="10" data-setting="difficulty_multiplier_medium" value="${s.difficulty_multiplier_medium ?? 1.0}"></label>
<label>${this._t("panel.difficulty_hard")}<input type="number" class="tm-input" step="0.1" min="0" max="10" data-setting="difficulty_multiplier_hard" value="${s.difficulty_multiplier_hard ?? 2.0}"></label>
<label>${this._t("panel.difficulty_easy")}<input type="number" class="tm-input" step="0.1" min="0" max="10" data-setting="difficulty_multiplier_easy" value="${this._num(s.difficulty_multiplier_easy, 0.5)}"></label>
<label>${this._t("panel.difficulty_medium")}<input type="number" class="tm-input" step="0.1" min="0" max="10" data-setting="difficulty_multiplier_medium" value="${this._num(s.difficulty_multiplier_medium, 1.0)}"></label>
<label>${this._t("panel.difficulty_hard")}<input type="number" class="tm-input" step="0.1" min="0" max="10" data-setting="difficulty_multiplier_hard" value="${this._num(s.difficulty_multiplier_hard, 2.0)}"></label>
</div>
</div>
<div class="tm-setting-row">
<div class="tm-setting-label">${this._t("panel.settings_calendar_projection_label")}<small>${this._t("panel.settings_calendar_projection_hint")}</small></div>
<input type="number" class="tm-input" min="1" max="90" data-setting="calendar_projection_days" value="${s.calendar_projection_days || 14}">
<input type="number" class="tm-input" min="1" max="90" data-setting="calendar_projection_days" value="${this._num(s.calendar_projection_days, 14)}">
</div>
<div class="tm-setting-row">
<div class="tm-setting-label">${this._t("panel.settings_surprise_enabled_label")}<small>${this._t("panel.settings_surprise_enabled_hint")}</small></div>
@@ -4432,9 +4608,9 @@ class TaskMatePanel extends HTMLElement {
<div class="tm-setting-row">
<div class="tm-setting-label">${this._t("panel.settings_surprise_params_label")}<small>${this._t("panel.settings_surprise_params_hint")}</small></div>
<div class="tm-difficulty-mults">
<label>${this._t("panel.settings_surprise_chance")}<input type="number" class="tm-input" step="1" min="0" max="100" data-setting="surprise_bonus_chance" value="${s.surprise_bonus_chance ?? 15}"></label>
<label>${this._t("panel.settings_surprise_min")}<input type="number" class="tm-input" step="1" min="0" data-setting="surprise_bonus_min" value="${s.surprise_bonus_min ?? 5}"></label>
<label>${this._t("panel.settings_surprise_max")}<input type="number" class="tm-input" step="1" min="0" data-setting="surprise_bonus_max" value="${s.surprise_bonus_max ?? 20}"></label>
<label>${this._t("panel.settings_surprise_chance")}<input type="number" class="tm-input" step="1" min="0" max="100" data-setting="surprise_bonus_chance" value="${this._num(s.surprise_bonus_chance, 15)}"></label>
<label>${this._t("panel.settings_surprise_min")}<input type="number" class="tm-input" step="1" min="0" data-setting="surprise_bonus_min" value="${this._num(s.surprise_bonus_min, 5)}"></label>
<label>${this._t("panel.settings_surprise_max")}<input type="number" class="tm-input" step="1" min="0" data-setting="surprise_bonus_max" value="${this._num(s.surprise_bonus_max, 20)}"></label>
</div>
</div>
<div class="tm-setting-row tm-setting-stack">
@@ -4460,8 +4636,8 @@ class TaskMatePanel extends HTMLElement {
<div class="tm-setting-row">
<div class="tm-setting-label">${this._t("panel.settings_roulette_section")}</div>
<div class="tm-difficulty-mults">
<label>${this._t("panel.settings_roulette_multiplier")}<input type="number" class="tm-input" step="0.5" min="1" max="5" data-setting="roulette_multiplier" value="${s.roulette_multiplier ?? 2}"></label>
<label>${this._t("panel.settings_roulette_spins")}<input type="number" class="tm-input" step="1" min="1" max="10" data-setting="roulette_daily_spins" value="${s.roulette_daily_spins ?? 1}"></label>
<label>${this._t("panel.settings_roulette_multiplier")}<input type="number" class="tm-input" step="0.5" min="1" max="5" data-setting="roulette_multiplier" value="${this._num(s.roulette_multiplier, 2)}"></label>
<label>${this._t("panel.settings_roulette_spins")}<input type="number" class="tm-input" step="1" min="1" max="10" data-setting="roulette_daily_spins" value="${this._num(s.roulette_daily_spins, 1)}"></label>
</div>
</div>
<div class="tm-setting-row">
@@ -4471,7 +4647,7 @@ class TaskMatePanel extends HTMLElement {
<div class="tm-setting-row">
<div class="tm-setting-label">${this._t("panel.settings_decay_params_label")}<small>${this._t("panel.settings_decay_params_hint")}</small></div>
<div class="tm-difficulty-mults">
<label>${this._t("panel.settings_decay_percent")}<input type="number" class="tm-input" step="1" min="0" max="100" data-setting="points_decay_percent" value="${s.points_decay_percent ?? 10}"></label>
<label>${this._t("panel.settings_decay_percent")}<input type="number" class="tm-input" step="1" min="0" max="100" data-setting="points_decay_percent" value="${this._num(s.points_decay_percent, 10)}"></label>
<label>${this._t("panel.settings_decay_period")}
<select class="tm-select" data-setting="points_decay_period">
<option value="weekly" ${s.points_decay_period === "weekly" ? "selected" : ""}>${this._t("panel.reward_restock_weekly")}</option>
@@ -4486,7 +4662,7 @@ class TaskMatePanel extends HTMLElement {
</div>
<div class="tm-setting-row">
<div class="tm-setting-label">${this._t("panel.settings_level_step_label")}<small>${this._t("panel.settings_level_step_hint")}</small></div>
<input type="number" class="tm-input" min="1" data-setting="level_xp_step" value="${s.level_xp_step || 100}">
<input type="number" class="tm-input" min="1" data-setting="level_xp_step" value="${this._num(s.level_xp_step, 100)}">
</div>
<div class="tm-setting-row">
<div class="tm-setting-label">${this._t("panel.settings_spendcap_enabled_label")}<small>${this._t("panel.settings_spendcap_enabled_hint")}</small></div>
@@ -4495,7 +4671,7 @@ class TaskMatePanel extends HTMLElement {
<div class="tm-setting-row">
<div class="tm-setting-label">${this._t("panel.settings_spendcap_params_label")}<small>${this._t("panel.settings_spendcap_params_hint")}</small></div>
<div class="tm-difficulty-mults">
<label>${this._t("panel.settings_spendcap_amount")}<input type="number" class="tm-input" step="1" min="0" data-setting="spend_cap_amount" value="${s.spend_cap_amount ?? 0}"></label>
<label>${this._t("panel.settings_spendcap_amount")}<input type="number" class="tm-input" step="1" min="0" data-setting="spend_cap_amount" value="${this._num(s.spend_cap_amount, 0)}"></label>
<label>${this._t("panel.settings_decay_period")}
<select class="tm-select" data-setting="spend_cap_period">
<option value="weekly" ${s.spend_cap_period === "weekly" ? "selected" : ""}>${this._t("panel.reward_restock_weekly")}</option>
@@ -4511,7 +4687,7 @@ class TaskMatePanel extends HTMLElement {
<div class="tm-setting-row">
<div class="tm-setting-label">${this._t("panel.settings_interest_params_label")}<small>${this._t("panel.settings_interest_params_hint")}</small></div>
<div class="tm-difficulty-mults">
<label>${this._t("panel.settings_interest_percent")}<input type="number" class="tm-input" step="1" min="0" max="100" data-setting="interest_percent" value="${s.interest_percent ?? 5}"></label>
<label>${this._t("panel.settings_interest_percent")}<input type="number" class="tm-input" step="1" min="0" max="100" data-setting="interest_percent" value="${this._num(s.interest_percent, 5)}"></label>
<label>${this._t("panel.settings_decay_period")}
<select class="tm-select" data-setting="interest_period">
<option value="weekly" ${(s.interest_period || "weekly") === "weekly" ? "selected" : ""}>${this._t("panel.reward_restock_weekly")}</option>
@@ -4544,7 +4720,7 @@ class TaskMatePanel extends HTMLElement {
</div>
<div class="tm-setting-row">
<div class="tm-setting-label">${this._t("panel.settings_perfect_week_bonus_label")}<small>${this._t("panel.settings_perfect_week_bonus_hint")}</small></div>
<input type="number" class="tm-input" min="0" data-setting="perfect_week_bonus" value="${s.perfect_week_bonus || 50}">
<input type="number" class="tm-input" min="0" data-setting="perfect_week_bonus" value="${this._num(s.perfect_week_bonus, 50)}">
</div>
<div class="tm-setting-row">
<div class="tm-setting-label">${this._t("panel.settings_celebration_notify_label")}<small>${this._t("panel.settings_celebration_notify_hint")}</small></div>
@@ -4658,7 +4834,7 @@ class TaskMatePanel extends HTMLElement {
<tr>
<td>${this._esc((p.date || "").slice(0, 10))}</td>
<td>${this._esc(p.child_name || "")}</td>
<td style="text-align:right">${p.points} → ${this._esc(p.currency || "")}${this._esc(String(p.amount))}</td>
<td style="text-align:right">${this._num(p.points)} → ${this._esc(p.currency || "")}${this._esc(String(p.amount))}</td>
</tr>
`).join("")}
</tbody></table></div>
@@ -5115,7 +5291,7 @@ class TaskMatePanel extends HTMLElement {
<div style="padding:0 14px 12px;border-top:1px solid var(--tm-border-soft)">
<div class="tm-field-row" style="margin-top:10px">
<div class="tm-field"><span class="tm-field-label">${this._t("panel.common_name")}</span><input class="tm-input" type="text" data-tpl-dialog-field="name" data-tpl-dialog-idx="${i}" value="${this._esc(c.name)}"></div>
<div class="tm-field"><span class="tm-field-label">${this._t("panel.common_points")}</span><input class="tm-input" type="number" data-tpl-dialog-field="points" data-tpl-dialog-idx="${i}" value="${c.points || 0}" min="0"></div>
<div class="tm-field"><span class="tm-field-label">${this._t("panel.common_points")}</span><input class="tm-input" type="number" data-tpl-dialog-field="points" data-tpl-dialog-idx="${i}" value="${this._num(c.points)}" min="0"></div>
</div>
<div class="tm-field-row">
<div class="tm-field"><span class="tm-field-label">${this._t("panel.chore_time_category_label")}</span>
@@ -5266,7 +5442,7 @@ class TaskMatePanel extends HTMLElement {
${this._select(this._t("panel.chore_first_occurrence_label"), "first_occurrence_mode", d.first_occurrence_mode, FIRST_OCCURRENCE)}
</div>
` : "",
this._select(this._t("panel.chore_completion_sound_label"), "completion_sound", d.completion_sound, COMPLETION_SOUNDS.map(s => ({ v: s, l: s }))),
this._soundField(d.completion_sound),
this._select(this._t("panel.chore_difficulty_label"), "difficulty", d.difficulty || "medium", [
{ v: "easy", l: this._t("panel.difficulty_easy") },
{ v: "medium", l: this._t("panel.difficulty_medium") },
@@ -5299,7 +5475,7 @@ class TaskMatePanel extends HTMLElement {
${(d.bonus_subtasks || []).map((b, idx) => `
<div class="tm-field-row" style="align-items:flex-end;gap:6px;margin-bottom:6px">
<div class="tm-field" style="flex:2;margin:0"><input class="tm-input" placeholder="${this._t("panel.chore_subtask_name_placeholder")}" value="${this._esc(b.name)}" data-field="bonus_subtasks[${idx}].name"></div>
<div class="tm-field" style="flex:0 0 70px;margin:0"><input class="tm-input" type="number" min="0" placeholder="${this._t("panel.chore_subtask_points_placeholder")}" value="${b.points}" data-field="bonus_subtasks[${idx}].points"></div>
<div class="tm-field" style="flex:0 0 70px;margin:0"><input class="tm-input" type="number" min="0" placeholder="${this._t("panel.chore_subtask_points_placeholder")}" value="${this._num(b.points)}" data-field="bonus_subtasks[${idx}].points"></div>
<button type="button" class="tm-btn tm-btn-icon" data-act="remove-bonus-subtask" data-idx="${idx}" title="${this._t("panel.tooltip_remove")}" style="padding:6px 10px">✕</button>
</div>
`).join("")}
@@ -5595,6 +5771,7 @@ class TaskMatePanel extends HTMLElement {
{ v: "monthly", l: this._t("panel.reward_restock_monthly") },
])}
</div>`,
this._renderRewardTimeLock(d),
this._renderRewardUnlock(d),
].join(""),
`<button type="button" class="tm-btn" data-act="close-dialog">${this._t("panel.btn_cancel")}</button>
@@ -5602,6 +5779,42 @@ class TaskMatePanel extends HTMLElement {
);
}
/**
* Time lock (#857). Restricts *claiming* to certain weekdays and/or a
* time-of-day window — the reward stays visible the rest of the time, greyed
* out with a "when it's available" badge, and saving points into a pool
* reward is never blocked.
*/
_renderRewardTimeLock(d) {
const open = this._dialog._openAdvanced?.has("timelock") || !!d.time_lock_enabled;
const days = (d.available_days || []).map(Number);
return `<details class="tm-advanced" data-section="timelock"${open ? " open" : ""}>
<summary>${this._t("panel.reward_timelock_section")}</summary>
<div>
${this._switch(this._t("panel.reward_timelock_label"), "time_lock_enabled", d.time_lock_enabled,
this._t("panel.reward_timelock_hint"), true)}
${d.time_lock_enabled ? `
<div class="tm-field">
<span class="tm-field-label">${this._t("panel.reward_timelock_days_label")}</span>
<div class="tm-chip-row">
${DAYS.map((day, i) => `
<button type="button" class="tm-chip-btn ${days.includes(i) ? "tm-chip-on" : ""}" data-act="toggle-reward-day" data-day="${i}">
${this._t(day.lk)}
</button>
`).join("")}
</div>
<span class="tm-field-hint">${this._t("panel.reward_timelock_days_hint")}</span>
</div>
<div class="tm-field-row">
${this._timeField(this._t("panel.reward_timelock_from_label"), "available_from", d.available_from)}
${this._timeField(this._t("panel.reward_timelock_until_label"), "available_until", d.available_until)}
</div>
<span class="tm-field-hint">${this._t("panel.reward_timelock_times_hint")}</span>
` : ""}
</div>
</details>`;
}
/**
* Timed unlock (#678). Only offers entities the parent has allowlisted in
* Settings — a reward must not be able to reach arbitrary devices.
@@ -5845,7 +6058,7 @@ class TaskMatePanel extends HTMLElement {
</div>
</div>
` : "",
this._select(this._t("panel.chore_completion_sound_label"), "completion_sound", d.completion_sound, COMPLETION_SOUNDS.map(s => ({ v: s, l: s }))),
this._soundField(d.completion_sound),
this._select(this._t("panel.chore_difficulty_label"), "difficulty", d.difficulty || "medium", [
{ v: "easy", l: this._t("panel.difficulty_easy") },
{ v: "medium", l: this._t("panel.difficulty_medium") },
@@ -5881,7 +6094,7 @@ class TaskMatePanel extends HTMLElement {
<div class="tm-reorder-item" draggable="true" data-drag-id="${this._esc(id)}">
<div class="tm-reorder-handle">⠿</div>
<div class="tm-reorder-name">${this._esc(c.name)}</div>
<div class="tm-reorder-points tm-numeric">${c.points}</div>
<div class="tm-reorder-points tm-numeric">${this._num(c.points)}</div>
<div class="tm-reorder-moves">
<button type="button" class="tm-reorder-move" data-act="reorder-move" data-dir="-1" data-drag-id="${this._esc(id)}" ${idx === 0 ? "disabled" : ""} title="${this._t("reorder.move_up")}" aria-label="${this._t("reorder.move_up")}">▲</button>
<button type="button" class="tm-reorder-move" data-act="reorder-move" data-dir="1" data-drag-id="${this._esc(id)}" ${idx === arr.length - 1 ? "disabled" : ""} title="${this._t("reorder.move_down")}" aria-label="${this._t("reorder.move_down")}">▼</button>
@@ -5910,7 +6123,7 @@ class TaskMatePanel extends HTMLElement {
</div>`;
}
_field(label, name, value, type = "text", hint = "") {
_field(label, name, value, type = "text", hintHtml = "") {
return `
<div class="tm-field">
<span class="tm-field-label">${this._esc(label)}</span>
@@ -5920,11 +6133,11 @@ class TaskMatePanel extends HTMLElement {
value="${this._esc(value == null ? "" : value)}"
${type === "number" ? 'type="number"' : 'type="text"'}
>
${hint ? `<span class="tm-field-hint">${hint}</span>` : ""}
${hintHtml ? `<span class="tm-field-hint">${hintHtml}</span>` : ""}
</div>`;
}
_dateField(label, name, value, hint = "") {
_dateField(label, name, value, hintHtml = "") {
return `
<div class="tm-field">
<span class="tm-field-label">${this._esc(label)}</span>
@@ -5934,11 +6147,25 @@ class TaskMatePanel extends HTMLElement {
value="${this._esc(value || "")}"
type="date"
>
${hint ? `<span class="tm-field-hint">${hint}</span>` : ""}
${hintHtml ? `<span class="tm-field-hint">${hintHtml}</span>` : ""}
</div>`;
}
_select(label, name, value, options, hint = "", rerender = false) {
_timeField(label, name, value, hintHtml = "") {
return `
<div class="tm-field">
<span class="tm-field-label">${this._esc(label)}</span>
<input
class="tm-input"
data-field="${name}"
value="${this._esc(value || "")}"
type="time"
>
${hintHtml ? `<span class="tm-field-hint">${hintHtml}</span>` : ""}
</div>`;
}
_select(label, name, value, options, hintHtml = "", rerender = false) {
return `
<div class="tm-field">
<span class="tm-field-label">${this._esc(label)}</span>
@@ -5949,17 +6176,17 @@ class TaskMatePanel extends HTMLElement {
>
${options.map(o => `<option value="${this._esc(o.v)}" ${String(o.v) === String(value) ? "selected" : ""}>${this._esc(o.lk ? this._t(o.lk) : o.l)}</option>`).join("")}
</select>
${hint ? `<span class="tm-field-hint">${hint}</span>` : ""}
${hintHtml ? `<span class="tm-field-hint">${hintHtml}</span>` : ""}
</div>`;
}
_switch(label, name, checked, hint = "", rerender = false) {
_switch(label, name, checked, hintHtml = "", rerender = false) {
return `
<div class="tm-check-row">
<ha-switch data-field="${name}" ${checked ? "checked" : ""} ${rerender ? 'data-rerender="true"' : ""}></ha-switch>
<div>
<div class="tm-check-title">${this._esc(label)}</div>
${hint ? `<span class="tm-field-hint">${hint}</span>` : ""}
${hintHtml ? `<span class="tm-field-hint">${hintHtml}</span>` : ""}
</div>
</div>`;
}
@@ -5985,11 +6212,63 @@ class TaskMatePanel extends HTMLElement {
</div>`;
}
/** Uploaded custom sounds from the last get_state (#856). */
_customSounds() {
return (this._state && this._state.custom_sounds) || [];
}
/** Built-in + uploaded sounds, as <select> options. */
_soundOptions() {
return [
...COMPLETION_SOUNDS.map(v => ({ v, l: v })),
...this._customSounds().map(s => ({ v: s.value, l: s.name })),
];
}
/**
* The chore editor's completion-sound picker, with a preview button.
*
* Picking a sound used to be silent here: the only preview code lived in
* taskmate-config-sounds.js, which scans the document for sound dropdowns
* and so could never see this one inside the panel's shadow root (#856).
*/
_soundField(value) {
const current = value || "coin";
return `
<div class="tm-field">
<span class="tm-field-label">${this._t("panel.chore_completion_sound_label")}</span>
<div class="tm-sound-row">
<select class="tm-select" data-field="completion_sound">
${this._soundOptions().map(o => `<option value="${this._esc(o.v)}" ${String(o.v) === String(current) ? "selected" : ""}>${this._esc(o.l)}</option>`).join("")}
</select>
<button type="button" class="tm-btn tm-btn-sm" data-act="sound-preview-field"
title="${this._esc(this._t("panel.sound_preview"))}"
aria-label="${this._esc(this._t("panel.sound_preview"))}">▶</button>
</div>
</div>`;
}
/** Play a sound through the shared engine (taskmate-sounds.js). */
_previewSound(value) {
const engine = window.__taskmate_sounds;
if (engine) engine.play(value, this._customSounds());
}
/**
* Icon field. `ha-icon-picker` is HA's own component — we keep it for the
* searchable MDI list, but its Material shell (grey fill, underline, 56px
* box) reads as a foreign control beside the panel's own inputs. The
* `.tm-icon-well` wrapper supplies the tm-input frame and the picker inside
* it is made transparent; see the CSS for why the wrapper, not the picker,
* carries the border.
*/
_iconPickerField(label, name, value) {
return `
<div class="tm-field">
<span class="tm-field-label">${this._esc(label)}</span>
<ha-icon-picker data-field="${name}" data-current="${this._esc(value || "")}"></ha-icon-picker>
<div class="tm-icon-well">
<ha-icon-picker data-field="${name}" data-current="${this._esc(value || "")}"></ha-icon-picker>
</div>
</div>`;
}
@@ -6014,7 +6293,7 @@ class TaskMatePanel extends HTMLElement {
else { (this._settingsEntityDraft = this._settingsEntityDraft || {})[field] = value; }
}
_entityPickerField(label, name, value, domains, hint = "") {
_entityPickerField(label, name, value, domains, hintHtml = "") {
const friendly = value && this._hass?.states?.[value]?.attributes?.friendly_name || "";
const icon = value ? (this._hass?.states?.[value]?.attributes?.icon || this._entityDomainIcon(value)) : "";
return `
@@ -6033,7 +6312,7 @@ class TaskMatePanel extends HTMLElement {
</div>`
}
</div>
${hint ? `<span class="tm-field-hint">${hint}</span>` : ""}
${hintHtml ? `<span class="tm-field-hint">${hintHtml}</span>` : ""}
</div>`;
}
@@ -6208,6 +6487,15 @@ class TaskMatePanel extends HTMLElement {
return new Intl.NumberFormat().format(n);
}
// A number bound for a markup or attribute slot. The panel assembles its
// HTML as strings, so anything stored in a field we *assume* is numeric has
// to be proven numeric before it is echoed back. Non-numbers, inf and NaN
// fall back rather than reaching the DOM.
_num(v, fallback = 0) {
const n = Number(v);
return Number.isFinite(n) ? n : fallback;
}
// Quick point-adjust amounts (#746). Stored as a comma-separated string like
// "5, 10, 20" — same shape as streak_milestones — so the generic settings
// collector can round-trip it as a plain text input. Junk degrades to the
@@ -6740,6 +7028,24 @@ class TaskMatePanel extends HTMLElement {
}
.tm-image-actions { display: flex; gap: 6px; flex-wrap: wrap; }
/* Custom completion sounds (#856) */
.tm-sound-row { display: flex; gap: 6px; align-items: center; }
.tm-sound-row .tm-select { flex: 1; min-width: 0; }
.tm-sound-list {
display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px;
}
.tm-sound-item {
display: flex; align-items: center; gap: 10px;
padding: 8px 10px; border: 1px solid var(--tm-border);
border-radius: var(--tm-radius-sm); background: var(--tm-surface-2);
}
.tm-sound-name {
flex: 1; min-width: 0; font-size: 14px; font-weight: 600;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.tm-sound-item-actions { display: flex; gap: 6px; flex-wrap: wrap; }
.tm-sound-upload { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.tm-points-adjust {
display: flex; gap: 4px; flex-wrap: wrap;
align-items: center;
@@ -6911,6 +7217,8 @@ class TaskMatePanel extends HTMLElement {
.tm-pill-pool { background: var(--tm-pool-soft); color: var(--tm-pool); border-color: color-mix(in srgb, var(--tm-pool), transparent 75%); }
.tm-pill-sticky { background: var(--tm-sticky-soft); color: var(--tm-sticky); border-color: color-mix(in srgb, var(--tm-sticky), transparent 75%); }
.tm-pill-spread { background: var(--tm-positive-soft); color: var(--tm-positive); border-color: var(--tm-positive-border); }
.tm-pill-timelock { background: var(--tm-accent-soft); color: var(--tm-accent-text); border-color: var(--tm-accent-border); }
.tm-pill-timelock ha-icon { --mdc-icon-size: 13px; vertical-align: -2px; }
.tm-pill-jackpot { background: var(--tm-gold-soft); color: var(--tm-gold); border-color: color-mix(in srgb, var(--tm-gold), transparent 75%); }
.tm-pill-muted { background: var(--tm-surface-2); color: var(--tm-text-muted); border-color: var(--tm-border); }
.tm-quest-inactive { opacity: 0.6; }
@@ -7335,6 +7643,42 @@ class TaskMatePanel extends HTMLElement {
display: block; width: 100%;
}
/* Icon field well: make ha-icon-picker sit in the dialogs like a
tm-input. The picker's inner shell can't be reached from here (shadow
DOM), so the wrapper carries the frame and the picker is stripped back:
- the fill and the 1px "active indicator" are var() usages inside
ha-picker-field, so overriding the vars here does reach them;
- ha-combo-box-item hard-sets its own 56px row height and square
bottom corners, which no variable overrides — so the well is
tm-input height with overflow:hidden, and the picker is pulled up
by half the difference to keep its content optically centred.
The --mdc-* vars cover older HA, which still renders an mwc textfield. */
.tm-icon-well {
box-sizing: border-box;
height: 37px;
background: var(--tm-surface-0);
border: 1px solid var(--tm-border);
border-radius: var(--tm-radius-sm);
box-shadow: var(--tm-shadow-xs);
overflow: hidden;
transition: all 0.1s var(--tm-easing);
}
.tm-icon-well:hover { border-color: var(--tm-border-strong); }
.tm-icon-well:focus-within { border-color: var(--tm-accent); box-shadow: var(--tm-shadow-focus); }
.tm-icon-well ha-icon-picker {
display: block; width: 100%;
margin-top: calc((37px - 56px) / 2);
--ha-color-form-background: transparent;
--ha-color-border-neutral-loud: transparent;
--md-list-item-leading-space: 11px;
--md-list-item-trailing-space: 4px;
--md-list-item-label-text-size: 13px;
--mdc-text-field-fill-color: transparent;
--mdc-text-field-idle-line-color: transparent;
--mdc-text-field-hover-line-color: transparent;
--mdc-text-field-focused-line-color: transparent;
}
/* Chip multi-select */
/* Insight reports (#679) */
.tm-verdict {
@@ -325,6 +325,13 @@ class TaskMateRewardProgressCard extends LitElement {
background: color-mix(in srgb, var(--error-color, #db4437) 18%, transparent);
color: var(--error-color, #db4437);
}
.availability-badge.badge-time-locked {
background: color-mix(in srgb, var(--info-color, #3498db) 18%, transparent);
color: var(--info-color, #2471a3);
/* Longer than the other badges — uppercasing a whole window reads badly. */
text-transform: none;
letter-spacing: 0;
}
.availability-badge.badge-expiring-soon {
background: color-mix(in srgb, var(--warning-color, #ff9800) 20%, transparent);
color: var(--warning-color, #ff9800);
@@ -503,6 +510,9 @@ class TaskMateRewardProgressCard extends LitElement {
} else if (isSoldOut) {
availabilityLabel = this._t('rewards.sold_out');
availabilityClass = 'badge-sold-out';
} else if (reward.is_time_locked === true) {
availabilityLabel = this._timeLockLabel(reward);
availabilityClass = 'badge-time-locked';
} else if (typeof reward.quantity === 'number' && reward.quantity > 0 && reward.quantity <= 3) {
availabilityLabel = this._t('rewards.only_n_left', { count: reward.quantity });
availabilityClass = 'badge-low-stock';
@@ -559,6 +569,14 @@ class TaskMateRewardProgressCard extends LitElement {
`;
}
/** "Available Fri, Sat · 18:00–21:00" for a time-locked reward (#857). */
_timeLockLabel(reward) {
const summary = window.__taskmate_time_lock_label
? window.__taskmate_time_lock_label(reward.time_lock, (k, pr) => this._t(k, pr))
: '';
return summary ? this._t('rewards.available_when', { window: summary }) : this._t('rewards.locked_now');
}
_renderChildProgress(child, reward, pointsIcon, pointsName) {
const cost = reward.calculated_costs?.[child.id] ?? reward.cost;
const have = child.points || 0;
@@ -760,6 +778,9 @@ class TaskMateRewardProgressCard extends LitElement {
} else if (isSoldOut) {
availabilityLabel = this._t('rewards.sold_out');
availabilityTone = 'dim';
} else if (reward.is_time_locked === true) {
availabilityLabel = this._timeLockLabel(reward);
availabilityTone = 'dim';
} else if (typeof reward.quantity === 'number' && reward.quantity > 0 && reward.quantity <= 3) {
availabilityLabel = this._t('rewards.only_n_left', { count: reward.quantity });
availabilityTone = 'bad';
@@ -587,6 +587,12 @@ class TaskMateRewardsCard extends LitElement {
background: rgba(231,76,60,0.15);
color: #c0392b;
}
.availability-badge.badge-time-locked {
gap: 3px;
background: rgba(52,152,219,0.16);
color: #2471a3;
}
.availability-badge.badge-time-locked ha-icon { --mdc-icon-size: 13px; }
.availability-badge.badge-expiring-soon {
background: rgba(243,156,18,0.18);
color: #d35400;
@@ -1393,10 +1399,12 @@ class TaskMateRewardsCard extends LitElement {
const isLoading = this._loading[reward.id];
const isAllocLoading = this._loading[`${reward.id}_alloc`];
// Availability (quantity / expiration)
// Availability (quantity / expiration / time lock)
const isSoldOut = reward.is_sold_out === true;
const isExpired = reward.is_expired === true;
const isUnavailable = isSoldOut || isExpired;
const isTimeLocked = reward.is_time_locked === true;
const canClaimNow = !isUnavailable && !isTimeLocked;
const daysUntilExpiry = (typeof reward.days_until_expiry === 'number')
? reward.days_until_expiry
: null;
@@ -1407,7 +1415,7 @@ class TaskMateRewardsCard extends LitElement {
const showPoolControls = enablePoolMode && childId && !hasPendingClaim && !isUnavailable;
return html`
<div class="reward-row ${isJackpot ? 'jackpot' : ''} ${hasPendingClaim ? 'pending-approval' : ''} ${enablePoolMode ? 'pool-mode' : ''} ${isUnavailable ? 'unavailable' : ''}">
<div class="reward-row ${isJackpot ? 'jackpot' : ''} ${hasPendingClaim ? 'pending-approval' : ''} ${enablePoolMode ? 'pool-mode' : ''} ${isUnavailable || isTimeLocked ? 'unavailable' : ''}">
<div class="cost-badge">
<ha-icon icon="${pointsIcon}"></ha-icon>
<span class="cost-value">${displayCost}</span>
@@ -1450,7 +1458,7 @@ class TaskMateRewardsCard extends LitElement {
: ""}
</div>
<div class="reward-right-col">
${!enablePoolMode && !hasPendingClaim && childId && !isUnavailable ? html`
${!enablePoolMode && !hasPendingClaim && childId && canClaimNow ? html`
<button
class="claim-btn ${!canAfford ? 'cant-afford' : ''}"
?disabled="${!canAfford || isLoading}"
@@ -1460,7 +1468,7 @@ class TaskMateRewardsCard extends LitElement {
<ha-icon icon="${isLoading ? 'mdi:loading' : rewardIcon}"></ha-icon>
</button>
` : html`
<div class="reward-icon-container" title="${isUnavailable ? this._t('rewards.unavailable_hint') : ''}">
<div class="reward-icon-container" title="${isTimeLocked ? this._timeLockLabel(reward) : (isUnavailable ? this._t('rewards.unavailable_hint') : '')}">
<ha-icon icon="${rewardIcon}"></ha-icon>
</div>
`}
@@ -1469,6 +1477,14 @@ class TaskMateRewardsCard extends LitElement {
`;
}
/** "Available Fri, Sat · 18:00–21:00" for a time-locked reward (#857). */
_timeLockLabel(reward) {
const summary = window.__taskmate_time_lock_label
? window.__taskmate_time_lock_label(reward.time_lock, (k, pr) => this._t(k, pr))
: '';
return summary ? this._t('rewards.available_when', { window: summary }) : this._t('rewards.locked_now');
}
_renderAvailabilityBadge(reward, isSoldOut, isExpired, daysUntilExpiry) {
if (isExpired) {
return html`<div class="availability-badge badge-expired">${this._t('rewards.expired')}</div>`;
@@ -1476,6 +1492,11 @@ class TaskMateRewardsCard extends LitElement {
if (isSoldOut) {
return html`<div class="availability-badge badge-sold-out">${this._t('rewards.sold_out')}</div>`;
}
if (reward.is_time_locked === true) {
return html`<div class="availability-badge badge-time-locked">
<ha-icon icon="mdi:clock-outline"></ha-icon>${this._timeLockLabel(reward)}
</div>`;
}
const badges = [];
if (typeof reward.quantity === 'number' && reward.quantity > 0 && reward.quantity <= 3) {
badges.push(html`<div class="availability-badge badge-low-stock">${this._t('rewards.only_n_left', { count: reward.quantity })}</div>`);
@@ -1872,14 +1893,17 @@ class TaskMateRewardsCard extends LitElement {
const isSoldOut = reward.is_sold_out === true;
const isExpired = reward.is_expired === true;
const isUnavailable = isSoldOut || isExpired;
// Time lock (#857) is temporary: it blocks claiming and greys the reward,
// but leaves pool allocation alone — saving up is not spending.
const isTimeLocked = reward.is_time_locked === true;
const daysUntilExpiry = (typeof reward.days_until_expiry === 'number')
? reward.days_until_expiry : null;
return {
reward, isJackpot, enablePoolMode, displayCost, relevantChildren, relevantChild,
childId, currentStars, childContributions, percentage, hasPendingClaim,
spendable, poolFull, canAfford, isSoldOut, isExpired, isUnavailable, daysUntilExpiry,
showClaim: !enablePoolMode && !hasPendingClaim && childId && !isUnavailable,
spendable, poolFull, canAfford, isSoldOut, isExpired, isUnavailable, isTimeLocked, daysUntilExpiry,
showClaim: !enablePoolMode && !hasPendingClaim && childId && !isUnavailable && !isTimeLocked,
showPoolControls: enablePoolMode && childId && !hasPendingClaim && !isUnavailable,
};
}
@@ -1888,6 +1912,7 @@ class TaskMateRewardsCard extends LitElement {
_designAvail(d) {
if (d.isExpired) return { label: this._t('rewards.expired'), tone: 'muted' };
if (d.isSoldOut) return { label: this._t('rewards.sold_out'), tone: 'muted' };
if (d.isTimeLocked) return { label: this._timeLockLabel(d.reward), tone: 'muted' };
if (typeof d.reward.quantity === 'number' && d.reward.quantity > 0 && d.reward.quantity <= 3) {
return { label: this._t('rewards.only_n_left', { count: d.reward.quantity }), tone: 'bad' };
}
@@ -2049,7 +2074,7 @@ class TaskMateRewardsCard extends LitElement {
: html`<span class="muted rw-status">${d.currentStars} / ${d.displayCost}</span>`;
return html`
<div class="rw-card ${d.isJackpot ? 'rw-jackpot' : ''} ${d.isUnavailable ? 'rw-unavail' : ''}"
<div class="rw-card ${d.isJackpot ? 'rw-jackpot' : ''} ${d.isUnavailable || d.isTimeLocked ? 'rw-unavail' : ''}"
style="--ac:${tone}">
<div class="row rw-top">
<span class="rw-emoji">${emoji}</span>
@@ -0,0 +1,367 @@
/**
* TaskMate shared sound engine.
*
* Single home for every completion sound, so the child card, the admin panel
* and the config-flow preview module all behave identically. Before this
* existed the six synthesised sounds were copy-pasted into two files that had
* already drifted apart (one still knew only about a long-gone "fart"/"fart_long"
* pair while the real list had moved to fart1..fart10).
*
* Three kinds of sound:
* synthesised — generated on the fly via Web Audio API, no files needed
* built-in files — the CC0 fart mp3s shipped in this www folder
* custom — user-uploaded, referenced as "custom:<file>" (#856)
*
* A custom sound is resolved through a lookup table the caller passes in (the
* `custom_sounds` attribute of sensor.taskmate_chores), because its URL is
* signed server-side — a bare Audio() carries no bearer token and would 401
* against the auth-gated serve view.
*
* Mirrors window.__taskmate_design: exposed globally, no ES module imports.
*/
(function () {
"use strict";
// Base URL for files shipped inside the integration's www folder. Registered
// by frontend.py as a static path. NOT /local/, which maps to config/www and
// is where these files have never lived — that mismatch silently 404'd every
// fart sound until #856.
const FILE_BASE = "/taskmate";
const CUSTOM_PREFIX = "custom:";
const FART_COUNT = 10;
// Built-in names, mirroring COMPLETION_SOUND_OPTIONS in const.py.
const BUILTIN = [
"none", "coin", "levelup", "fanfare", "chime", "powerup", "undo",
"fart1", "fart2", "fart3", "fart4", "fart5", "fart6", "fart7",
"fart8", "fart9", "fart10", "fart_random",
];
// One shared AudioContext per page. Browsers cap how many a page may create,
// and each card minting its own used to leak one per card instance.
let audioContext = null;
function getAudioContext() {
if (!audioContext) {
const Ctor = window.AudioContext || window.webkitAudioContext;
if (!Ctor) return null;
audioContext = new Ctor();
}
// Autoplay policy: a context created before any user gesture starts
// suspended and stays silent until resumed.
if (audioContext.state === "suspended") audioContext.resume();
return audioContext;
}
function playCoinSound(ctx, startTime) {
const masterGain = ctx.createGain();
masterGain.connect(ctx.destination);
masterGain.gain.value = 0.3;
// First tone (E6)
const osc1 = ctx.createOscillator();
const gain1 = ctx.createGain();
osc1.connect(gain1);
gain1.connect(masterGain);
osc1.frequency.value = 1318.5; // E6
osc1.type = 'square';
gain1.gain.setValueAtTime(0.5, startTime);
gain1.gain.exponentialRampToValueAtTime(0.01, startTime + 0.1);
osc1.start(startTime);
osc1.stop(startTime + 0.1);
// Second tone (B6) - higher
const osc2 = ctx.createOscillator();
const gain2 = ctx.createGain();
osc2.connect(gain2);
gain2.connect(masterGain);
osc2.frequency.value = 1975.5; // B6
osc2.type = 'square';
gain2.gain.setValueAtTime(0.5, startTime + 0.08);
gain2.gain.exponentialRampToValueAtTime(0.01, startTime + 0.25);
osc2.start(startTime + 0.08);
osc2.stop(startTime + 0.25);
}
/**
* Level up sound - triumphant ascending arpeggio
*/
function playLevelUpSound(ctx, startTime) {
const masterGain = ctx.createGain();
masterGain.connect(ctx.destination);
masterGain.gain.value = 0.25;
// C major arpeggio going up: C5, E5, G5, C6
const notes = [523.25, 659.25, 783.99, 1046.5];
const duration = 0.12;
notes.forEach((freq, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(masterGain);
osc.frequency.value = freq;
osc.type = 'square';
const noteStart = startTime + i * duration;
gain.gain.setValueAtTime(0.6, noteStart);
gain.gain.exponentialRampToValueAtTime(0.01, noteStart + duration + 0.1);
osc.start(noteStart);
osc.stop(noteStart + duration + 0.15);
});
// Final sustained chord
const chordNotes = [523.25, 659.25, 783.99]; // C major chord
const chordStart = startTime + notes.length * duration;
chordNotes.forEach((freq) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(masterGain);
osc.frequency.value = freq;
osc.type = 'triangle';
gain.gain.setValueAtTime(0.3, chordStart);
gain.gain.exponentialRampToValueAtTime(0.01, chordStart + 0.5);
osc.start(chordStart);
osc.stop(chordStart + 0.55);
});
}
/**
* Fanfare sound - celebratory trumpet-like fanfare
*/
function playFanfareSound(ctx, startTime) {
const masterGain = ctx.createGain();
masterGain.connect(ctx.destination);
masterGain.gain.value = 0.2;
// Fanfare pattern: G4, G4, G4, E4, G4, C5 (classic celebration pattern)
const pattern = [
{ freq: 392.00, duration: 0.1, delay: 0 }, // G4
{ freq: 392.00, duration: 0.1, delay: 0.12 }, // G4
{ freq: 392.00, duration: 0.15, delay: 0.24 }, // G4
{ freq: 329.63, duration: 0.15, delay: 0.42 }, // E4
{ freq: 392.00, duration: 0.15, delay: 0.6 }, // G4
{ freq: 523.25, duration: 0.4, delay: 0.78 }, // C5 (long final note)
];
pattern.forEach(({ freq, duration, delay }) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(masterGain);
osc.frequency.value = freq;
osc.type = 'sawtooth';
const noteStart = startTime + delay;
gain.gain.setValueAtTime(0.5, noteStart);
gain.gain.setValueAtTime(0.5, noteStart + duration * 0.8);
gain.gain.exponentialRampToValueAtTime(0.01, noteStart + duration);
osc.start(noteStart);
osc.stop(noteStart + duration + 0.05);
});
}
/**
* Chime sound - simple pleasant bell chime
*/
function playChimeSound(ctx, startTime) {
const masterGain = ctx.createGain();
masterGain.connect(ctx.destination);
masterGain.gain.value = 0.3;
// Bell-like sound using multiple harmonics
const fundamental = 880; // A5
const harmonics = [1, 2, 3, 4.2]; // Slight inharmonicity for bell-like quality
harmonics.forEach((harmonic, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(masterGain);
osc.frequency.value = fundamental * harmonic;
osc.type = 'sine';
// Higher harmonics decay faster
const amplitude = 0.5 / (i + 1);
const decayTime = 0.8 / (i + 1);
gain.gain.setValueAtTime(amplitude, startTime);
gain.gain.exponentialRampToValueAtTime(0.001, startTime + decayTime);
osc.start(startTime);
osc.stop(startTime + decayTime + 0.1);
});
}
/**
* Power up sound - ascending sweep with sparkle
*/
function playPowerUpSound(ctx, startTime) {
const masterGain = ctx.createGain();
masterGain.connect(ctx.destination);
masterGain.gain.value = 0.25;
// Ascending sweep
const osc1 = ctx.createOscillator();
const gain1 = ctx.createGain();
osc1.connect(gain1);
gain1.connect(masterGain);
osc1.type = 'sawtooth';
osc1.frequency.setValueAtTime(200, startTime);
osc1.frequency.exponentialRampToValueAtTime(1200, startTime + 0.3);
gain1.gain.setValueAtTime(0.4, startTime);
gain1.gain.exponentialRampToValueAtTime(0.01, startTime + 0.35);
osc1.start(startTime);
osc1.stop(startTime + 0.4);
// Sparkle notes at the end
const sparkleNotes = [1318.5, 1567.98, 1975.5]; // E6, G6, B6
sparkleNotes.forEach((freq, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(masterGain);
osc.frequency.value = freq;
osc.type = 'sine';
const noteStart = startTime + 0.25 + i * 0.05;
gain.gain.setValueAtTime(0.3, noteStart);
gain.gain.exponentialRampToValueAtTime(0.01, noteStart + 0.2);
osc.start(noteStart);
osc.stop(noteStart + 0.25);
});
}
/**
* Undo sound - sad descending "womp womp" style
* Two descending tones that sound disappointed/sad
*/
function playUndoSound(ctx, startTime) {
const masterGain = ctx.createGain();
masterGain.connect(ctx.destination);
masterGain.gain.value = 0.25;
// First "womp" - descending tone
const osc1 = ctx.createOscillator();
const gain1 = ctx.createGain();
osc1.connect(gain1);
gain1.connect(masterGain);
osc1.type = 'triangle';
osc1.frequency.setValueAtTime(311.13, startTime); // Eb4
osc1.frequency.exponentialRampToValueAtTime(233.08, startTime + 0.25); // Bb3
gain1.gain.setValueAtTime(0.6, startTime);
gain1.gain.exponentialRampToValueAtTime(0.3, startTime + 0.2);
gain1.gain.exponentialRampToValueAtTime(0.01, startTime + 0.3);
osc1.start(startTime);
osc1.stop(startTime + 0.35);
// Second "womp" - even lower descending tone (the sad part)
const osc2 = ctx.createOscillator();
const gain2 = ctx.createGain();
osc2.connect(gain2);
gain2.connect(masterGain);
osc2.type = 'triangle';
osc2.frequency.setValueAtTime(233.08, startTime + 0.3); // Bb3
osc2.frequency.exponentialRampToValueAtTime(155.56, startTime + 0.7); // Eb3
gain2.gain.setValueAtTime(0.5, startTime + 0.3);
gain2.gain.exponentialRampToValueAtTime(0.25, startTime + 0.55);
gain2.gain.exponentialRampToValueAtTime(0.01, startTime + 0.75);
osc2.start(startTime + 0.3);
osc2.stop(startTime + 0.8);
// Optional: add a subtle low vibrato for extra sadness
const osc3 = ctx.createOscillator();
const gain3 = ctx.createGain();
osc3.connect(gain3);
gain3.connect(masterGain);
osc3.type = 'sine';
osc3.frequency.setValueAtTime(116.54, startTime + 0.5); // Bb2 (sub bass)
gain3.gain.setValueAtTime(0.15, startTime + 0.5);
gain3.gain.exponentialRampToValueAtTime(0.01, startTime + 0.8);
osc3.start(startTime + 0.5);
osc3.stop(startTime + 0.85);
}
/**
* Play an audio file by URL. Used for the shipped fart mp3s and for
* user-uploaded custom sounds.
*/
function playUrl(url) {
try {
const audio = new Audio(url);
audio.volume = 1.0;
audio.play().catch(() => { /* autoplay blocked until a user gesture */ });
} catch (e) { /* no Audio support */ }
}
function playFile(filename) {
playUrl(`${FILE_BASE}/${filename}`);
}
/** True for a well-formed "custom:<32hex>.<ext>" value. Mirrors sounds.is_custom_sound. */
function isCustom(name) {
return typeof name === "string"
&& name.startsWith(CUSTOM_PREFIX)
&& /^[0-9a-f]{32}\.(mp3|ogg|wav|m4a)$/.test(name.slice(CUSTOM_PREFIX.length));
}
/**
* Resolve a custom sound value to its signed URL via the lookup table.
* `customSounds` is the list delivered on sensor.taskmate_chores.
*/
function customUrl(name, customSounds) {
if (!isCustom(name) || !Array.isArray(customSounds)) return null;
const hit = customSounds.find(s => s && s.value === name);
return (hit && hit.url) || null;
}
/**
* Play a completion sound by name.
*
* @param {string} name - built-in name, or "custom:<file>"
* @param {Array} customSounds - optional lookup table for custom sounds
*/
function play(name, customSounds) {
if (!name || name === "none") return;
if (isCustom(name)) {
const url = customUrl(name, customSounds);
// A dangling reference (sound deleted, or a card whose sensor data
// predates the upload) stays silent rather than playing the wrong thing.
if (url) playUrl(url);
return;
}
if (name === "fart_random") {
playFile(`fart${Math.floor(Math.random() * FART_COUNT) + 1}.mp3`);
return;
}
if (/^fart([1-9]|10)$/.test(name)) {
playFile(`${name}.mp3`);
return;
}
const ctx = getAudioContext();
if (!ctx) return;
const now = ctx.currentTime;
try {
switch (name) {
case "coin": playCoinSound(ctx, now); break;
case "levelup": playLevelUpSound(ctx, now); break;
case "fanfare": playFanfareSound(ctx, now); break;
case "chime": playChimeSound(ctx, now); break;
case "powerup": playPowerUpSound(ctx, now); break;
case "undo": playUndoSound(ctx, now); break;
default: playCoinSound(ctx, now); break;
}
} catch (e) { /* a failed sound must never break a completion */ }
}
window.__taskmate_sounds = { BUILTIN, CUSTOM_PREFIX, play, isCustom, customUrl, getAudioContext };
})();