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