456 files

This commit is contained in:
Home Assistant Version Control
2026-10-01 05:38:21 +00:00
parent 4192ecbaae
commit 5d6d845ebf
456 changed files with 64951 additions and 3828 deletions
+327 -6
View File
@@ -63,6 +63,10 @@ from .const import (
ATTR_REWARD_ID,
ATTR_SOUND,
BADGE_TIERS,
BOUNTY_CLAIM_HOURS_MAX,
BOUNTY_DESCRIPTION_MAX_LENGTH,
BOUNTY_POINTS_MAX,
BOUNTY_TITLE_MAX_LENGTH,
CHORE_SUGGESTED_POINTS_MAX,
CONF_TASK_GROUP_CHORE_IDS,
CONF_TASK_GROUP_ID,
@@ -77,6 +81,7 @@ from .const import (
SERVICE_ADD_PENALTY,
SERVICE_ADD_POINTS,
SERVICE_ADD_TASK_GROUP,
SERVICE_ADJUST_STREAK_FREEZES,
SERVICE_ALLOCATE_POINTS_TO_POOL,
SERVICE_APPLY_BONUS,
SERVICE_APPLY_MANDATORY_PENALTY,
@@ -85,12 +90,18 @@ from .const import (
SERVICE_APPROVE_CHORE,
SERVICE_APPROVE_REWARD,
SERVICE_CHOOSE_AVATAR,
SERVICE_CLAIM_BOUNTY,
SERVICE_CLAIM_REWARD,
SERVICE_COMPLETE_BONUS_SUBTASK,
SERVICE_COMPLETE_BOUNTY,
SERVICE_COMPLETE_CHORE,
SERVICE_COMPLETE_NEXT_CHORE,
SERVICE_DISMISS_MANDATORY_CHORE,
SERVICE_GIFT_POINTS,
SERVICE_GIVE_BACK_BOUNTY,
SERVICE_LEAVE_TEAM_CHORE,
SERVICE_PAUSE_TIMED_TASK,
SERVICE_POST_BOUNTY,
SERVICE_POSTPONE_MANDATORY_CHORE,
SERVICE_PREVIEW_SOUND,
SERVICE_READ_ALOUD,
@@ -98,6 +109,7 @@ from .const import (
SERVICE_REJECT_CHORE,
SERVICE_REJECT_REWARD,
SERVICE_REMOVE_BONUS,
SERVICE_REMOVE_BOUNTY,
SERVICE_REMOVE_PENALTY,
SERVICE_REMOVE_POINTS,
SERVICE_REMOVE_TASK_GROUP,
@@ -109,17 +121,21 @@ from .const import (
SERVICE_START_TIMED_TASK,
SERVICE_STOP_TIMED_TASK,
SERVICE_TEST_NOTIFICATION,
SERVICE_UNDO_CHORE,
SERVICE_UNDO_CHORE_APPROVAL,
SERVICE_UNDO_TRANSACTION,
SERVICE_UPDATE_BONUS,
SERVICE_UPDATE_BOUNTY,
SERVICE_UPDATE_PENALTY,
SERVICE_UPDATE_TASK_GROUP,
TASK_GROUP_POLICIES,
TEAM_POINTS_MODES,
TEAM_SIZE_MAX,
TIME_CATEGORIES,
)
from .coordinator import TaskMateCoordinator
from .frontend import async_register_cards, async_register_frontend
from .models import Badge, BadgeCriterion
from .models import REJECT_REASON_MAX, Badge, BadgeCriterion
from .panel import async_register_panel
from .websocket import async_register_websocket_commands
@@ -191,6 +207,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
# Entities of children deleted before #946 stayed in the registry. Never
# let the sweep block setup.
try:
coordinator.async_prune_orphan_child_entities()
except Exception: # noqa: BLE001
_LOGGER.exception("Could not prune entities of deleted children")
# Likewise the buttons of chores/rewards deleted before #960.
try:
coordinator.async_prune_orphan_button_entities()
except Exception: # noqa: BLE001
_LOGGER.exception("Could not prune buttons of deleted chores or rewards")
# Register services (only once)
if not hass.data[DOMAIN].get(SERVICES_REGISTERED):
await _async_register_services(hass)
@@ -242,6 +270,7 @@ _DYNAMIC_SELECTOR_FIELDS: dict[str, str] = {
"penalty_id": "get_penalties",
"bonus_id": "get_bonuses",
"group_id": "get_task_groups",
"wish_id": "get_wishes",
}
_BASE_SERVICE_DESCRIPTIONS: dict | None = None
@@ -348,6 +377,9 @@ _AUDIT_TARGET_KEYS = (
"claim_id",
"transaction_id",
"type_id",
"wish_id",
"bounty_id",
"inspection_id",
)
@@ -538,6 +570,114 @@ async def _async_register_services(hass: HomeAssistant) -> None:
# validation error rather than an unhandled 500 + traceback.
raise ServiceValidationError(str(err)) from err
async def handle_complete_next_chore(call: ServiceCall) -> None:
"""Complete the child's next outstanding chore (#978), e.g. from a watch.
"Next" is the child card's order (see watch.next_chore_for_child); chores
that need a photo, a note, a timer or a team are skipped. The completion
goes through the normal path, so approval rules apply exactly as if the
child had tapped it. Nothing left to do is a quiet no-op, not an error —
a watch button pressed once too often shouldn't raise an alert.
"""
from .watch import next_chore_for_child
coordinator = _get_coordinator(hass)
if not coordinator:
_LOGGER.error("No TaskMate coordinator available")
return
child_id = call.data[ATTR_CHILD_ID]
# The same gate as a child completing a chore for themselves.
await _async_require_linked_child(hass, call, coordinator, child_id)
if coordinator.get_child(child_id) is None:
raise ServiceValidationError(f"Child {child_id} not found")
chore = next_chore_for_child(coordinator, child_id)
if chore is None:
_LOGGER.debug("complete_next_chore: nothing left to complete for %s", child_id)
return
await coordinator.async_complete_chore(chore.id, child_id)
async def handle_leave_team_chore(call: ServiceCall) -> None:
"""Take a child back out of a teamwork chore they joined (#928)."""
coordinator = _get_coordinator(hass)
if not coordinator:
_LOGGER.error("No TaskMate coordinator available")
return
child_id = call.data[ATTR_CHILD_ID]
# Same gate as completing: a child acts for themselves, parents for anyone.
await _async_require_linked_child(hass, call, coordinator, child_id)
await coordinator.async_leave_team_chore(call.data[ATTR_CHORE_ID], child_id)
# ── Bounty board (#931) ───────────────────────────────────────────────
_BOUNTY_FIELDS = (
"title",
"points",
"description",
"icon",
"expires_at",
"eligible_child_ids",
"claim_hours",
"require_photo",
"notify_children",
)
async def handle_post_bounty(call: ServiceCall) -> None:
"""A parent posts a one-off job to the bounty board."""
coordinator = _get_coordinator(hass)
if not coordinator:
_LOGGER.error("No TaskMate coordinator available")
return
extra = {k: call.data[k] for k in _BOUNTY_FIELDS if k in call.data and k not in ("title", "points")}
await coordinator.async_post_bounty(call.data["title"], call.data["points"], **extra)
async def handle_update_bounty(call: ServiceCall) -> None:
"""A parent edits a bounty (only points and expiry once it's claimed)."""
coordinator = _get_coordinator(hass)
if not coordinator:
_LOGGER.error("No TaskMate coordinator available")
return
changes = {k: call.data[k] for k in _BOUNTY_FIELDS if k in call.data}
await coordinator.async_update_bounty(call.data["bounty_id"], **changes)
async def handle_remove_bounty(call: ServiceCall) -> None:
"""A parent takes a bounty off the board."""
coordinator = _get_coordinator(hass)
if not coordinator:
_LOGGER.error("No TaskMate coordinator available")
return
await coordinator.async_remove_bounty(call.data["bounty_id"])
async def handle_claim_bounty(call: ServiceCall) -> None:
"""A child claims a bounty — same linked-child gate as completing a chore."""
coordinator = _get_coordinator(hass)
if not coordinator:
_LOGGER.error("No TaskMate coordinator available")
return
child_id = call.data[ATTR_CHILD_ID]
await _async_require_linked_child(hass, call, coordinator, child_id)
await coordinator.async_claim_bounty(call.data["bounty_id"], child_id)
async def handle_give_back_bounty(call: ServiceCall) -> None:
"""The claimer hands a bounty back to the board."""
coordinator = _get_coordinator(hass)
if not coordinator:
_LOGGER.error("No TaskMate coordinator available")
return
child_id = call.data[ATTR_CHILD_ID]
await _async_require_linked_child(hass, call, coordinator, child_id)
await coordinator.async_give_back_bounty(call.data["bounty_id"], child_id)
async def handle_complete_bounty(call: ServiceCall) -> None:
"""The claimer marks their bounty done — it goes to the approval queue."""
coordinator = _get_coordinator(hass)
if not coordinator:
_LOGGER.error("No TaskMate coordinator available")
return
child_id = call.data[ATTR_CHILD_ID]
await _async_require_linked_child(hass, call, coordinator, child_id)
await coordinator.async_complete_bounty(
call.data["bounty_id"], child_id, photo_url=call.data.get("photo_url", "")
)
async def handle_complete_bonus_subtask(call: ServiceCall) -> None:
"""Handle the complete_bonus_subtask service call."""
coordinator = _get_coordinator(hass)
@@ -584,7 +724,9 @@ async def _async_register_services(hass: HomeAssistant) -> None:
_LOGGER.error("No TaskMate coordinator available")
return
completion_id = call.data["completion_id"]
await coordinator.async_approve_chore(completion_id, points=call.data.get("points"))
await coordinator.async_approve_chore(
completion_id, points=call.data.get("points"), rating=call.data.get("rating")
)
async def handle_approve_all_chores(call: ServiceCall) -> None:
"""Handle the approve_all_chores service call."""
@@ -592,7 +734,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
if not coordinator:
_LOGGER.error("No TaskMate coordinator available")
return
await coordinator.async_approve_chores_bulk(call.data.get("completion_ids"))
await coordinator.async_approve_chores_bulk(call.data.get("completion_ids"), rating=call.data.get("rating"))
async def handle_reject_chore(call: ServiceCall) -> None:
"""Handle the reject_chore service call."""
@@ -601,7 +743,26 @@ async def _async_register_services(hass: HomeAssistant) -> None:
_LOGGER.error("No TaskMate coordinator available")
return
completion_id = call.data["completion_id"]
await coordinator.async_reject_chore(completion_id)
await coordinator.async_reject_chore(completion_id, reason=call.data.get("reason", ""))
async def handle_undo_chore(call: ServiceCall) -> None:
"""A child takes back their own chore inside the undo window (#918).
The child is read from the stored completion, never from the call, and
the linked-child rule is applied to that child — so one child can't
undo a sibling's chore. The window and parent-review checks live in the
coordinator. Parents keep reject_chore / undo_chore_approval.
"""
coordinator = _get_coordinator(hass)
if not coordinator:
_LOGGER.error("No TaskMate coordinator available")
return
completion_id = call.data["completion_id"]
completion = next((c for c in coordinator.storage.get_completions() if c.id == completion_id), None)
if completion is None:
raise ValueError("This chore has already been undone. Refresh the card.")
await _async_require_linked_child(hass, call, coordinator, completion.child_id)
await coordinator.async_undo_chore(completion_id)
async def handle_apply_mandatory_penalty(call: ServiceCall) -> None:
"""Handle apply_mandatory_penalty (deduct penalty for a missed mandatory chore)."""
@@ -663,6 +824,14 @@ async def _async_register_services(hass: HomeAssistant) -> None:
call.data["points"],
)
async def handle_adjust_streak_freezes(call: ServiceCall) -> None:
"""Grant (positive amount) or remove (negative) streak-freeze tokens (#925)."""
coordinator = _get_coordinator(hass)
if not coordinator:
_LOGGER.error("No TaskMate coordinator available")
return
await coordinator.async_adjust_streak_freezes(call.data[ATTR_CHILD_ID], call.data["amount"])
async def handle_record_allowance_payout(call: ServiceCall) -> None:
"""Record a parent-confirmed allowance payout (deduct points, log cash)."""
coordinator = _get_coordinator(hass)
@@ -741,7 +910,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
_LOGGER.error("No TaskMate coordinator available")
return
claim_id = call.data["claim_id"]
await coordinator.async_reject_reward(claim_id)
await coordinator.async_reject_reward(claim_id, reason=call.data.get("reason", ""))
async def handle_claim_reward(call: ServiceCall) -> None:
"""Handle the claim_reward service call."""
@@ -1004,6 +1173,9 @@ async def _async_register_services(hass: HomeAssistant) -> None:
schedule_mode=schedule_mode,
deadline_at=deadline_at,
speed_bonus_points=call.data.get(ATTR_CHORE_SPEED_BONUS_POINTS, 0),
team_size=call.data.get("team_size", 0),
team_points_mode=call.data.get("team_points_mode", "each"),
team_bonus=call.data.get("team_bonus", 0),
)
async def handle_add_badge(call: ServiceCall) -> None:
@@ -1138,6 +1310,13 @@ async def _async_register_services(hass: HomeAssistant) -> None:
),
)
hass.services.async_register(
DOMAIN,
SERVICE_COMPLETE_NEXT_CHORE,
_audited(handle_complete_next_chore),
schema=vol.Schema({vol.Required(ATTR_CHILD_ID): cv.string}),
)
hass.services.async_register(
DOMAIN,
SERVICE_COMPLETE_BONUS_SUBTASK,
@@ -1151,6 +1330,79 @@ async def _async_register_services(hass: HomeAssistant) -> None:
),
)
hass.services.async_register(
DOMAIN,
SERVICE_LEAVE_TEAM_CHORE,
_audited(handle_leave_team_chore),
schema=vol.Schema(
{
vol.Required(ATTR_CHORE_ID): cv.string,
vol.Required(ATTR_CHILD_ID): cv.string,
}
),
)
_bounty_editable = {
vol.Optional("description"): vol.All(cv.string, vol.Length(max=BOUNTY_DESCRIPTION_MAX_LENGTH)),
vol.Optional("icon"): cv.string,
# ISO date-time; "" clears it. A naive time is read as local time.
vol.Optional("expires_at"): vol.Any(None, cv.string),
vol.Optional("eligible_child_ids"): vol.All(cv.ensure_list, [cv.string]),
vol.Optional("claim_hours"): vol.All(vol.Coerce(int), vol.Range(min=1, max=BOUNTY_CLAIM_HOURS_MAX)),
vol.Optional("require_photo"): cv.boolean,
vol.Optional("notify_children"): cv.boolean,
}
hass.services.async_register(
DOMAIN,
SERVICE_POST_BOUNTY,
_parent(handle_post_bounty),
schema=vol.Schema(
{
vol.Required("title"): vol.All(cv.string, vol.Length(min=1, max=BOUNTY_TITLE_MAX_LENGTH)),
vol.Required("points"): vol.All(vol.Coerce(int), vol.Range(min=1, max=BOUNTY_POINTS_MAX)),
**_bounty_editable,
}
),
)
hass.services.async_register(
DOMAIN,
SERVICE_UPDATE_BOUNTY,
_parent(handle_update_bounty),
schema=vol.Schema(
{
vol.Required("bounty_id"): cv.string,
vol.Optional("title"): vol.All(cv.string, vol.Length(min=1, max=BOUNTY_TITLE_MAX_LENGTH)),
vol.Optional("points"): vol.All(vol.Coerce(int), vol.Range(min=1, max=BOUNTY_POINTS_MAX)),
**_bounty_editable,
}
),
)
hass.services.async_register(
DOMAIN,
SERVICE_REMOVE_BOUNTY,
_parent(handle_remove_bounty),
schema=vol.Schema({vol.Required("bounty_id"): cv.string}),
)
_bounty_child_schema = vol.Schema({vol.Required("bounty_id"): cv.string, vol.Required(ATTR_CHILD_ID): cv.string})
hass.services.async_register(
DOMAIN, SERVICE_CLAIM_BOUNTY, _audited(handle_claim_bounty), schema=_bounty_child_schema
)
hass.services.async_register(
DOMAIN, SERVICE_GIVE_BACK_BOUNTY, _audited(handle_give_back_bounty), schema=_bounty_child_schema
)
hass.services.async_register(
DOMAIN,
SERVICE_COMPLETE_BOUNTY,
_audited(handle_complete_bounty),
schema=vol.Schema(
{
vol.Required("bounty_id"): cv.string,
vol.Required(ATTR_CHILD_ID): cv.string,
vol.Optional("photo_url"): cv.string,
}
),
)
hass.services.async_register(
DOMAIN,
SERVICE_START_TIMED_TASK,
@@ -1195,6 +1447,8 @@ async def _async_register_services(hass: HomeAssistant) -> None:
{
vol.Required("completion_id"): cv.string,
vol.Optional("points"): vol.All(vol.Coerce(int), vol.Range(min=0)),
# 1-3 star quality rating (#927); ignored while the feature is off.
vol.Optional("rating"): vol.All(vol.Coerce(int), vol.Range(min=1, max=3)),
}
),
)
@@ -1206,6 +1460,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
schema=vol.Schema(
{
vol.Optional("completion_ids"): [cv.string],
vol.Optional("rating"): vol.All(vol.Coerce(int), vol.Range(min=1, max=3)),
}
),
)
@@ -1217,10 +1472,19 @@ async def _async_register_services(hass: HomeAssistant) -> None:
schema=vol.Schema(
{
vol.Required("completion_id"): cv.string,
vol.Optional("reason", default=""): vol.All(cv.string, vol.Length(max=REJECT_REASON_MAX)),
}
),
)
# Child-facing: gated on the stored child's linked user, not on parent.
hass.services.async_register(
DOMAIN,
SERVICE_UNDO_CHORE,
_audited(handle_undo_chore),
schema=vol.Schema({vol.Required("completion_id"): cv.string}),
)
_miss_schema = vol.Schema({vol.Required("miss_id"): cv.string})
hass.services.async_register(
DOMAIN,
@@ -1287,6 +1551,18 @@ async def _async_register_services(hass: HomeAssistant) -> None:
),
)
hass.services.async_register(
DOMAIN,
SERVICE_ADJUST_STREAK_FREEZES,
_parent(handle_adjust_streak_freezes),
schema=vol.Schema(
{
vol.Required(ATTR_CHILD_ID): cv.string,
vol.Required("amount"): vol.All(vol.Coerce(int), vol.Range(min=-10, max=10), vol.NotIn([0])),
}
),
)
hass.services.async_register(
DOMAIN,
SERVICE_RECORD_ALLOWANCE_PAYOUT,
@@ -1362,7 +1638,12 @@ async def _async_register_services(hass: HomeAssistant) -> None:
DOMAIN,
SERVICE_REJECT_REWARD,
_parent(handle_reject_reward),
schema=vol.Schema({vol.Required("claim_id"): cv.string}),
schema=vol.Schema(
{
vol.Required("claim_id"): cv.string,
vol.Optional("reason", default=""): vol.All(cv.string, vol.Length(max=REJECT_REASON_MAX)),
}
),
)
hass.services.async_register(
@@ -1554,6 +1835,12 @@ async def _async_register_services(hass: HomeAssistant) -> None:
vol.Optional(ATTR_CHORE_REQUIRES_APPROVAL, default=True): cv.boolean,
vol.Optional(ATTR_CHORE_EXPIRES_IN_MINUTES, default=0): vol.All(cv.positive_int, vol.Range(max=10080)),
vol.Optional(ATTR_CHORE_SPEED_BONUS_POINTS, default=0): cv.positive_int,
# Teamwork (#928). The service creates "everyone" chores, so the
# only cross-field rule left to check is the size range, which
# async_add_chore enforces.
vol.Optional("team_size", default=0): vol.All(vol.Coerce(int), vol.Range(min=0, max=TEAM_SIZE_MAX)),
vol.Optional("team_points_mode", default="each"): vol.In(TEAM_POINTS_MODES),
vol.Optional("team_bonus", default=0): cv.positive_int,
}
),
)
@@ -1684,16 +1971,38 @@ async def _async_register_services(hass: HomeAssistant) -> None:
schema=vol.Schema({}),
)
# Wishlist (#932): registered from its own module, through these same gates.
from .wishlist_services import async_register_wishlist_services
async_register_wishlist_services(
hass,
child_action=_audited,
parent_action=_parent,
get_coordinator=lambda: _get_coordinator(hass),
require_linked_child=lambda *args: _async_require_linked_child(*args),
)
# Surprise inspections (#981): parent actions, same gate and audit trail.
from .inspection_services import async_register_inspection_services
async_register_inspection_services(
hass,
parent_action=_parent,
get_coordinator=lambda: _get_coordinator(hass),
)
def _async_unregister_services(hass: HomeAssistant) -> None:
"""Unregister TaskMate services."""
services = [
SERVICE_COMPLETE_CHORE,
SERVICE_COMPLETE_NEXT_CHORE,
SERVICE_COMPLETE_BONUS_SUBTASK,
SERVICE_APPROVE_CHORE,
SERVICE_APPROVE_ALL_CHORES,
SERVICE_REJECT_CHORE,
SERVICE_UNDO_TRANSACTION,
SERVICE_UNDO_CHORE,
SERVICE_UNDO_CHORE_APPROVAL,
SERVICE_TEST_NOTIFICATION,
SERVICE_GIFT_POINTS,
@@ -1722,6 +2031,13 @@ def _async_unregister_services(hass: HomeAssistant) -> None:
SERVICE_ADD_TASK_GROUP,
SERVICE_UPDATE_TASK_GROUP,
SERVICE_REMOVE_TASK_GROUP,
SERVICE_LEAVE_TEAM_CHORE,
SERVICE_POST_BOUNTY,
SERVICE_UPDATE_BOUNTY,
SERVICE_REMOVE_BOUNTY,
SERVICE_CLAIM_BOUNTY,
SERVICE_GIVE_BACK_BOUNTY,
SERVICE_COMPLETE_BOUNTY,
SERVICE_START_TIMED_TASK,
SERVICE_PAUSE_TIMED_TASK,
SERVICE_STOP_TIMED_TASK,
@@ -1732,5 +2048,10 @@ def _async_unregister_services(hass: HomeAssistant) -> None:
"revoke_badge",
"rebuild_badges",
]
from .inspection_services import INSPECTION_SERVICES
from .wishlist_services import WISHLIST_SERVICES
services.extend(WISHLIST_SERVICES)
services.extend(INSPECTION_SERVICES)
for service in services:
hass.services.async_remove(DOMAIN, service)
+17 -20
View File
@@ -14,7 +14,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import authz
from .const import DOMAIN
from .coordinator import TaskMateCoordinator
from .coordinator import TaskMateCoordinator, button_keys, chore_has_button
from .entity import taskmate_device_info
from .models import Child, Chore, Reward
@@ -38,32 +38,25 @@ async def async_setup_entry(
for child in children:
# Chore completion buttons
for chore in chores:
if getattr(chore, "assignment_mode", "everyone") == "unassigned":
continue
if not chore.assigned_to or child.id in chore.assigned_to:
entities.append(CompleteChoreButton(coordinator, entry, child, chore))
entities.extend(
CompleteChoreButton(coordinator, entry, child, chore)
for chore in chores
if chore_has_button(chore, child.id)
)
# Reward claim buttons
entities.extend(ClaimRewardButton(coordinator, entry, child, reward) for reward in rewards)
# Track which entity combos already exist
tracked_combos: set[str] = set()
for child in children:
for chore in chores:
if getattr(chore, "assignment_mode", "everyone") == "unassigned":
continue
if not chore.assigned_to or child.id in chore.assigned_to:
tracked_combos.add(f"{child.id}_{chore.id}_complete")
for reward in rewards:
tracked_combos.add(f"{child.id}_{reward.id}_claim")
tracked_combos: set[str] = button_keys(children, chores, rewards)
async_add_entities(entities)
# Set up listener to add buttons for new children/chores/rewards
# Set up listener to add buttons for new children/chores/rewards, and drop
# those whose chore/reward was deleted or child unassigned (#960).
@callback
def async_update_entities() -> None:
"""Add button entities for newly created children, chores, or rewards."""
"""Keep button entities in step with children, chores and rewards."""
new_entities: list[ButtonEntity] = []
current_children = coordinator.data.get("children", [])
current_chores = coordinator.data.get("chores", [])
@@ -71,9 +64,7 @@ async def async_setup_entry(
for child in current_children:
for chore in current_chores:
if getattr(chore, "assignment_mode", "everyone") == "unassigned":
continue
if not chore.assigned_to or child.id in chore.assigned_to:
if chore_has_button(chore, child.id):
key = f"{child.id}_{chore.id}_complete"
if key not in tracked_combos:
new_entities.append(CompleteChoreButton(coordinator, entry, child, chore))
@@ -84,6 +75,12 @@ async def async_setup_entry(
new_entities.append(ClaimRewardButton(coordinator, entry, child, reward))
tracked_combos.add(key)
# Forget stale keys too, so re-assigning a child brings the button back.
stale = tracked_combos - button_keys(current_children, current_chores, current_rewards)
if stale:
tracked_combos.difference_update(stale)
coordinator.remove_button_entities(stale)
if new_entities:
async_add_entities(new_entities)
+226 -10
View File
@@ -12,30 +12,51 @@ to keep in sync:
coalesced into multi-day all-day events. On an away day the child's chores
are hidden, mirroring the rest of the integration.
Read-only for now: completing a chore from the calendar is intentionally not
supported.
Two-way (#977): a parent or admin can edit the calendar from Home Assistant.
Creating an event adds a one-off chore for the child; moving an occurrence
moves that one day (a one-off chore just changes date); deleting one removes
that day only. Each chore occurrence carries a uid of
``taskmate-chore:<chore id>:<scheduled date>`` so an edit can find it again —
the scheduled date stays the identity even after the occurrence is moved.
Moving or removing a whole repeating series is refused: edit the chore.
Completing a chore from the calendar is intentionally not supported.
"""
from __future__ import annotations
import logging
from datetime import date, datetime, timedelta
from datetime import date, datetime, time, timedelta
from typing import Any
from homeassistant.components.calendar import CalendarEntity, CalendarEvent
from homeassistant.components.calendar import CalendarEntity, CalendarEntityFeature, CalendarEvent
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError, Unauthorized
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util
from . import authz
from .const import DOMAIN
from .coordinator import TaskMateCoordinator
from .entity import taskmate_device_info
from .models import Child, Chore
try: # The websocket connection a calendar-panel edit arrived on (see _acting_user_ids).
from homeassistant.components.websocket_api.connection import current_connection
except ImportError: # pragma: no cover - present on every supported HA version
current_connection = None
_LOGGER = logging.getLogger(__name__)
# Prefix of every chore occurrence's event uid: taskmate-chore:<chore id>:<date>.
_UID_PREFIX = "taskmate-chore"
# The description marker TaskMate stamps on events it publishes to other
# calendars. A create_event carrying it is TaskMate talking to itself (a chore
# published to a TaskMate calendar) and must not spawn a new chore.
_PUBLISHED_MARKER = "taskmate:chore:"
# How far ahead the `event` (next-up) property scans for the soonest event.
_NEXT_EVENT_HORIZON_DAYS = 60
@@ -89,10 +110,14 @@ def _chore_applies_to_child(coordinator: TaskMateCoordinator, chore: Chore, chil
return not chore.assigned_to
def _chore_description(chore: Chore) -> str:
"""Compact one-line description for a chore event."""
def _chore_description(chore: Chore, points: int | None = None) -> str:
"""Compact one-line description for a chore event.
``points`` overrides the chore's own: a won auction occurrence (#982) pays
the winning bid, and shows it ahead of the day (#998).
"""
parts = ["TaskMate chore"]
pts = getattr(chore, "points", 0) or 0
pts = points if points is not None else (getattr(chore, "points", 0) or 0)
parts.append(f"{pts} pts")
cat = getattr(chore, "time_category", "anytime") or "anytime"
if cat != "anytime":
@@ -100,6 +125,42 @@ def _chore_description(chore: Chore) -> str:
return " · ".join(parts)
def _occurrence_uid(chore_id: str, origin: date) -> str:
"""The stable uid for the occurrence of ``chore_id`` scheduled on ``origin``."""
return f"{_UID_PREFIX}:{chore_id}:{origin.isoformat()}"
def _parse_day(value: str) -> date | None:
"""Parse an occurrence date: ISO (2026-06-22) or RFC 5545 (20260622[T...])."""
digits = str(value or "").replace("-", "")[:8]
try:
return date(int(digits[:4]), int(digits[4:6]), int(digits[6:8]))
except ValueError:
return None
def _parse_occurrence_uid(uid: str, recurrence_id: str | None) -> tuple[str, date] | None:
"""Split an event uid into (chore id, scheduled date), or None if not ours.
Accepts the full per-occurrence uid, or the bare ``taskmate-chore:<id>``
series uid plus a ``recurrence_id`` naming the occurrence.
"""
parts = str(uid or "").split(":")
if len(parts) < 2 or parts[0] != _UID_PREFIX or not parts[1]:
return None
raw = parts[2] if len(parts) >= 3 else recurrence_id
day = _parse_day(raw) if raw else None
return (parts[1], day) if day else None
def _local_date_time(value: date | datetime) -> tuple[date, time | None]:
"""Split an event start into its local date and time (None = all-day)."""
if isinstance(value, datetime):
local = dt_util.as_local(value) if value.tzinfo else value
return local.date(), local.time().replace(second=0, microsecond=0)
return value, None
def _as_local_dt(value: date | datetime) -> datetime:
"""Normalise a CalendarEvent start/end to an aware local datetime for sorting."""
if isinstance(value, datetime):
@@ -110,9 +171,14 @@ def _as_local_dt(value: date | datetime) -> datetime:
class TaskMateCalendar(CoordinatorEntity, CalendarEntity):
"""A read-only calendar of one child's chores and away periods."""
"""A calendar of one child's chores and away periods, editable by parents."""
_attr_icon = "mdi:calendar-account"
_attr_supported_features = (
CalendarEntityFeature.CREATE_EVENT | CalendarEntityFeature.UPDATE_EVENT | CalendarEntityFeature.DELETE_EVENT
)
# Context handed over by a calendar.* service call (see async_set_context).
_edit_context: Any = None
def __init__(
self,
@@ -180,11 +246,148 @@ class TaskMateCalendar(CoordinatorEntity, CalendarEntity):
self, hass: HomeAssistant, start_date: datetime, end_date: datetime
) -> list[CalendarEvent]:
"""Return all events overlapping the requested range."""
# A calendar.get_events service call hands us a context too; drop it
# so it can never vouch for a later edit (see async_set_context).
self._edit_context = None
child = self._child
if not child:
return []
return self._build_events(child, start_date.date(), end_date.date())
# ── Two-way editing (#977) ─────────────────────────────────────────────
@callback
def async_set_context(self, context: Any) -> None:
"""Remember the context of a calendar.* service call for the edit it precedes.
Home Assistant sets the context immediately before invoking the entity
method, with no await in between, and the edit consumes it at once.
"""
parent = getattr(super(), "async_set_context", None)
if parent is not None:
parent(context)
self._edit_context = context
def _acting_user_ids(self) -> set[str]:
"""Every HA user behind the current edit.
The calendar panel edits over the calendar/event/* websocket commands,
which call the entity directly without setting a context — so the
user there comes from the websocket connection itself. A calendar.*
service call sets a context. Both are checked when both are present;
neither (an automation or script) is a trusted caller, as elsewhere.
"""
ids: set[str] = set()
conn = current_connection.get() if current_connection is not None else None
user = getattr(conn, "user", None) if conn is not None else None
if user is not None and getattr(user, "id", None):
ids.add(user.id)
ctx, self._edit_context = self._edit_context, None
if ctx is not None and getattr(ctx, "user_id", None):
ids.add(ctx.user_id)
return ids
async def _async_require_parent(self) -> str:
"""Refuse the edit unless every acting user is a parent or an admin.
Returns the acting user id for the audit log ("" when trusted).
"""
ids = self._acting_user_ids()
for user_id in ids:
if not await authz.async_user_is_parent(self.hass, self.coordinator, user_id):
raise Unauthorized(user_id=user_id)
return sorted(ids)[0] if ids else ""
async def _async_audit(self, user_id: str, action: str, target: str) -> None:
"""Record the edit in TaskMate's audit log. Never blocks the edit."""
user_name = ""
if user_id:
try:
user = await self.hass.auth.async_get_user(user_id)
user_name = user.name if user else ""
except Exception: # noqa: BLE001 - audit must never break the action
user_name = ""
child = self._child
who = child.name if child else self._child_id
try:
await self.coordinator.async_record_audit(user_id, user_name, action, f"{who}: {target}")
except Exception: # noqa: BLE001 - audit must never break the action
_LOGGER.debug("Failed to record calendar audit for %s", action, exc_info=True)
def _resolve_occurrence(self, uid: str, recurrence_id: str | None) -> tuple[Chore, date]:
"""The chore and scheduled date behind ``uid`` — on *this* child's calendar."""
parsed = _parse_occurrence_uid(uid, recurrence_id)
chore = self.coordinator.storage.get_chore(parsed[0]) if parsed else None
if chore is None:
raise self.coordinator._calendar_error("calendar_unknown_event")
origin = parsed[1]
current = self.coordinator._occurrence_day(chore, origin)
with self.coordinator.availability_build_scope():
applies = _chore_applies_to_child(self.coordinator, chore, self._child_id, current)
if not applies:
raise self.coordinator._calendar_error("calendar_unknown_event")
return chore, origin
@staticmethod
def _refuse_series(recurrence_range: str | None, rrule: str | None = None) -> None:
if (recurrence_range or "").upper() == "THISANDFUTURE" or rrule:
raise ServiceValidationError(translation_domain=DOMAIN, translation_key="calendar_series_not_supported")
async def async_create_event(self, **kwargs: Any) -> None:
"""Add a one-off chore for this child on the event's date."""
user_id = await self._async_require_parent()
description = str(kwargs.get("description") or "")
if _PUBLISHED_MARKER in description:
raise ServiceValidationError(translation_domain=DOMAIN, translation_key="calendar_own_event")
if kwargs.get("rrule"):
raise ServiceValidationError(translation_domain=DOMAIN, translation_key="calendar_repeating_create")
day, start_time = _local_date_time(kwargs["dtstart"])
chore = await self.coordinator.async_calendar_add_chore(
self._child_id,
str(kwargs.get("summary") or ""),
day,
description=description,
start_time=start_time,
)
await self._async_audit(user_id, "calendar.create_event", f"{chore.name} ({day.isoformat()})")
async def async_update_event(
self,
uid: str,
event: dict[str, Any],
recurrence_id: str | None = None,
recurrence_range: str | None = None,
) -> None:
"""Move one occurrence (or a one-off chore) to the event's new date."""
user_id = await self._async_require_parent()
self._refuse_series(recurrence_range, event.get("rrule"))
chore, origin = self._resolve_occurrence(uid, recurrence_id)
new_day, start_time = _local_date_time(event["dtstart"])
await self.coordinator.async_move_chore_occurrence(
chore.id,
origin,
new_day,
start_time=start_time,
all_day=start_time is None,
summary=event.get("summary"),
)
await self._async_audit(
user_id, "calendar.move_event", f"{chore.name} ({origin.isoformat()} → {new_day.isoformat()})"
)
async def async_delete_event(
self,
uid: str,
recurrence_id: str | None = None,
recurrence_range: str | None = None,
) -> None:
"""Remove one occurrence — that day only."""
user_id = await self._async_require_parent()
self._refuse_series(recurrence_range)
chore, origin = self._resolve_occurrence(uid, recurrence_id)
await self.coordinator.async_remove_chore_occurrence(chore.id, origin)
await self._async_audit(user_id, "calendar.delete_event", f"{chore.name} ({origin.isoformat()})")
def _build_events(self, child: Child, start_day: date, end_day: date) -> list[CalendarEvent]:
with self.coordinator.availability_build_scope():
return self._build_events_locked(child, start_day, end_day)
@@ -223,8 +426,11 @@ class TaskMateCalendar(CoordinatorEntity, CalendarEntity):
for chore in chores:
if not _chore_applies_to_child(coord, chore, child.id, day):
continue
window = coord._time_category_window(getattr(chore, "time_category", "anytime"), day)
desc = _chore_description(chore)
window = coord._chore_event_window(chore, day)
desc = _chore_description(chore, coord.auction_price_for(chore, child.id, day))
# The uid names the scheduled date, so an edit finds the
# occurrence again even after it has been moved (#977).
ident = _occurrence_ident(coord, chore, day)
if window is None:
events.append(
CalendarEvent(
@@ -232,6 +438,7 @@ class TaskMateCalendar(CoordinatorEntity, CalendarEntity):
end=day + timedelta(days=1),
summary=chore.name,
description=desc,
**ident,
)
)
else:
@@ -242,6 +449,7 @@ class TaskMateCalendar(CoordinatorEntity, CalendarEntity):
end=end_dt.replace(tzinfo=tz),
summary=chore.name,
description=desc,
**ident,
)
)
day += timedelta(days=1)
@@ -249,6 +457,14 @@ class TaskMateCalendar(CoordinatorEntity, CalendarEntity):
return events
def _occurrence_ident(coord: TaskMateCoordinator, chore: Chore, day: date) -> dict[str, str]:
"""uid (+ recurrence_id for a repeating chore) of the occurrence shown on ``day``."""
if getattr(chore, "schedule_mode", "specific_days") == "one_shot":
return {"uid": _occurrence_uid(chore.id, day)}
origin = coord.occurrence_origin(chore, day)
return {"uid": _occurrence_uid(chore.id, origin), "recurrence_id": origin.isoformat()}
def _away_event(start_day: date, end_day_exclusive: date, label: str | None) -> CalendarEvent:
"""Build a coalesced all-day 'away' event over [start, end)."""
summary = f"Away — {label}" if label else "Away"
+118
View File
@@ -26,6 +26,37 @@ MAX_CALENDAR_PROJECTION_DAYS: Final = 90
# hides for everyone else (shared quota of 1). A parent rejection reopens it for the pool.
ASSIGNMENT_MODES: Final = ["everyone", "alternating", "random", "balanced", "first_come", "unassigned"]
# Teamwork chores (#928): how a finished team's points are shared out.
# "each" = every participant earns the chore's full points
# "split" = the points are divided evenly across the team, rounded down
TEAM_POINTS_MODES: Final = ["each", "split"]
# Upper bound on team_size — more than a family has children is meaningless,
# and the joins list rides in a sensor attribute.
TEAM_SIZE_MAX: Final = 10
# Bounty board (#931): one-off jobs any eligible child can claim.
# A claim locks the bounty to one child for claim_hours (never past its
# expiry); an unfinished claim lapses back onto the board.
BOUNTY_STATUSES: Final = ["open", "claimed", "pending", "completed", "expired"]
BOUNTY_CLAIM_HOURS_DEFAULT: Final = 2
BOUNTY_CLAIM_HOURS_MAX: Final = 48
BOUNTY_POINTS_MAX: Final = 100000
BOUNTY_TITLE_MAX_LENGTH: Final = 120
BOUNTY_DESCRIPTION_MAX_LENGTH: Final = 500
# The claimer is warned this long before their claim lapses (opt-in type).
BOUNTY_LAPSE_WARNING_MINUTES: Final = 15
# Approved bounties stay on the card as "Recently completed" this long.
BOUNTY_RECENT_HOURS: Final = 24
# Chore auctions (#982): children bid the fewest points they'd accept for one
# occurrence of a chore; the lowest bid wins it at that price.
AUCTION_STATUSES: Final = ["open", "closed", "cancelled"]
AUCTION_POINTS_MAX: Final = 100000
# Eligible children get the "closing soon" push this long before bidding ends.
AUCTION_REMINDER_MINUTES: Final = 60
# Closed auctions stay on the children's cards as results this long.
AUCTION_RESULTS_HOURS: Final = 24
# Default values
DEFAULT_POINTS_NAME: Final = "Stars"
DEFAULT_POINTS_ICON: Final = "mdi:star"
@@ -81,8 +112,22 @@ MAX_TIME_PERIODS: Final = 24
# Open-ended chores (#832): the child describes the work and suggests what it
# was worth. Both are child-entered free input, so they're bounded before storage.
CHORE_NOTE_MAX_LENGTH: Final = 200
# Admin panel Today page (#966): days of per-child done/total snapshots kept.
DAILY_PROGRESS_KEEP_DAYS: Final = 30
CHORE_SUGGESTED_POINTS_MAX: Final = 999
# NFC / QR tag completion (#923): a chore lists the HA tag ids that complete it.
# Tag ids are typed or picked by a parent, so both the count and the length are
# bounded before storage.
MAX_CHORE_TAGS: Final = 10
TAG_ID_MAX_LENGTH: Final = 100
# A phone reading an NFC sticker often fires tag_scanned twice for one tap, so a
# second scan of the same chore by the same child inside this window is ignored
# even when the daily limit would allow another completion.
TAG_SCAN_DEBOUNCE_SECONDS: Final = 60
EVENT_TAG_COMPLETION: Final = "taskmate_tag_completion"
# Platforms
PLATFORMS: Final = ["sensor", "button", "binary_sensor"]
@@ -91,6 +136,7 @@ SERVICE_COMPLETE_CHORE: Final = "complete_chore"
SERVICE_APPROVE_CHORE: Final = "approve_chore"
SERVICE_APPROVE_ALL_CHORES: Final = "approve_all_chores"
SERVICE_REJECT_CHORE: Final = "reject_chore"
SERVICE_UNDO_CHORE: Final = "undo_chore"
SERVICE_UNDO_CHORE_APPROVAL: Final = "undo_chore_approval"
SERVICE_APPLY_MANDATORY_PENALTY: Final = "apply_mandatory_penalty"
SERVICE_POSTPONE_MANDATORY_CHORE: Final = "postpone_mandatory_chore"
@@ -103,6 +149,7 @@ SERVICE_REMOVE_POINTS: Final = "remove_points"
SERVICE_UNDO_TRANSACTION: Final = "undo_transaction"
SERVICE_TEST_NOTIFICATION: Final = "test_notification"
SERVICE_GIFT_POINTS: Final = "gift_points"
SERVICE_ADJUST_STREAK_FREEZES: Final = "adjust_streak_freezes"
SERVICE_RECORD_ALLOWANCE_PAYOUT: Final = "record_allowance_payout"
SERVICE_REQUEST_SWAP: Final = "request_swap"
SERVICE_SPIN_ROULETTE: Final = "spin_roulette"
@@ -129,6 +176,14 @@ SERVICE_COMPLETE_BONUS_SUBTASK: Final = "complete_bonus_subtask"
SERVICE_START_TIMED_TASK: Final = "start_timed_task"
SERVICE_PAUSE_TIMED_TASK: Final = "pause_timed_task"
SERVICE_STOP_TIMED_TASK: Final = "stop_timed_task"
SERVICE_LEAVE_TEAM_CHORE: Final = "leave_team_chore"
SERVICE_COMPLETE_NEXT_CHORE: Final = "complete_next_chore"
SERVICE_POST_BOUNTY: Final = "post_bounty"
SERVICE_UPDATE_BOUNTY: Final = "update_bounty"
SERVICE_REMOVE_BOUNTY: Final = "remove_bounty"
SERVICE_CLAIM_BOUNTY: Final = "claim_bounty"
SERVICE_GIVE_BACK_BOUNTY: Final = "give_back_bounty"
SERVICE_COMPLETE_BOUNTY: Final = "complete_bounty"
# Events
EVENT_PREVIEW_SOUND: Final = "taskmate_preview_sound"
@@ -229,6 +284,21 @@ DIFFICULTY_TIERS: Final = ("easy", "medium", "hard")
DEFAULT_DIFFICULTY: Final = "medium"
DEFAULT_DIFFICULTY_MULTIPLIERS: Final = {"easy": 0.5, "medium": 1.0, "hard": 2.0}
# Setup wizard age groups (#980), youngest first. A child's age group only
# decides which chores the wizard suggests; "" means none picked. It is stored
# separately from the birthday so an age without a date never becomes a fake
# birthday (which would set off birthday mode on the wrong day).
AGE_GROUPS: Final = ("3_5", "6_8", "9_12", "13_plus")
# --- Chore quality rating (#927) ---
# When the "quality_rating_enabled" setting is on, a parent may rate an approval
# 1-3 stars and the chore's base points are scaled by that star's multiplier
# (configurable via the "quality_rating_multiplier_<n>" settings keys). An
# unrated approval pays 100%, so turning the feature on changes nothing until a
# parent actually picks a star.
QUALITY_RATINGS: Final = (1, 2, 3)
DEFAULT_QUALITY_RATING_MULTIPLIERS: Final = {1: 0.75, 2: 1.0, 3: 1.25}
# --- Notification type IDs (v3.9.0) ---
NOTIF_TYPE_BEDTIME_REMINDER: Final = "bedtime_reminder"
NOTIF_TYPE_STREAK_AT_RISK: Final = "streak_at_risk"
@@ -245,6 +315,30 @@ NOTIF_TYPE_MANDATORY_PARENT_ALERT: Final = "mandatory_parent_alert"
NOTIF_TYPE_MONTHLY_REPORT: Final = "monthly_report"
NOTIF_TYPE_SEASON_CHAMPION: Final = "season_champion"
NOTIF_TYPE_FAMILY_GOAL_REACHED: Final = "family_goal_reached"
NOTIF_TYPE_BIRTHDAY: Final = "birthday"
NOTIF_TYPE_STREAK_FREEZE_USED: Final = "streak_freeze_used"
NOTIF_TYPE_PRESENCE_ARRIVAL: Final = "presence_arrival"
NOTIF_TYPE_BOUNTY_POSTED: Final = "bounty_posted"
NOTIF_TYPE_BOUNTY_CLAIM_LAPSING: Final = "bounty_claim_lapsing"
NOTIF_TYPE_RECAP_READY: Final = "recap_ready"
# Reject reasons (#976): tells a child a chore or reward claim was sent back,
# with the parent's reason when one was given.
NOTIF_TYPE_ITEM_REJECTED: Final = "item_rejected"
# Chore auctions (#982): an auction opened, bidding closes within the hour,
# and the result (or a cancellation).
NOTIF_TYPE_AUCTION_OPENED: Final = "auction_opened"
NOTIF_TYPE_AUCTION_CLOSING: Final = "auction_closing"
NOTIF_TYPE_AUCTION_RESULT: Final = "auction_result"
# Surprise inspections (#981): the child hears an inspection is coming (only
# when they're told) and that it passed; the parent is reminded 30 minutes
# before an undecided inspection closes.
NOTIF_TYPE_INSPECTION_STARTED: Final = "inspection_started"
NOTIF_TYPE_INSPECTION_PASSED: Final = "inspection_passed"
NOTIF_TYPE_INSPECTION_REMINDER: Final = "inspection_reminder"
# Presence-aware reminders (#926): the default for how long a child must have
# been away before arriving home earns a "you're home" nudge.
DEFAULT_PRESENCE_ARRIVAL_MIN_AWAY: Final = 30
# Default notification tap target. Must match PANEL_URL_PATH in panel.py —
# a bare /taskmate is the static-files prefix and returns 403, not the panel.
@@ -255,3 +349,27 @@ DEFAULT_NOTIFICATION_NAV_URL: Final = "/taskmate-admin"
# scattering through the rest of the phone's HA notifications. Applied by
# default — set it to "" in the panel to turn grouping off.
DEFAULT_NOTIFICATION_GROUP: Final = "taskmate"
# --- Wishlist with pledges (#932) ---
# Wishes a child has open at once: waiting for approval, saving, or waiting
# for the parent to hand the thing over.
WISH_MAX_OPEN_PER_CHILD: Final = 5
WISH_MAX_TARGET: Final = 100000
# Pledges on one wish; each is typed by a parent, so this only bounds abuse.
WISH_MAX_PLEDGES: Final = 50
# Finished wishes kept per child: declined ones stay on the card (with the
# reason) until dismissed, redeemed ones are the panel's history.
WISH_KEEP_DECLINED_PER_CHILD: Final = 3
WISH_KEEP_REDEEMED_PER_CHILD: Final = 20
NOTIF_TYPE_WISH_REQUESTED: Final = "wish_requested"
NOTIF_TYPE_WISH_PLEDGED: Final = "wish_pledged"
SERVICE_ADD_WISH: Final = "add_wish"
SERVICE_WITHDRAW_WISH: Final = "withdraw_wish"
SERVICE_MOVE_POINTS_TO_WISH: Final = "move_points_to_wish"
SERVICE_TAKE_POINTS_FROM_WISH: Final = "take_points_from_wish"
SERVICE_REQUEST_WISH_REDEEM: Final = "request_wish_redeem"
SERVICE_APPROVE_WISH: Final = "approve_wish"
SERVICE_DECLINE_WISH: Final = "decline_wish"
SERVICE_PLEDGE_TO_WISH: Final = "pledge_to_wish"
SERVICE_REMOVE_WISH_PLEDGE: Final = "remove_wish_pledge"
SERVICE_REMOVE_WISH: Final = "remove_wish"
@@ -390,6 +390,13 @@ class AssignmentsMixin:
if mode == "unassigned":
return []
# A chore auction (#982) settles one occurrence outright: the winner
# does it, ahead of every mode's own logic and of a sibling swap. Like a
# swap, `require_availability` is not re-applied — the child bid for it.
winner = self.auction_winner(chore, today)
if winner:
return [winner]
# An approved swap replaces the whole active set for that day, ahead of
# every mode's own logic. `require_availability` is deliberately not
# re-applied: a parent explicitly approved this child for today, which
@@ -616,7 +623,11 @@ class AssignmentsMixin:
active_child_id = getattr(chore, "assignment_current_child_id", "") or ""
completions_today = 0
completed_bonus_ids_today: set[str] = set()
# Sent back to redo by an inspection (#981): no longer fills the quota.
redo_ids = self.inspection_redo_completion_ids()
for comp in self._cached_completions_for_chore(chore.id):
if comp.id in redo_ids:
continue
comp_dt = comp.completed_at
try:
if hasattr(comp_dt, "astimezone"):
@@ -4,6 +4,9 @@ from __future__ import annotations
import logging
from homeassistant.util import dt as dt_util
from .coord_birthdays import is_birthday_on
from .models import Badge, BadgeCriterion, Child
_LOGGER = logging.getLogger(__name__)
@@ -43,6 +46,8 @@ BUILTIN_CATALOGUE: list[Badge] = [
),
_b("first_reward", "First Reward", "Claim your first reward", "mdi:gift", "bronze", 0, "first_reward", 1),
_b("100_points", "100 Points", "Earn 100 lifetime points", "mdi:star", "bronze", 0, "total_points", 100),
# Awarded on the child's birthday (#924) — evaluated by the birthday check.
_b("birthday", "Birthday", "Celebrate your birthday", "mdi:cake-variant", "bronze", 0, "birthday", 1),
_b(
"10_chores",
"10 Chores Completed",
@@ -158,6 +163,9 @@ def resolve_metric(metric: str, child: Child, storage) -> int:
return len(child.awarded_perfect_weeks or [])
if metric == "first_chore":
return 1 if (child.total_chores_completed or 0) >= 1 else 0
if metric == "birthday":
today = dt_util.as_local(dt_util.now()).date()
return 1 if is_birthday_on(getattr(child, "birthday", "") or "", today) else 0
if metric in ("total_rewards", "first_reward"):
approved_count = sum(1 for c in storage.get_reward_claims() if c.child_id == child.id and c.approved)
if metric == "first_reward":
@@ -172,6 +180,7 @@ TRIGGER_METRICS: dict[str, set[str]] = {
"reward_redeemed": {"total_rewards", "first_reward"},
"streak_updated": {"current_streak", "best_streak"},
"perfect_week": {"perfect_weeks"},
"birthday": {"birthday"},
}
+239 -3
View File
@@ -8,11 +8,13 @@ from calendar import monthrange
from datetime import date, datetime, time, timedelta
from typing import TYPE_CHECKING
from homeassistant.exceptions import ServiceValidationError
from homeassistant.util import dt as dt_util
from .const import (
DEFAULT_CALENDAR_PROJECTION_DAYS,
DEFAULT_TIME_PERIODS,
DOMAIN,
MAX_CALENDAR_PROJECTION_DAYS,
MIN_CALENDAR_PROJECTION_DAYS,
TIME_CATEGORY_ICONS,
@@ -107,6 +109,22 @@ class CalendarMixin:
start_t, end_t = window
return datetime.combine(today, start_t), datetime.combine(today, end_t)
# A chore with a due_time shows as a short event starting then (#993).
_DUE_TIME_EVENT_MINUTES = 30
def _chore_event_window(self, chore: Chore, day: date) -> tuple[datetime, datetime] | None:
"""Return the (start, end) a chore's event covers on ``day``, or None for all-day.
A valid due_time wins: the event starts at it and lasts
``_DUE_TIME_EVENT_MINUTES`` (possibly past midnight). Without one, the
event spans the chore's time-of-day period, or the whole day for anytime.
"""
due = self._parse_hhmm(getattr(chore, "due_time", "") or "")
if due is not None:
start = datetime.combine(day, due)
return start, start + timedelta(minutes=self._DUE_TIME_EVENT_MINUTES)
return self._time_category_window(getattr(chore, "time_category", "anytime"), day)
def _chore_event_marker(self, chore: Chore) -> str:
"""Marker stitched into the event description so we can find our own events."""
return f"taskmate:chore:{chore.id}"
@@ -121,12 +139,64 @@ class CalendarMixin:
raw = DEFAULT_CALENDAR_PROJECTION_DAYS
return max(MIN_CALENDAR_PROJECTION_DAYS, min(MAX_CALENDAR_PROJECTION_DAYS, raw))
@staticmethod
def occurrence_override(chore: Chore, day: date) -> bool | None:
"""How a calendar move/removal (#977) changes ``day`` for ``chore``.
True: an occurrence was moved onto ``day``. False: the occurrence that
belonged to ``day`` was moved away or removed. None: nothing was
changed on ``day`` and the regular schedule decides.
"""
moved = getattr(chore, "moved_occurrences", None) or {}
if not moved:
return None
iso = day.isoformat()
if iso in moved.values():
return True
if iso in moved:
return False
return None
@staticmethod
def occurrence_origin(chore: Chore, day: date) -> date:
"""The scheduled date the occurrence held on ``day`` belongs to.
A moved occurrence keeps the identity of the day it was moved from, so
a recurrence window measures from the original date and the calendar
event keeps the same uid wherever it is moved to.
"""
moved = getattr(chore, "moved_occurrences", None) or {}
iso = day.isoformat()
for src, dst in moved.items():
if dst == iso:
try:
return date.fromisoformat(src)
except ValueError:
break
return day
def _is_chore_scheduled_for_date(self, chore: Chore, day: date) -> bool:
"""Return True if the chore's schedule places it on `day`.
"""Return True if the chore is due on `day`, calendar moves included.
Every caller that asks "is this chore on this day" — the calendar
entity, the ICS feed, calendar publishing, streaks, perfect weeks and
mandatory misses — goes through here, so a moved or removed occurrence
(#977) is honoured everywhere at once.
"""
if not getattr(chore, "enabled", True):
return False
override = self.occurrence_override(chore, day)
if override is not None:
return override
return self._is_chore_base_scheduled_for_date(chore, day)
def _is_chore_base_scheduled_for_date(self, chore: Chore, day: date) -> bool:
"""Return True if the chore's regular schedule places it on `day`.
Mirrors the client-side `_isChoreScheduledOn` in taskmate-calendar-card.js
so the HA calendar projection matches what the card shows. Does not
consult completion state — this is purely the recurrence/schedule math.
consult completion state or calendar moves — this is purely the
recurrence/schedule math.
"""
if not getattr(chore, "enabled", True):
return False
@@ -209,7 +279,7 @@ class CalendarMixin:
def _build_event_payload(self, chore: Chore, day: date, summary: str) -> dict:
"""Build the calendar.create_event payload for one (chore, day)."""
description = self._chore_event_marker(chore)
window = self._time_category_window(getattr(chore, "time_category", "anytime"), day)
window = self._chore_event_window(chore, day)
if window is None:
return {
"summary": summary,
@@ -386,3 +456,169 @@ class CalendarMixin:
await asyncio.gather(*(_purge(e) for e in ents), return_exceptions=True)
chore.publish_calendar_published_dates = []
# ── Two-way calendar edits (#977) ─────────────────────────────────────
# How long a spent move/removal is kept. Long enough that a recurrence
# window after a moved occurrence still measures from its original date,
# short enough that the map can't grow without bound.
_MOVED_OCCURRENCE_RETENTION_DAYS = 62
@staticmethod
def _calendar_error(key: str, **placeholders: str) -> ServiceValidationError:
"""A translated, user-facing refusal for a calendar edit."""
return ServiceValidationError(
translation_domain=DOMAIN,
translation_key=key,
translation_placeholders=placeholders or None,
)
def _period_for_time(self, value: time) -> str:
"""The time-of-day period containing ``value``, or "anytime"."""
for period in self.get_time_periods():
start = self._parse_hhmm(period["start"])
end = self._parse_hhmm(period["end"])
if start is not None and end is not None and start <= value < end:
return period["id"]
return "anytime"
def _occurrence_start_time(self, chore: Chore, day: date) -> time | None:
"""The start time the calendar shows for ``chore`` on ``day`` (None = all-day)."""
window = self._chore_event_window(chore, day)
return window[0].time() if window else None
def _prune_moved_occurrences(self, moved: dict[str, str], today: date) -> dict[str, str]:
"""Drop moves/removals that lie entirely in the distant past."""
cutoff = (today - timedelta(days=self._MOVED_OCCURRENCE_RETENTION_DAYS)).isoformat()
return {src: dst for src, dst in moved.items() if src >= cutoff or (dst and dst >= cutoff)}
async def async_calendar_add_chore(
self,
child_id: str,
name: str,
day: date,
*,
description: str = "",
start_time: time | None = None,
) -> Chore:
"""Create a one-off chore for ``child_id`` on ``day`` from a calendar event.
Goes through ``async_add_chore`` like the ``add_chore`` service, with
its default points. A timed event sets the chore's due time and files
it under the time-of-day period that contains it.
"""
name = (name or "").strip()
if not name:
raise self._calendar_error("calendar_empty_title")
today = dt_util.as_local(dt_util.now()).date()
if day < today:
raise self._calendar_error("calendar_past_date")
if not self.storage.get_child(child_id):
raise ValueError(f"Unknown child: {child_id}")
return await self.async_add_chore(
name=name,
description=(description or "").strip(),
assigned_to=[child_id],
schedule_mode="one_shot",
created_date=day.isoformat(),
time_category=self._period_for_time(start_time) if start_time else "anytime",
due_time=start_time.strftime("%H:%M") if start_time else "",
)
async def async_move_chore_occurrence(
self,
chore_id: str,
origin: date,
new_day: date,
*,
start_time: time | None = None,
all_day: bool | None = None,
summary: str | None = None,
) -> Chore:
"""Move one occurrence of a chore to ``new_day``.
``origin`` is the occurrence's scheduled date (its identity, which a
move never changes). A one-off chore simply takes the new date — and a
new title or time if the event changed them. A repeating chore moves
only this occurrence; its title and time of day belong to the whole
series, so changing those here is refused.
"""
chore = self.storage.get_chore(chore_id)
if not chore:
raise self._calendar_error("calendar_unknown_event")
today = dt_util.as_local(dt_util.now()).date()
current = self._occurrence_day(chore, origin)
if new_day < today or current < today:
raise self._calendar_error("calendar_past_date")
old_start = self._occurrence_start_time(chore, current)
time_changed = all_day is not None and (
(all_day and old_start is not None) or (not all_day and start_time != old_start)
)
title = (summary or "").strip()
if getattr(chore, "schedule_mode", "specific_days") == "one_shot":
if title:
chore.name = title
chore.created_date = new_day.isoformat()
if time_changed:
chore.time_category = self._period_for_time(start_time) if start_time else "anytime"
chore.due_time = start_time.strftime("%H:%M") if start_time else ""
await self.async_update_chore(chore)
return chore
if title and title != chore.name:
raise self._calendar_error("calendar_rename_series")
if time_changed:
raise self._calendar_error("calendar_time_follows_chore")
if new_day == current:
return chore
if self._is_chore_scheduled_for_date(chore, new_day):
raise self._calendar_error("calendar_already_scheduled", chore=chore.name, date=new_day.isoformat())
moved = dict(getattr(chore, "moved_occurrences", None) or {})
if new_day == origin:
moved.pop(origin.isoformat(), None)
else:
moved[origin.isoformat()] = new_day.isoformat()
chore.moved_occurrences = self._prune_moved_occurrences(moved, today)
await self.async_update_chore(chore)
return chore
async def async_remove_chore_occurrence(self, chore_id: str, origin: date) -> Chore:
"""Remove one occurrence of a chore — that day only.
A repeating chore records the removal and carries on as normal on every
other day. A one-off chore has only the one occurrence, so it is
switched off, exactly as if it had expired.
"""
chore = self.storage.get_chore(chore_id)
if not chore:
raise self._calendar_error("calendar_unknown_event")
today = dt_util.as_local(dt_util.now()).date()
if self._occurrence_day(chore, origin) < today:
raise self._calendar_error("calendar_past_date")
if getattr(chore, "schedule_mode", "specific_days") == "one_shot":
chore.enabled = False
else:
moved = dict(getattr(chore, "moved_occurrences", None) or {})
moved[origin.isoformat()] = ""
chore.moved_occurrences = self._prune_moved_occurrences(moved, today)
await self.async_update_chore(chore)
return chore
@staticmethod
def _occurrence_day(chore: Chore, origin: date) -> date:
"""Where the occurrence scheduled for ``origin`` currently sits."""
if getattr(chore, "schedule_mode", "specific_days") == "one_shot":
try:
return date.fromisoformat(getattr(chore, "created_date", "") or "")
except ValueError:
return origin
dst = (getattr(chore, "moved_occurrences", None) or {}).get(origin.isoformat())
if dst:
try:
return date.fromisoformat(dst)
except ValueError:
pass
return origin
+437 -74
View File
@@ -10,7 +10,10 @@ from typing import TYPE_CHECKING
from homeassistant.util import dt as dt_util
from . import images, photos
from .const import CHORE_NOTE_MAX_LENGTH, CHORE_SUGGESTED_POINTS_MAX
from .chore_undo import child_can_undo, undo_window_seconds
from .const import CHORE_NOTE_MAX_LENGTH, CHORE_SUGGESTED_POINTS_MAX, DAILY_PROGRESS_KEEP_DAYS, QUALITY_RATINGS
from .coord_rejections import clean_reject_reason
from .coord_teamwork import teamwork_config_error
from .models import Chore, ChoreCompletion, PointsTransaction
if TYPE_CHECKING:
@@ -76,8 +79,18 @@ class ChoresMixin:
manual_start_child_id: str = "",
deadline_at: str = "",
speed_bonus_points: int = 0,
team_size: int = 0,
team_points_mode: str = "each",
team_bonus: int = 0,
due_time: str = "",
) -> Chore:
"""Add a new chore."""
# Teamwork (#928) is refused up front, before anything is stored.
team_error = teamwork_config_error(
team_size, assignment_mode=assignment_mode, team_points_mode=team_points_mode
)
if team_error:
raise ValueError(team_error)
# One-shot chores: force daily_limit=1, set created_date to today
if schedule_mode == "one_shot":
daily_limit = 1
@@ -127,6 +140,10 @@ class ChoresMixin:
require_availability=require_availability,
deadline_at=deadline_at,
speed_bonus_points=max(0, int(speed_bonus_points or 0)),
team_size=int(team_size or 0),
team_points_mode=team_points_mode,
team_bonus=max(0, int(team_bonus or 0)),
due_time=due_time,
)
# Cache today's active child so the card can show it immediately
active = self._compute_active_children(chore, today)
@@ -157,6 +174,9 @@ class ChoresMixin:
raise ValueError(f"Chore {chore_id} not found")
if getattr(chore, "assignment_mode", "everyone") in ("everyone", "unassigned"):
raise ValueError("Only rotation chores can be swapped")
# A chore won at auction (#982) is the winner's by their own bid.
if self.auction_winner(chore):
raise ValueError("This chore was won at auction today, so it can't be swapped")
requester = self.get_child(requester_id)
if not requester:
raise ValueError(f"Child {requester_id} not found")
@@ -341,6 +361,7 @@ class ChoresMixin:
data["assignment_swap_child_id"] = ""
data["assignment_swap_date"] = ""
data["publish_calendar_published_dates"] = []
data["moved_occurrences"] = {}
data["disabled_for"] = []
data["enabled"] = True
@@ -374,12 +395,15 @@ class ChoresMixin:
if action not in ("delete", "enable", "disable", "reassign"):
raise ValueError(f"Unknown bulk action {action}")
count = 0
called_off = []
for cid in ids:
chore = self.storage.get_chore(cid)
if not chore:
continue
if action == "delete":
self.storage.remove_chore(cid)
# Its auctions go with it, live ones called off (#999).
called_off += self.remove_chore_from_auctions(cid)
elif action == "enable":
chore.enabled = True
chore.disabled_for = []
@@ -393,9 +417,13 @@ class ChoresMixin:
count += 1
await self.storage.async_save()
await self.async_refresh()
for auction, bidders in called_off:
await self._async_tell_bidders_called_off(auction, bidders)
return count
async def async_approve_chores_bulk(self, completion_ids: list[str] | None = None) -> int:
async def async_approve_chores_bulk(
self, completion_ids: list[str] | None = None, rating: int | None = None
) -> int:
"""Approve several pending chore completions at once. Returns count approved.
If completion_ids is given, only those (still-pending) completions are
@@ -411,6 +439,9 @@ class ChoresMixin:
the button reads as broken. The batch refreshes once at the end instead.
Safe because the per-approval side-effects (badges, quests, challenges,
the all-done check) all read `storage` directly, never `self.data`.
``rating`` (#927) applies the same 1-3 star quality rating to every
approval in the batch; see ``async_approve_chore``.
"""
pending = {c.id for c in self.storage.get_completions() if not c.approved}
if completion_ids:
@@ -418,9 +449,10 @@ class ChoresMixin:
targets = [cid for cid in dict.fromkeys(completion_ids) if cid in pending]
else:
targets = [c.id for c in self.storage.get_completions() if not c.approved]
rated = {"rating": rating} if rating is not None else {}
count = 0
for cid in targets:
await self.async_approve_chore(cid, refresh=False)
await self.async_approve_chore(cid, refresh=False, **rated)
count += 1
if count:
await self.async_refresh()
@@ -475,6 +507,16 @@ class ChoresMixin:
async def async_update_chore(self, chore: Chore) -> None:
"""Update a chore."""
# Checked before storage is touched so a refused edit changes nothing.
team_error = teamwork_config_error(
getattr(chore, "team_size", 0),
assignment_mode=getattr(chore, "assignment_mode", "everyone"),
open_ended=bool(getattr(chore, "open_ended", False)),
task_type=getattr(chore, "task_type", "standard") or "standard",
team_points_mode=getattr(chore, "team_points_mode", "each"),
)
if team_error:
raise ValueError(team_error)
today = dt_util.as_local(dt_util.now()).date()
# Capture pre-update state before storage is mutated below.
existing = self.storage.get_chore(chore.id)
@@ -557,6 +599,10 @@ class ChoresMixin:
# Drop pending swap requests (#785), else they sit in the parent's
# approval queue forever showing "?" for the chore that no longer exists.
self.storage.remove_swap_requests_for_chore(chore_id)
# Teamwork joins (#928) belong to the occurrence of a chore that's gone.
self.storage.remove_team_joins_for_chore(chore_id)
# Its auctions (#982) go with it; live ones are called off (#999).
called_off = self.remove_chore_from_auctions(chore_id)
# Remove chore from children's chore_order lists
for child in self.storage.get_children():
if chore_id in child.chore_order:
@@ -564,6 +610,8 @@ class ChoresMixin:
self.storage.update_child(child)
await self.storage.async_save()
await self.async_refresh()
for auction, bidders in called_off:
await self._async_tell_bidders_called_off(auction, bidders)
def get_chore(self, chore_id: str) -> Chore | None:
"""Get a chore by ID."""
@@ -807,10 +855,14 @@ class ChoresMixin:
)
return None
# Check daily limit (only count parent completions, not bonus sub-tasks)
# Check daily limit (only count parent completions, not bonus sub-tasks).
# A completion an inspection sent back to redo (#981) no longer counts.
all_completions = self.storage.get_completions()
redo_ids = self.inspection_redo_completion_ids()
todays_completions_count = 0
for comp in all_completions:
if comp.id in redo_ids:
continue
if comp.chore_id == chore_id and comp.child_id == child_id and not comp.bonus_subtask_id:
comp_dt = comp.completed_at
if isinstance(comp_dt, str):
@@ -862,29 +914,102 @@ class ChoresMixin:
is_open_ended = bool(getattr(chore, "open_ended", False))
if is_open_ended and not as_parent and not note:
raise ValueError("Say what you did to submit this one.")
# Teamwork chores (#928): this tap joins the day's occurrence. Only the
# join that fills the team records anything — a completion for every
# participant at once — so a lone join returns None like the other
# soft outcomes above. Every gate above has already run for the joiner.
if self.teamwork_size(chore):
return await self._async_join_team_chore(chore, child, now, as_parent=as_parent, photo_url=photo_url)
# Open-ended completions always go to a parent: the child suggested the
# point value, so self-awarding it would let them set their own pay.
auto_approve = as_parent or (not chore.requires_approval and not requires_photo and not is_open_ended)
effective_points = self._apply_roulette_multiplier(
chore,
child_id,
self._apply_speed_bonus(
# Won at auction (#982): the winner is paid their winning bid for this
# occurrence instead of the chore's own points — exactly what they bid.
auction_price = self.auction_price_for(chore, child_id, today)
if auction_price is not None:
effective_points = auction_price
else:
effective_points = self._apply_roulette_multiplier(
chore,
self._apply_time_adjustment(chore, self.effective_chore_points(chore), now),
now,
),
child_id,
self._apply_speed_bonus(
chore,
self._apply_time_adjustment(chore, self.effective_chore_points(chore), now),
now,
),
)
# Redoing a chore an inspection sent back (#981): the original
# completion's points stand, so this one earns nothing extra. It still
# goes through the normal approval path.
redo = self._inspection_redo_for(chore.id, child_id)
if redo is not None:
effective_points = 0
completion = await self._async_record_completion(
chore,
child,
now,
auto_approve=auto_approve,
points=effective_points,
as_parent=as_parent,
photo_url=photo_url,
note=note,
suggested_points=suggested_points,
)
if redo is not None:
self._inspection_on_redo_recorded(redo, completion.id)
await self._async_after_completions(
chore,
# An open-ended chore has no points of its own, so quote the
# child's own estimate — otherwise every one of these pushes
# announces "+0" (#832).
[
(
child,
completion,
0
if redo is not None
else auction_price
if auction_price is not None
else (completion.suggested_points if is_open_ended else chore.points),
)
],
auto_approve=auto_approve,
)
return completion
async def _async_record_completion(
self,
chore,
child,
now,
*,
auto_approve: bool,
points: int,
as_parent: bool = False,
photo_url: str = "",
note: str = "",
suggested_points: int = 0,
) -> ChoreCompletion:
"""Store one completion and, when auto-approved, pay it.
The part of completing a chore that is per child. The caller has already
run every eligibility gate and then calls _async_after_completions once
for the whole batch (one child normally, a whole team for #928).
"""
completion = ChoreCompletion(
chore_id=chore_id,
child_id=child_id,
chore_id=chore.id,
child_id=child.id,
completed_at=now,
approved=auto_approve,
points_awarded=effective_points if auto_approve else 0,
submitted_points=effective_points,
points_awarded=points if auto_approve else 0,
submitted_points=points,
photo_url=photo_url or "",
note=note,
suggested_points=suggested_points,
# A child's own submission may be taken back inside the undo
# window (#918); one a parent made on their behalf may not, and
# nothing made while the window is 0 (off) ever can.
child_undo_allowed=not as_parent and undo_window_seconds(self.storage) > 0,
)
# Record the completion before awarding anything. Awarding can suspend
@@ -895,7 +1020,7 @@ class ChoresMixin:
self.storage.add_completion(completion)
if auto_approve:
total_awarded = await self._award_points(child, effective_points, chore_id=chore_id)
total_awarded = await self._award_points(child, points, chore_id=chore.id)
completion.approved = True
completion.approved_at = dt_util.now()
completion.points_awarded = total_awarded
@@ -908,53 +1033,61 @@ class ChoresMixin:
"child_name": child.name,
"chore_id": chore.id,
"chore_name": chore.name,
"points": effective_points,
"points": points,
"difficulty": getattr(chore, "difficulty", "medium"),
"timestamp": dt_util.now().isoformat(),
},
)
# Update last_completed store (window starts at completion time, midnight-rounded)
self.storage.set_last_completed(chore_id, child_id, now.isoformat())
self.storage.set_last_completed(chore.id, child.id, now.isoformat())
# One-shot: if auto-approved, disable for this child immediately
if getattr(chore, "schedule_mode", "specific_days") == "one_shot" and auto_approve:
if child_id not in chore.disabled_for:
chore.disabled_for.append(child_id)
if child.id not in chore.disabled_for:
chore.disabled_for.append(child.id)
self._check_one_shot_fully_disabled(chore)
self.storage.update_chore(chore)
return completion
async def _async_after_completions(self, chore, recorded: list, *, auto_approve: bool) -> None:
"""Save, notify and refresh once for completions just recorded.
``recorded`` is a list of ``(child, completion, notify_points)``. One
save and one refresh cover the batch (a refresh per child is the cost
#794 measured); the approval push and the progression hooks stay per
child.
"""
await self.storage.async_save()
# Fire approval notification only if it stays pending
if not auto_approve:
await self._async_notify_pending_approval(
child.name,
chore.name,
# An open-ended chore has no points of its own, so quote the
# child's own estimate — otherwise every one of these pushes
# announces "+0" (#832).
completion.suggested_points if is_open_ended else chore.points,
completion_id=completion.id,
photo_url=completion.photo_url,
)
for child, completion, notify_points in recorded:
await self._async_notify_pending_approval(
child.name,
chore.name,
notify_points,
completion_id=completion.id,
photo_url=completion.photo_url,
)
await self.async_refresh()
# Trigger badge evaluation for anything that auto-approved
if auto_approve and getattr(self, "badges", None):
await self.badges.evaluate_for_child(child_id, "manual")
if not auto_approve:
return
for child, _completion, _points in recorded:
# Trigger badge evaluation for anything that auto-approved
if getattr(self, "badges", None):
await self.badges.evaluate_for_child(child.id, "manual")
# Auto-approved completions skip the parent-approval path, so they must
# run the same post-approval progression hooks here — otherwise quest
# steps and challenges only advance for approval-required chores (#558).
if auto_approve:
# Auto-approved completions skip the parent-approval path, so they must
# run the same post-approval progression hooks here — otherwise quest
# steps and challenges only advance for approval-required chores (#558).
if hasattr(self, "_async_advance_quests"):
await self._async_advance_quests(child_id, chore_id)
await self._async_advance_quests(child.id, chore.id)
if hasattr(self, "_async_evaluate_challenges"):
await self._async_evaluate_challenges(child_id)
return completion
await self._async_evaluate_challenges(child.id)
async def async_parent_complete_chore(self, chore_id: str) -> ChoreCompletion:
"""Mark a chore as completed by the parent — zero points, advances recurrence."""
@@ -1014,9 +1147,13 @@ class ChoresMixin:
return completion
async def async_complete_bonus_subtask(
self, chore_id: str, bonus_subtask_id: str, child_id: str
self, chore_id: str, bonus_subtask_id: str, child_id: str, by_child: bool = True
) -> ChoreCompletion:
"""Complete a bonus sub-task (only available after parent chore is completed today)."""
"""Complete a bonus sub-task (only available after parent chore is completed today).
``by_child=False`` marks a completion made from the admin panel, which
the child may not undo (#918).
"""
chore = self.get_chore(chore_id)
if not chore:
raise ValueError(f"Chore {chore_id} not found")
@@ -1074,6 +1211,7 @@ class ChoresMixin:
points_awarded=subtask.points if not chore.requires_approval else 0,
submitted_points=subtask.points,
bonus_subtask_id=bonus_subtask_id,
child_undo_allowed=by_child and undo_window_seconds(self.storage) > 0,
)
# Written before the award for the same reason as the main completion
@@ -1097,7 +1235,13 @@ class ChoresMixin:
await self.async_refresh()
return completion
async def async_approve_chore(self, completion_id: str, refresh: bool = True, points: int | None = None) -> None:
async def async_approve_chore(
self,
completion_id: str,
refresh: bool = True,
points: int | None = None,
rating: int | None = None,
) -> None:
"""Approve a chore completion.
``refresh=False`` is used by ``async_approve_chores_bulk`` so a batch
@@ -1111,6 +1255,15 @@ class ChoresMixin:
would for an ordinary approval. ``None`` means "pay what the submission
was worth when it was made", falling back to the chore's value for
completions recorded before that was stored.
``rating`` is the parent's optional 1-3 star quality rating (#927),
honoured only while the ``quality_rating_enabled`` setting is on. It
scales the base points by that star's multiplier before the streak and
weekend multipliers ride on top, and is stored on the completion. No
rating pays 100%. An explicit ``points`` award is already the parent's
own valuation, so it is paid as-is and the rating is only recorded.
Because the scaled total lands in ``points_awarded``, undo and reject
reverse exactly what the rating paid.
"""
completions = self.storage.get_completions()
for completion in completions:
@@ -1121,7 +1274,9 @@ class ChoresMixin:
completion_id,
)
return
chore = self.get_chore(completion.chore_id)
# A bounty's completion (#931) has no chore of its own; it is
# priced and paid through this same path.
chore = self.get_chore(completion.chore_id) or self.bounty_completion_chore(completion)
child = self.get_child(completion.child_id)
if chore and child:
@@ -1149,6 +1304,20 @@ class ChoresMixin:
pts = self._apply_time_adjustment(
chore, self.effective_chore_points(chore), completion.completed_at
)
stars = 0
if rating is not None and self.quality_rating_enabled():
try:
stars = int(rating)
except (TypeError, ValueError):
stars = 0
if stars not in QUALITY_RATINGS:
_LOGGER.warning("Ignoring invalid quality rating %r for %s", rating, completion_id)
stars = 0
if stars and points is None:
# Half-up, like the cards' preview (Python's round() is
# half-to-even, so ★ on a 30-point chore would pay 22
# where the card showed 23).
pts = max(0, int(pts * self.quality_rating_multipliers()[stars] + 0.5))
if points is not None:
# An explicit award overrides every per-chore
# calculation above (bonus sub-task, timed rate,
@@ -1170,6 +1339,10 @@ class ChoresMixin:
completion.approved = True
completion.approved_at = dt_util.now()
completion.points_awarded = 0
completion.quality_rating = stars
# A parent has reviewed it: the child can no longer undo
# it, whatever the window says (#918).
completion.child_undo_allowed = False
self.storage.update_completion(completion)
total_awarded = await self._award_points(
@@ -1181,6 +1354,8 @@ class ChoresMixin:
)
completion.points_awarded = total_awarded
self.storage.update_completion(completion)
if getattr(completion, "bounty_id", ""):
self._bounty_on_approved(completion)
# Dismiss the mobile approval push now this completion is
# reviewed (covers single approve AND "approve all", which
@@ -1195,6 +1370,7 @@ class ChoresMixin:
"chore_id": completion.chore_id,
"completion_id": completion.id,
"timestamp": dt_util.now().isoformat(),
**({"quality_rating": stars} if stars else {}),
},
)
@@ -1258,8 +1434,11 @@ class ChoresMixin:
NOT remove or modify the completion records themselves.
"""
completion = target_completion
if completion.points_awarded > 0:
child = self.get_child(completion.child_id)
child = self.get_child(completion.child_id)
# Approval, not a positive payout, is what marks an award to reverse: a
# 0-point chore still bumps the chore count and can advance the streak.
# Pending submissions have been awarded nothing yet.
if completion.approved:
if child:
child.points = max(0, child.points - completion.points_awarded)
child.total_points_earned = max(0, child.total_points_earned - completion.points_awarded)
@@ -1273,18 +1452,23 @@ class ChoresMixin:
other_same_day = any(
c.id != completion.id
and c.child_id == completion.child_id
and c.approved
and not c.bonus_subtask_id
and dt_util.as_local(c.completed_at).date() == reject_date
for c in completions
)
if not other_same_day:
streak_before_undo = child.current_streak or 0
child.current_streak = max(0, child.current_streak - 1)
# A streak freeze this completion earned goes too (#925).
self._reverse_streak_freeze_earn(child, streak_before_undo)
if getattr(child, "last_completion_date", None) == reject_date.isoformat():
remaining = [
dt_util.as_local(c.completed_at).date()
for c in completions
if c.id != completion.id
and c.child_id == completion.child_id
and c.approved
and not c.bonus_subtask_id
]
child.last_completion_date = max(remaining).isoformat() if remaining else None
@@ -1316,8 +1500,6 @@ class ChoresMixin:
)
child.streak_milestones_achieved = sorted(d for d in achieved if d <= child.current_streak)
self.storage.update_child(child)
bonus_completions: list = []
is_parent = not target_completion.bonus_subtask_id
if is_parent:
@@ -1333,15 +1515,11 @@ class ChoresMixin:
and c.id != target_completion.id
and dt_util.as_local(c.completed_at).date() == comp_date
]
if bonus_completions:
child = self.get_child(target_completion.child_id)
for bc in bonus_completions:
if bc.points_awarded > 0 and child:
child.points = max(0, child.points - bc.points_awarded)
child.total_points_earned = max(0, child.total_points_earned - bc.points_awarded)
child.total_chores_completed = max(0, child.total_chores_completed - 1)
if child:
self.storage.update_child(child)
for bc in bonus_completions:
if bc.approved and child:
child.points = max(0, child.points - bc.points_awarded)
child.total_points_earned = max(0, child.total_points_earned - bc.points_awarded)
child.total_chores_completed = max(0, child.total_chores_completed - 1)
# Undo last_completed store so recurrence window resets correctly
self.storage.undo_last_completed(target_completion.chore_id, target_completion.child_id)
@@ -1354,12 +1532,48 @@ class ChoresMixin:
chore.enabled = True
self.storage.update_chore(chore)
if child and (completion.approved or any(bc.approved for bc in bonus_completions)):
# Keep the leaderboard and its chart in step once the main award and
# any cascaded bonus sub-tasks have both been reversed.
child.career_score = child.total_points_earned - child.total_penalties_received
self.storage.update_child(child)
self.storage.append_career_score_snapshot(child.id, dt_util.now().date().isoformat(), child.career_score)
return bonus_completions
async def async_reject_chore(self, completion_id: str) -> None:
"""Reject a chore completion and fully reverse all awards if already granted."""
async def async_undo_chore(self, completion_id: str) -> None:
"""A child takes back their own completion (#918).
Allowed only for a pending submission no parent has reviewed yet, or an
auto-approved one still inside the global undo window — see
``chore_undo``. The caller has already checked the linked-child rule
against the completion's *stored* child. Everything from the re-read to
the reversal inside ``async_reject_chore`` runs without yielding, so a
parent's approval or a second undo can't slip in between the check and
the reversal.
"""
completions = self.storage.get_completions()
completion = next((c for c in completions if c.id == completion_id), None)
if completion is None:
raise ValueError("This chore has already been undone. Refresh the card.")
if not child_can_undo(completion, completions, undo_window_seconds(self.storage)):
raise ValueError("This chore can't be undone any more. Ask a parent to undo it.")
await self.async_reject_chore(completion_id, event="taskmate_chore_undone")
async def async_reject_chore(
self, completion_id: str, event: str = "taskmate_chore_rejected", reason: str = ""
) -> None:
"""Reject a chore completion and fully reverse all awards if already granted.
``event`` is the bus event fired afterwards; a child's own undo fires
``taskmate_chore_undone`` so automations can tell it from a parent's
rejection. ``reason`` (#976) is the parent's optional "why", shown to
the child; only a parent's rejection carries one.
"""
completions = self.storage.get_completions()
target_completion = next((c for c in completions if c.id == completion_id), None)
rejected = event == "taskmate_chore_rejected"
reason = clean_reject_reason(reason) if rejected else ""
if target_completion:
bonus_completions = self._reverse_completion_awards(target_completion, completions)
@@ -1367,6 +1581,10 @@ class ChoresMixin:
self.storage.remove_completion(bc.id)
self.storage.remove_completion(completion_id)
if target_completion and getattr(target_completion, "bounty_id", ""):
# A bounty goes back to the same child rather than to the board
# (#931): renewed on a rejection, as it was on their own undo.
self._bounty_on_withdrawn(target_completion, rejected=rejected)
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.
@@ -1376,23 +1594,35 @@ class ChoresMixin:
# delete it (best-effort; foreign/blank URLs are ignored).
if target_completion and getattr(target_completion, "photo_url", ""):
await photos.async_delete_photo(self.hass, target_completion.photo_url)
chore_name = ""
if target_completion:
chore = self.get_chore(target_completion.chore_id)
bounty = None if chore else self.storage.get_bounty(getattr(target_completion, "bounty_id", "") or "")
chore_name = getattr(chore, "name", "") or getattr(bounty, "title", "") or ""
if rejected and not target_completion.bonus_subtask_id:
self._record_rejection(
"chore", target_completion.child_id, target_completion.chore_id, chore_name, reason
)
# An open inspection of it is void; a redo it was is owed again (#981).
self._inspection_on_completion_removed(completion_id)
await self.storage.async_save()
await self.async_refresh()
if target_completion:
child = self.get_child(target_completion.child_id)
chore = self.get_chore(target_completion.chore_id)
self.hass.bus.async_fire(
"taskmate_chore_rejected",
{
"child_id": target_completion.child_id,
"child_name": getattr(child, "name", ""),
"chore_id": target_completion.chore_id,
"chore_name": getattr(chore, "name", ""),
"completion_id": completion_id,
"timestamp": dt_util.now().isoformat(),
},
)
payload = {
"child_id": target_completion.child_id,
"child_name": getattr(child, "name", ""),
"chore_id": target_completion.chore_id,
"chore_name": chore_name,
"completion_id": completion_id,
"timestamp": dt_util.now().isoformat(),
}
if rejected:
payload["reason"] = reason
self.hass.bus.async_fire(event, payload)
if rejected and not target_completion.bonus_subtask_id:
await self._async_notify_rejected("chore", child, chore_name, reason)
# Dismiss the mobile approval push for this reviewed completion. Also
# covers undoing an already-approved chore (whose push was cleared at
# approval): re-clearing a stale tag is a harmless no-op.
@@ -1430,12 +1660,21 @@ class ChoresMixin:
bc.approved = False
bc.approved_at = None
bc.points_awarded = 0
bc.quality_rating = 0
bc.child_undo_allowed = False
self.storage.update_completion(bc)
target.approved = False
target.approved_at = None
target.points_awarded = 0
# The rating belonged to the approval being undone (#927); a
# re-approval rates it afresh.
target.quality_rating = 0
# A parent touched it, so child undo stays locked (#918).
target.child_undo_allowed = False
self.storage.update_completion(target)
if getattr(target, "bounty_id", ""):
self._bounty_on_unapproved(target)
# Quest progress advanced on approval, so it has to come back too —
# otherwise re-approving the same completion advances the chain a
@@ -1485,6 +1724,10 @@ class ChoresMixin:
if self._is_child_on_vacation(self._cached_child(child_id)):
return False
# Birthday day off (#924): only mandatory chores stay on the list.
if not getattr(chore, "mandatory", False) and self.is_birthday_day_off(self._cached_child(child_id)):
return False
# Check if chore is globally disabled (soft-disabled one-shot chores)
if not getattr(chore, "enabled", True):
return False
@@ -1494,6 +1737,12 @@ class ChoresMixin:
if child_id in disabled_for:
return False
# Chore auction (#982): today's occurrence was won at auction, so it is
# the winner's alone — whatever the mode, "everyone" included.
winner = self.auction_winner(chore)
if winner and winner != child_id:
return False
# Dynamic assignment — only the active child(ren) see alternating/random chores
if getattr(chore, "assignment_mode", "everyone") != "everyone":
active = self._compute_active_children(chore)
@@ -1542,6 +1791,13 @@ class ChoresMixin:
if not satisfied:
return False
# Calendar moves/removals (#977): an occurrence removed from today, or
# moved away from it, takes the chore off today's list; one moved onto
# today puts it on, whatever the regular schedule says.
override = self.occurrence_override(chore, dt_util.as_local(dt_util.now()).date())
if override is False:
return False
schedule_mode = getattr(chore, "schedule_mode", "specific_days")
# One-shot chores: only available on the day they were created
@@ -1576,6 +1832,14 @@ class ChoresMixin:
record = self.storage.get_last_completed(chore.id, child_id)
current_iso = record.get("current")
if override is True:
# Moved onto today: open until it is done today. The regular window
# and anchor maths would measure from the wrong day.
try:
return not current_iso or date.fromisoformat(current_iso[:10]) < today
except (ValueError, TypeError):
return True
if not current_iso:
# Never completed — a future recurrence anchor always defers
# availability, regardless of first_occurrence_mode
@@ -1595,6 +1859,9 @@ class ChoresMixin:
last_dt = date.fromisoformat(current_iso[:10])
except (ValueError, TypeError):
return True
# A completion of a moved occurrence counts from the day it was moved
# from, so moving one week's chore doesn't shift every week after it.
last_dt = self.occurrence_origin(chore, last_dt)
# every_2_days with anchor — check alignment
if recurrence == "every_2_days" and recurrence_start:
@@ -1673,7 +1940,8 @@ class ChoresMixin:
if due_days:
today = dt_util.as_local(dt_util.now()).date()
dow = ("monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday")[today.weekday()]
if dow not in due_days:
# An occurrence moved onto today (#977) is due whatever the weekday.
if dow not in due_days and self.occurrence_override(chore, today) is not True:
return False
return True
@@ -1690,8 +1958,10 @@ class ChoresMixin:
"""
today = dt_util.as_local(dt_util.now()).date()
out = []
# Sent back to redo by an inspection (#981): owed again today.
redo_ids = self.inspection_redo_completion_ids()
with self.availability_build_scope():
completions = self._cached_completions()
completions = [c for c in self._cached_completions() if c.id not in redo_ids]
for chore in self.storage.get_chores():
if not self._is_chore_completable_by_child(chore, child_id):
continue
@@ -1716,6 +1986,99 @@ class ChoresMixin:
out.append(chore)
return out
def get_today_board(self) -> dict:
"""Every chore each child has today and where it stands (#966).
Feeds the admin panel's Today page. Unlike ``get_due_chores_for_child``
(outstanding only) this keeps the chores already done, so the panel can
show done/total. A chore is on a child's board when it is still
completable today, when they completed it today (a finished recurring
chore is no longer "available", but it was still today's), or when a
mandatory miss is waiting for it today.
Status per chore: ``done`` (approved, up to the daily limit), ``pending``
(up to the limit, some awaiting approval), ``missed`` (a pending
mandatory miss for today) or ``todo``.
"""
today = dt_util.as_local(dt_util.now()).date()
today_iso = today.isoformat()
missed = {(m.child_id, m.chore_id) for m in self.storage.get_mandatory_misses() if m.due_date == today_iso}
children = []
redo_ids = self.inspection_redo_completion_ids()
with self.availability_build_scope():
done_today: dict[tuple[str, str], list[bool]] = {}
for c in self._cached_completions():
# Sent back to redo by an inspection (#981): not done any more.
if getattr(c, "bonus_subtask_id", "") or c.id in redo_ids:
continue
try:
if dt_util.as_local(c.completed_at).date() != today:
continue
except (AttributeError, TypeError, ValueError):
continue
done_today.setdefault((c.child_id, c.chore_id), []).append(bool(c.approved))
chores = self.storage.get_chores()
for child in self.storage.get_children():
items = []
for chore in chores:
flags = done_today.get((child.id, chore.id), [])
limit = max(int(getattr(chore, "daily_limit", 1) or 1), 1)
open_today = (
len(flags) < limit
and self._is_chore_completable_by_child(chore, child.id)
and not self.weekly_target_met(chore, child.id)
)
if open_today:
status = "missed" if (child.id, chore.id) in missed else "todo"
elif flags:
status = "done" if all(flags) else "pending"
elif (child.id, chore.id) in missed:
status = "missed"
else:
continue
items.append({"chore_id": chore.id, "status": status, "count": len(flags), "limit": limit})
children.append(
{
"child_id": child.id,
"due": len(items),
"done": sum(1 for i in items if i["status"] in ("done", "pending")),
"chores": items,
}
)
return {"date": today_iso, "children": children}
async def async_record_daily_progress(self, _now=None) -> None:
"""Store today's done/total per child for the Today page's week view (#966).
Runs just before midnight, while "today" is still the day being recorded;
the scheduled-chore rules only know about the current day, so a past
day's total can't be worked out afterwards.
"""
board = self.get_today_board()
cutoff = (dt_util.as_local(dt_util.now()).date() - timedelta(days=DAILY_PROGRESS_KEEP_DAYS)).isoformat()
for entry in board["children"]:
self.storage.upsert_daily_progress(entry["child_id"], board["date"], entry["due"], entry["done"], cutoff)
await self.storage.async_save()
def daily_progress_state(self, days: int = 7) -> dict:
"""Last ``days`` days of stored done/total per child, today included live."""
board = self.get_today_board()
today = date.fromisoformat(board["date"])
dates = [(today - timedelta(days=n)).isoformat() for n in range(days - 1, -1, -1)]
out = {}
for child in self.storage.get_children():
stored = {e.get("date"): e for e in self.storage.get_daily_progress(child.id)}
live = next((e for e in board["children"] if e["child_id"] == child.id), None)
if live:
stored[board["date"]] = {"due": live["due"], "done": live["done"]}
out[child.id] = [
{"date": d, "due": stored[d].get("due", 0), "done": stored[d].get("done", 0)}
if d in stored
else {"date": d}
for d in dates
]
return {"board": board, "history": out}
async def _async_expire_dated_chores(self) -> None:
"""Soft-disable any enabled chore whose expires_on date has passed."""
today = dt_util.as_local(dt_util.now()).date()
+13 -3
View File
@@ -42,8 +42,12 @@ class MandatoryMixin:
key = f"{chore.id}:{child_id}:{day.isoformat()}"
return self.mandatory_postpone.get(key) or (chore.time_category or "anytime")
def _mandatory_owers(self, chore) -> list[str]:
"""Child IDs responsible for this chore right now."""
def _mandatory_owers(self, chore, day: date | None = None) -> list[str]:
"""Child IDs responsible for this chore on ``day`` (default today)."""
# Won at auction (#982): the winner owes that occurrence, nobody else.
winner = self.auction_winner(chore, day)
if winner:
return [winner]
mode = getattr(chore, "assignment_mode", "everyone")
assigned = list(getattr(chore, "assigned_to", []) or [])
if mode in _SHARED_MODES:
@@ -112,7 +116,7 @@ class MandatoryMixin:
continue
if not self._is_chore_scheduled_for_date(chore, day):
continue
for child_id in self._mandatory_owers(chore):
for child_id in self._mandatory_owers(chore, day):
if self._effective_period_for(chore, child_id, day) != period_id:
continue
if child_id in (getattr(chore, "disabled_for", []) or []):
@@ -253,6 +257,8 @@ class MandatoryMixin:
will be resolved by the parent / next-day prune). Each notification is
still gated by its own master switch + routes inside ``fire()``; stages
advance regardless so a disabled type is not retro-fired when re-enabled.
While the child is away (#926) the child reminders are held by
``fire()`` for the single arrival nudge, and the parent alert waits.
Returns the number of misses whose stage advanced.
"""
now = now or dt_util.now()
@@ -277,6 +283,10 @@ class MandatoryMixin:
target = 2
if elapsed_min >= parent_minutes:
target = 3
if target == 3 and self.notifications.child_is_away(miss.child_id):
# Presence-aware (#926): no parent alert about a child who is
# out. Hold at stage 2; the ladder resumes once they're home.
target = 2
if target <= miss.escalation_stage:
continue
@@ -15,40 +15,75 @@ from __future__ import annotations
import contextlib
import logging
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Any
from homeassistant.core import HomeAssistant
from homeassistant.helpers.event import async_track_time_change
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.event import async_track_state_change_event, async_track_time_change
from . import authz
from .const import (
DEFAULT_NOTIFICATION_GROUP,
DEFAULT_NOTIFICATION_NAV_URL,
NOTIF_TYPE_ALL_CHORES_DONE,
NOTIF_TYPE_AUCTION_CLOSING,
NOTIF_TYPE_AUCTION_OPENED,
NOTIF_TYPE_AUCTION_RESULT,
NOTIF_TYPE_BADGE_EARNED,
NOTIF_TYPE_BEDTIME_REMINDER,
NOTIF_TYPE_BIRTHDAY,
NOTIF_TYPE_BOUNTY_CLAIM_LAPSING,
NOTIF_TYPE_BOUNTY_POSTED,
NOTIF_TYPE_CELEBRATION,
NOTIF_TYPE_FAMILY_GOAL_REACHED,
NOTIF_TYPE_INSPECTION_PASSED,
NOTIF_TYPE_INSPECTION_REMINDER,
NOTIF_TYPE_INSPECTION_STARTED,
NOTIF_TYPE_ITEM_REJECTED,
NOTIF_TYPE_LEVEL_UP,
NOTIF_TYPE_MANDATORY_PARENT_ALERT,
NOTIF_TYPE_MANDATORY_REMINDER,
NOTIF_TYPE_MONTHLY_REPORT,
NOTIF_TYPE_PENDING_CHORE_APPROVAL,
NOTIF_TYPE_PENDING_REWARD_CLAIM,
NOTIF_TYPE_PRESENCE_ARRIVAL,
NOTIF_TYPE_RECAP_READY,
NOTIF_TYPE_SEASON_CHAMPION,
NOTIF_TYPE_STREAK_AT_RISK,
NOTIF_TYPE_STREAK_FREEZE_USED,
NOTIF_TYPE_STREAK_MILESTONE,
NOTIF_TYPE_WEEKLY_DIGEST,
NOTIF_TYPE_WISH_PLEDGED,
NOTIF_TYPE_WISH_REQUESTED,
QUALITY_RATINGS,
)
from .models import NotificationRoute
from .timewindow import is_within_window, parse_hhmm
_LOGGER = logging.getLogger(__name__)
DEFAULT_MORNING_NOTIFY_TIME = "08:00"
# Appended to actionable notifications sent to non-mobile_app backends, which
# silently ignore tap actions. Gives those recipients a way to act.
_APPROVE_IN_PANEL_HINT = "Open the TaskMate panel to approve or reject."
# Mobile action id prefix for "approve with an N-star quality rating" (#927):
# TASKMATE_RATE_<n>_<completion id>. Distinct from TASKMATE_APPROVE_ so the
# original Approve/Reject ids keep working unchanged when ratings are off.
_RATE_ACTION_PREFIX = "TASKMATE_RATE_"
def _reject_action(entry_id: str) -> dict[str, str]:
"""The Reject button of an approval push, with a reply box for the reason (#976)."""
return {
"action": f"TASKMATE_REJECT_{entry_id}",
"title": "Reject",
"behavior": "textInput",
"textInputButtonTitle": "Reject",
"textInputPlaceholder": "Reason (optional)",
}
def _approval_tag(entry_id: str) -> str:
"""Stable HA companion-app notification tag for an approval (chore/reward).
@@ -87,6 +122,41 @@ NOTIFICATION_TYPES: list[NotificationTypeMeta] = [
NotificationTypeMeta(NOTIF_TYPE_MONTHLY_REPORT, "parent", False, False, False, False),
NotificationTypeMeta(NOTIF_TYPE_SEASON_CHAMPION, "both", False, False, False, False),
NotificationTypeMeta(NOTIF_TYPE_FAMILY_GOAL_REACHED, "both", False, False, False, False),
# Birthday mode (#924): a morning "happy birthday" to the child, at a
# per-child time (08:00 when none is set).
NotificationTypeMeta(NOTIF_TYPE_BIRTHDAY, "child", True, True, False, False),
NotificationTypeMeta(NOTIF_TYPE_STREAK_FREEZE_USED, "both", False, False, False, False),
NotificationTypeMeta(NOTIF_TYPE_PRESENCE_ARRIVAL, "child", False, False, False, False),
# Wishlist (#932): a parent hears about a new wish to approve; the child
# hears when someone pledges towards one of theirs. Off until turned on,
# like every type added after the first release.
NotificationTypeMeta(NOTIF_TYPE_WISH_REQUESTED, "parent", False, False, False, False),
NotificationTypeMeta(NOTIF_TYPE_WISH_PLEDGED, "child", False, False, False, False),
# Bounty board (#931), both opt-in: a new bounty for the children it's
# open to (when the parent ticks "notify"), and a warning to the claimer
# shortly before their claim lapses.
NotificationTypeMeta(NOTIF_TYPE_BOUNTY_POSTED, "child", False, False, False, False),
NotificationTypeMeta(NOTIF_TYPE_BOUNTY_CLAIM_LAPSING, "child", False, False, False, False),
# Recaps (#929): one push per child plus one grouped parent message, sent
# at the recap send time rather than at midnight when they're built. Off
# at install; switched on the first time a parent turns recaps on (#944).
NotificationTypeMeta(NOTIF_TYPE_RECAP_READY, "both", True, False, False, False),
# Reject reasons (#976): the child hears their chore / claim was sent
# back, with the parent's reason. Off until turned on.
NotificationTypeMeta(NOTIF_TYPE_ITEM_REJECTED, "child", False, False, False, False),
# Chore auctions (#982), all opt-in: an auction opened and bidding closing
# within the hour go to the children who may bid (when the parent ticks
# "tell children"); the result goes to them and to the parents.
NotificationTypeMeta(NOTIF_TYPE_AUCTION_OPENED, "child", False, False, False, False),
NotificationTypeMeta(NOTIF_TYPE_AUCTION_CLOSING, "child", False, False, False, False),
NotificationTypeMeta(NOTIF_TYPE_AUCTION_RESULT, "both", False, False, False, False),
# Surprise inspections (#981): the child hears one is coming (when they're
# told) and that it passed — both off until turned on. The parent's
# reminder before an undecided inspection closes quietly is on: it only
# ever fires for an inspection a parent started (or switched on).
NotificationTypeMeta(NOTIF_TYPE_INSPECTION_STARTED, "child", False, False, False, False),
NotificationTypeMeta(NOTIF_TYPE_INSPECTION_PASSED, "child", False, False, False, False),
NotificationTypeMeta(NOTIF_TYPE_INSPECTION_REMINDER, "parent", False, False, False, True),
]
NOTIFICATION_TYPES_BY_ID: dict[str, NotificationTypeMeta] = {t.id: t for t in NOTIFICATION_TYPES}
@@ -128,6 +198,31 @@ def _validate_group(value: str) -> str:
return value
# Presence-aware reminders (#926): the child-facing nags that wait for a child
# who is out. Custom reminders are held too (see _make_custom_callback). Good
# news (badges, level-ups, celebrations) is never held — only the nagging.
_PRESENCE_DEFERRED_TYPES = frozenset(
{
NOTIF_TYPE_BEDTIME_REMINDER,
NOTIF_TYPE_STREAK_AT_RISK,
NOTIF_TYPE_MANDATORY_REMINDER,
}
)
_PRESENCE_HOME_STATES = ("home", "on", "true", "present")
def _presence_is_home(state: str | None) -> bool | None:
"""Read a presence state: True home, False away, None when it can't tell.
``unavailable``/``unknown`` are "can't tell" rather than away, so a flaky
tracker neither starts an absence nor fakes an arrival.
"""
if state is None or state in ("unavailable", "unknown", ""):
return None
return str(state).lower() in _PRESENCE_HOME_STATES
# Quiet hours reuse the shared time-of-day window helpers (also used by
# time-locked rewards, #857).
_parse_hhmm = parse_hhmm
@@ -149,6 +244,12 @@ class NotificationCoordinator:
self.storage = storage
self._scheduled_unsubs: list = [] # cancellation handles for time triggers
self.coordinator: Any = None
# Presence-aware reminders (#926). Runtime-only: child_id -> linked
# entity, when each child left, and who had a reminder held back.
self._presence_unsub = None
self._presence_entities: dict[str, str] = {}
self._presence_away_since: dict[str, datetime] = {}
self._presence_deferred: set[str] = set()
async def fire(
self,
@@ -197,6 +298,10 @@ class NotificationCoordinator:
notify_service = self._resolve_notify_service(recipient_id)
if not notify_service:
continue
if type_id in _PRESENCE_DEFERRED_TYPES and self._defer_if_away(recipient_id):
# The child is out (#926): hold the nag for the arrival nudge
# instead of buzzing them at the park.
continue
await self._send_to(notify_service, message, meta, context, nav_url, group)
recipients_fired.append(recipient_id)
@@ -301,6 +406,20 @@ class NotificationCoordinator:
"month": "January 2026",
"goal_name": "Movie night fund",
"goal_reward": "a family movie night",
"multiplier": "2",
"count": 3,
"wish_name": "Lego set",
"target": 600,
"pledger": "Grandma",
"bounty_name": "Wash the car",
"minutes": 15,
"period": "January",
"item_name": "Tidy room",
"reason": "Not finished",
"reason_text": ": Not finished",
"until": "18:20",
"bonus": 10,
"note_text": ": Brilliant job!",
"points_name": self.storage.get_points_name(),
}
message = "[TEST] " + self._render_template(meta, ctx)
@@ -379,6 +498,21 @@ class NotificationCoordinator:
NOTIF_TYPE_MONTHLY_REPORT: "TaskMate {month} report:\n{summary}",
NOTIF_TYPE_SEASON_CHAMPION: "🏆 {child_name} won the {month} leaderboard with {points} {points_name}!",
NOTIF_TYPE_FAMILY_GOAL_REACHED: "🎉 Family goal reached: {goal_name}! Time for {goal_reward}.",
NOTIF_TYPE_BIRTHDAY: "🎂 Happy birthday, {child_name}! Every chore pays {multiplier}× today.",
NOTIF_TYPE_STREAK_FREEZE_USED: "❄️ A streak freeze saved {child_name}'s {streak}-day streak ({freezes_left} left).",
NOTIF_TYPE_PRESENCE_ARRIVAL: "🏠 You're home, {child_name} — {count} chores left today.",
NOTIF_TYPE_WISH_REQUESTED: "{child_name} wished for '{wish_name}' ({target} {points_name}) — waiting for your approval.",
NOTIF_TYPE_WISH_PLEDGED: "💝 {pledger} added {points} {points_name} to your wish '{wish_name}'!",
NOTIF_TYPE_BOUNTY_POSTED: "🏁 New bounty: {bounty_name} — {points} {points_name}. First to claim it gets it!",
NOTIF_TYPE_BOUNTY_CLAIM_LAPSING: "⏳ {child_name}, {minutes} minutes left to finish '{bounty_name}' before it goes back on the board.",
NOTIF_TYPE_RECAP_READY: "✨ Your {period} recap is ready, {child_name}! Tap to watch it.",
NOTIF_TYPE_ITEM_REJECTED: "↩️ {child_name}, '{item_name}' was sent back{reason_text}",
NOTIF_TYPE_AUCTION_OPENED: "🔨 New auction: {chore_name} on {date}, up to {max_points} {points_name}. Lowest bid wins!",
NOTIF_TYPE_AUCTION_CLOSING: "⏳ {child_name}, bidding on '{chore_name}' closes in {minutes} minutes.",
NOTIF_TYPE_AUCTION_RESULT: "🔨 Auction closed: {result_text}",
NOTIF_TYPE_INSPECTION_STARTED: "🔍 {child_name}, a grown-up is coming to inspect '{chore_name}' before {until}. Keep it looking great for +{bonus} {points_name}!",
NOTIF_TYPE_INSPECTION_PASSED: "🌟 Inspection passed, {child_name}! '{chore_name}' looked great: +{bonus} {points_name}{note_text}",
NOTIF_TYPE_INSPECTION_REMINDER: "🔍 {child_name}'s '{chore_name}' inspection closes in {minutes} minutes — pass or fail it before {until}.",
}
tpl = context.get("message_template") or templates.get(meta.id, "")
try:
@@ -425,10 +559,7 @@ class NotificationCoordinator:
# `tag` lets us dismiss this push later (clear_approval) once
# the item is reviewed — see _approval_tag.
push["tag"] = _approval_tag(entry_id)
push["actions"] = [
{"action": f"TASKMATE_APPROVE_{entry_id}", "title": "Approve"},
{"action": f"TASKMATE_REJECT_{entry_id}", "title": "Reject"},
]
push["actions"] = self._approval_actions(meta.id, entry_id)
else:
data["message"] = f"{message} {_APPROVE_IN_PANEL_HINT}"
@@ -445,6 +576,11 @@ class NotificationCoordinator:
push["clickAction"] = nav_url
push["url"] = nav_url
# A caller-supplied tag makes a re-send replace the earlier push on the
# phone instead of stacking (recaps, #929). Approvals set their own.
if context.get("tag") and service.startswith("mobile_app"):
push.setdefault("tag", context["tag"])
# Grouping (#811): stack TaskMate's notifications into one bundle so
# they don't scatter through the phone's other HA alerts. Android reads
# data.group; iOS threads on push.thread-id, so send both — same value,
@@ -461,6 +597,37 @@ class NotificationCoordinator:
except Exception as err: # noqa: BLE001
_LOGGER.warning("notify call failed for %s: %s", notify_service, err)
def _approval_actions(self, type_id: str, entry_id: str) -> list[dict[str, str]]:
"""Mobile action buttons for a pending-approval push.
With quality ratings on (#927) a chore approval offers the three star
ratings instead of a plain Approve, so a parent can rate from the lock
screen. Reject goes last: Android shows at most three actions, so the
ratings take priority there and Reject stays in the panel/card. With
the feature off — and always for reward claims — the push keeps the
original Approve/Reject ids, so older pushes still in flight resolve.
Reject asks for a reason in place (#976): the companion app's
``textInput`` behaviour opens a reply box and returns what was typed
as ``reply_text`` on the action event. The id is unchanged, and an
empty reply (or an older push without the box) is a plain reject.
"""
coordinator = getattr(self, "coordinator", None)
rated = (
type_id == NOTIF_TYPE_PENDING_CHORE_APPROVAL
and coordinator is not None
and coordinator.quality_rating_enabled()
)
if rated:
return [
*({"action": f"{_RATE_ACTION_PREFIX}{n}_{entry_id}", "title": "★" * n} for n in QUALITY_RATINGS),
_reject_action(entry_id),
]
return [
{"action": f"TASKMATE_APPROVE_{entry_id}", "title": "Approve"},
_reject_action(entry_id),
]
async def clear_approval(self, type_id: str, entry_id: str) -> None:
"""Dismiss the mobile push for a reviewed approval (chore or reward).
@@ -537,7 +704,20 @@ class NotificationCoordinator:
_LOGGER.warning("Ignoring TaskMate mobile action from a non-parent user")
return
if action.startswith("TASKMATE_APPROVE_"):
if action.startswith(_RATE_ACTION_PREFIX):
# TASKMATE_RATE_<n>_<completion id> (#927): approve with a rating.
stars, _, entry_id = action[len(_RATE_ACTION_PREFIX) :].partition("_")
try:
rating = int(stars)
except ValueError:
_LOGGER.info("Mobile action %s — malformed rating", action)
return
async def _approve_rated(completion_id: str) -> None:
await coordinator.async_approve_chore(completion_id, rating=rating)
await self._review_from_mobile(action, entry_id, _approve_rated, coordinator.async_approve_reward)
elif action.startswith("TASKMATE_APPROVE_"):
await self._review_from_mobile(
action,
action[len("TASKMATE_APPROVE_") :],
@@ -545,11 +725,20 @@ class NotificationCoordinator:
coordinator.async_approve_reward,
)
elif action.startswith("TASKMATE_REJECT_"):
# The reply box's text (#976); absent on a plain tap or an old push.
reason = str((event.data or {}).get("reply_text") or "")
async def _reject_chore(completion_id: str) -> None:
await coordinator.async_reject_chore(completion_id, reason=reason)
async def _reject_reward(claim_id: str) -> None:
await coordinator.async_reject_reward(claim_id, reason=reason)
await self._review_from_mobile(
action,
action[len("TASKMATE_REJECT_") :],
coordinator.async_reject_chore,
coordinator.async_reject_reward,
_reject_chore,
_reject_reward,
)
async def _review_from_mobile(self, action: str, entry_id: str, review_chore, review_reward) -> None:
@@ -575,6 +764,64 @@ class NotificationCoordinator:
# since sold out. Nothing to do, but say why in the log.
_LOGGER.info("Mobile action %s could not be applied: %s", action, err)
async def send_recap_ready(
self,
per_child: list[dict[str, Any]],
parent_message: str,
*,
to_children: bool = True,
to_parents: bool = True,
) -> list[str]:
"""Announce new recaps (#929): one push per child, one grouped for parents.
Goes around ``fire()`` because a single dispatch here is several
messages with different text. The routing rules are the same: the
master switch, each recipient's route, child quiet hours and the parent
routing policy. One ``taskmate_recap_ready`` event fires per child even
when the notification is switched off, like every other type.
"""
meta = NOTIFICATION_TYPES_BY_ID[NOTIF_TYPE_RECAP_READY]
cfg = self.storage.get_notification_config(NOTIF_TYPE_RECAP_READY)
nav_url = self._resolve_nav_url(cfg)
group = self._resolve_group(cfg)
sent: list[str] = []
for item in per_child:
rid = f"child:{item['child_id']}"
fired: list[str] = []
route = cfg.routes.get(rid)
if (
cfg.master_enabled
and to_children
and route is not None
and route.enabled
and not self._child_in_quiet_hours(rid)
):
notify_service = self._resolve_notify_service(rid)
if notify_service:
ctx = {**item, "tag": f"taskmate_recap_{item['child_id']}"}
await self._send_to(notify_service, item["message"], meta, ctx, nav_url, group)
fired.append(rid)
sent.extend(fired)
self._fire_bus_event(
NOTIF_TYPE_RECAP_READY,
{
"child_id": item["child_id"],
"child_name": item["child_name"],
"count": len(item.get("recaps", [])),
"recaps": item.get("recaps", []),
},
fired,
)
if cfg.master_enabled and to_parents and per_child:
for rid in sorted(self._route_parents(NOTIF_TYPE_RECAP_READY, cfg, None)):
notify_service = self._resolve_notify_service(rid)
if not notify_service:
continue
ctx = {"tag": "taskmate_recaps"}
await self._send_to(notify_service, parent_message, meta, ctx, nav_url, group)
sent.append(rid)
return sent
# ------------------------------------------------------------------
# Scheduler — time-gated callbacks
# ------------------------------------------------------------------
@@ -608,6 +855,17 @@ class NotificationCoordinator:
self._make_bedtime_callback(child_id),
)
# Birthday (#924) — per-child morning time, only fires on the day
cfg = self.storage.get_notification_config("birthday")
if cfg.master_enabled:
for recipient_id, route in cfg.routes.items():
if not route.enabled or not recipient_id.startswith("child:"):
continue
self._register_at(
route.time or DEFAULT_MORNING_NOTIFY_TIME,
self._make_birthday_callback(recipient_id.split(":", 1)[1]),
)
# Streak at risk — global cutoff time, fire once per child
cfg = self.storage.get_notification_config("streak_at_risk")
if cfg.master_enabled:
@@ -652,6 +910,24 @@ class NotificationCoordinator:
return _cb
def _make_birthday_callback(self, child_id: str):
async def _cb(now):
coord = self.coordinator
child = self.storage.get_child(child_id)
if child is None or coord is None or not coord.is_birthday(child):
return
await self.fire(
"birthday",
{
"child_name": child.name,
"child_id": child_id,
"multiplier": f"{coord.birthday_multiplier():g}",
},
only_recipients={f"child:{child_id}"},
)
return _cb
async def _streak_at_risk_callback(self, now) -> None:
from homeassistant.util import dt as dt_util
@@ -661,6 +937,9 @@ class NotificationCoordinator:
continue
if child.last_completion_date == today:
continue
# A birthday day off can't break the streak, so don't nag (#924).
if self.coordinator is not None and self.coordinator.is_birthday_day_off(child) is True:
continue
await self.fire(
"streak_at_risk",
{
@@ -687,6 +966,8 @@ class NotificationCoordinator:
notify_service = self._resolve_notify_service(recipient_id)
if not notify_service:
continue
if self._defer_if_away(recipient_id):
continue # held for the arrival nudge (#926)
child_name = ""
if recipient_id.startswith("child:"):
child = self.storage.get_child(recipient_id.split(":", 1)[1])
@@ -724,6 +1005,161 @@ class NotificationCoordinator:
return _cb
# ------------------------------------------------------------------
# Presence-aware reminders (#926)
# ------------------------------------------------------------------
def child_is_away(self, child_id: str) -> bool:
"""True only when the child's presence entity positively says "not home".
No entity, a missing entity, or an unavailable/unknown one all read as
home: a broken tracker must not silence every reminder.
"""
entity_id = self._presence_entities.get(child_id)
if entity_id is None:
child = self.storage.get_child(child_id)
entity_id = (getattr(child, "presence_entity", "") or "").strip() if child else ""
if not entity_id:
return False
state = self.hass.states.get(entity_id)
return _presence_is_home(getattr(state, "state", None)) is False
def _defer_if_away(self, recipient_id: str) -> bool:
"""Hold a reminder for an away child; True means "don't send it now"."""
if not recipient_id.startswith("child:"):
return False
child_id = recipient_id.split(":", 1)[1]
if not self.child_is_away(child_id):
return False
self._presence_deferred.add(child_id)
return True
def sync_presence_tracking(self) -> None:
"""(Re)subscribe to the children's presence entities if they changed.
Cheap enough to call on every coordinator refresh: it compares the
child -> entity map and only resubscribes on a difference, so adding,
editing, removing or importing a child all pick it up.
"""
wanted: dict[str, str] = {}
for child in self.storage.get_children():
entity_id = (getattr(child, "presence_entity", "") or "").strip()
if entity_id:
wanted[child.id] = entity_id
if wanted == self._presence_entities and (self._presence_unsub is not None or not wanted):
return
self.cancel_presence_tracking()
# A child whose entity changed (or was removed) starts from scratch —
# an absence measured on the old tracker says nothing about the new one.
for child_id in list(self._presence_away_since):
if wanted.get(child_id) != self._presence_entities.get(child_id):
self._presence_away_since.pop(child_id, None)
self._presence_deferred &= set(wanted)
self._presence_entities = wanted
if not wanted:
return
from homeassistant.util import dt as dt_util
# Seed absences already under way (e.g. across a restart) from the
# entity's own last_changed, so the min-away rule still holds.
for child_id, entity_id in wanted.items():
if child_id in self._presence_away_since:
continue
state = self.hass.states.get(entity_id)
if _presence_is_home(getattr(state, "state", None)) is False:
since = getattr(state, "last_changed", None)
self._presence_away_since[child_id] = since if isinstance(since, datetime) else dt_util.now()
self._presence_unsub = async_track_state_change_event(
self.hass,
sorted(set(wanted.values())),
self._presence_state_changed,
)
def cancel_presence_tracking(self) -> None:
"""Drop the presence subscription (idempotent; also used on unload)."""
if self._presence_unsub is not None:
with contextlib.suppress(Exception):
self._presence_unsub()
self._presence_unsub = None
@callback
def _presence_state_changed(self, event) -> None:
data = getattr(event, "data", None) or {}
entity_id = data.get("entity_id")
new_state = data.get("new_state")
is_home = _presence_is_home(getattr(new_state, "state", None))
if is_home is None:
return # flaky tracker: keep whatever we last knew
from homeassistant.util import dt as dt_util
now = dt_util.now()
for child_id, tracked in self._presence_entities.items():
if tracked != entity_id:
continue
if not is_home:
# Moving between zones (school -> park) is one absence.
self._presence_away_since.setdefault(child_id, now)
continue
# Consume the absence here, synchronously, so a burst of "home"
# updates (GPS jitter) can only ever produce one nudge.
away_since = self._presence_away_since.pop(child_id, None)
deferred = child_id in self._presence_deferred
self._presence_deferred.discard(child_id)
if away_since is None and not deferred:
continue
self.hass.async_create_task(self.async_arrival_nudge(child_id, away_since, deferred, now))
async def async_arrival_nudge(
self,
child_id: str,
away_since: datetime | None,
deferred: bool,
now: datetime,
) -> bool:
"""Send one "you're home — N chores left" nudge. Returns True if fired.
Fires when the child was out for at least the configured minimum, or
when a reminder was held back while they were out (however short the
trip — held reminders are deferred, never dropped). Held reminders
collapse into this single nudge rather than replaying one by one.
Quiet hours still apply, via ``fire()``.
"""
child = self.storage.get_child(child_id)
if child is None:
return False
if not deferred:
min_away = timedelta(minutes=self.storage.get_presence_arrival_min_away())
if away_since is None or now - away_since < min_away:
return False
count = self._outstanding_chore_count(child_id)
if count <= 0:
return False
await self.fire(
NOTIF_TYPE_PRESENCE_ARRIVAL,
{"child_name": child.name, "child_id": child_id, "count": count},
only_recipients={f"child:{child_id}"},
)
return True
def _outstanding_chore_count(self, child_id: str) -> int:
"""How many chores the child still owes today (what their card shows)."""
coordinator = getattr(self, "coordinator", None)
due = getattr(coordinator, "get_due_chores_for_child", None) if coordinator else None
if callable(due):
try:
return len(due(child_id))
except Exception: # noqa: BLE001
_LOGGER.debug("Due-chore lookup failed for %s", child_id, exc_info=True)
return 1 if self._has_outstanding_chores_today(child_id) else 0
async def set_presence_arrival_min_away(self, minutes: int) -> None:
self.storage.set_presence_arrival_min_away(minutes)
await self.storage.async_save()
# ------------------------------------------------------------------
# CRUD wrappers — persist + reload schedules as needed
# ------------------------------------------------------------------
@@ -768,10 +1204,27 @@ class NotificationCoordinator:
async def delete_parent(self, parent_id: str) -> None:
self.storage.delete_parent_recipient(parent_id)
# Their routes and custom-reminder slots go with them (#946).
self.storage.remove_notification_recipient(parent_id)
await self.storage.async_save()
await self.async_setup_schedules() # in case routes referenced this id
def _recipient_exists(self, recipient_id: str) -> bool:
"""True if ``recipient_id`` resolves to an existing child or parent recipient."""
if recipient_id.startswith("child:"):
child_id = recipient_id.split(":", 1)[1]
return bool(child_id) and self.storage.get_child(child_id) is not None
if recipient_id.startswith("parent:"):
return any(p.id == recipient_id for p in self.storage.get_parent_recipients())
return False
async def set_route(self, type_id: str, recipient_id: str, route) -> None:
# Reject unknown types/recipients (#947) — a stored route for an id
# that resolves to nobody is silently dead weight in the config.
if type_id not in NOTIFICATION_TYPES_BY_ID:
raise ValueError(f"Unknown notification type {type_id}")
if not self._recipient_exists(recipient_id):
raise ValueError(f"Unknown notification recipient {recipient_id}")
self.storage.set_notification_route(type_id, recipient_id, route)
await self.storage.async_save()
if NOTIFICATION_TYPES_BY_ID.get(type_id) and NOTIFICATION_TYPES_BY_ID[type_id].time_gated:
@@ -823,10 +1276,15 @@ class NotificationCoordinator:
for c in completions
if c.child_id == child_id and dt_util.as_local(c.completed_at).date() == today
}
# Birthday day off (#924): only mandatory chores are still owed.
coord = self.coordinator
birthday_off = coord is not None and coord.is_birthday_day_off(self.storage.get_child(child_id)) is True
for chore in chores:
if not chore.assigned_to or child_id not in chore.assigned_to:
continue
if chore.id in completed_today:
continue
if birthday_off and not getattr(chore, "mandatory", False):
continue
return True
return False
+238 -12
View File
@@ -57,8 +57,12 @@ class PointsMixin:
are skipped — who was active on a past day can't be reconstructed.
"""
req: set[str] = set()
child_obj = self.storage.get_child(child_id)
for d_str in last_week_dates:
day = date.fromisoformat(d_str)
# A birthday day off is never a required day (#924).
if self.is_birthday_day_off(child_obj, day):
continue
for chore in chores:
assigned = chore.assigned_to or []
if assigned and child_id not in assigned:
@@ -154,23 +158,188 @@ class PointsMixin:
await self.storage.async_save()
await self.async_refresh()
def _streak_breaks_after_gap(self, last_date_str: str, today: date) -> bool:
def _streak_breaks_after_gap(self, last_date_str: str, today: date, child: Child | None = None) -> bool:
"""True if a genuine (non-vacation) expected day was missed.
Walks each day strictly between the last completion and today; if any
is not a vacation day the child missed it and the streak breaks.
Days inside a vacation period are forgiven.
Days inside a vacation period are forgiven, and so are days one of the
child's streak-freeze tokens has already covered (#925), as is the
child's birthday when it's a day off (#924).
"""
return bool(self._unprotected_missed_days(last_date_str, today, child))
def _unprotected_missed_days(self, last_date_str: str, today: date, child: Child | None = None) -> list[date]:
"""Days strictly between the last completion and today that nothing covers.
A day is covered by a vacation period, a birthday day off (#924) or by
a streak-freeze token the child already spent on it. Anything left is a genuinely missed day.
"""
try:
last_date = date.fromisoformat(last_date_str)
except (TypeError, ValueError):
return False
return []
frozen = set(getattr(child, "streak_freeze_dates", None) or []) if child is not None else set()
missed = []
d = last_date + timedelta(days=1)
while d < today:
if not self.is_vacation_day(d):
return True
if not self.is_vacation_day(d) and d.isoformat() not in frozen and not self.is_birthday_day_off(child, d):
missed.append(d)
d += timedelta(days=1)
return False
return missed
# ── Streak freeze tokens (#925) ──────────────────────────────────────
def _gap_bridged_by_freeze(self, child: Child, last_date: date, effective_date: date) -> bool:
"""True when a streak freeze covered the gap before ``effective_date``.
Every day between the last completion and this one must be covered —
by a token, or by a vacation day alongside one — and at least one of
them by a token. A gap with no token in it keeps its old handling.
"""
frozen = set(getattr(child, "streak_freeze_dates", None) or [])
if not frozen or last_date >= effective_date - timedelta(days=1):
return False
used = False
d = last_date + timedelta(days=1)
while d < effective_date:
if d.isoformat() in frozen:
used = True
elif not self.is_vacation_day(d) and not self.is_birthday_day_off(child, d):
return False
d += timedelta(days=1)
return used
def _setting_int(self, key: str, default: int) -> int:
try:
return max(0, int(float(self.storage.get_setting(key, default))))
except (TypeError, ValueError):
return default
def streak_freeze_max(self) -> int:
"""How many streak-freeze tokens a child may hold; 0 turns the feature off."""
return self._setting_int("streak_freeze_max", 2)
def streak_freeze_earn_every(self) -> int:
"""Earn a token every N consecutive streak days; 0 means never."""
return self._setting_int("streak_freeze_earn_every", 7)
def _log_streak_freeze(self, child: Child, reason: str) -> None:
"""Record a token movement in the activity log.
Tokens are not points, so the entry carries 0 points; the reason is
what the activity feed shows (and translates). "Streak freeze" is on
the undo deny-list, so nobody can "undo" a zero-point row and expect
the token to come back.
"""
self.storage.add_points_transaction(
PointsTransaction(child_id=child.id, points=0, reason=reason, created_at=dt_util.now())
)
def _maybe_earn_streak_freeze(self, child: Child) -> bool:
"""Hand out a token when the streak lands on a multiple of earn_every.
Called only when the streak has just advanced. Records the streak
length that earned it (0 when the child was already at the cap), so an
undo of this very completion can take back exactly what it gave.
"""
cap = self.streak_freeze_max()
every = self.streak_freeze_earn_every()
streak = child.current_streak or 0
if cap <= 0 or every <= 0 or streak <= 0 or streak % every:
return False
if (child.streak_freezes or 0) >= cap:
child.streak_freeze_earned_at = 0
return False
child.streak_freezes = (child.streak_freezes or 0) + 1
child.streak_freeze_earned_at = streak
self._log_streak_freeze(child, f"Streak freeze earned ({streak} day streak!)")
_LOGGER.info("%s earned a streak freeze at a %d-day streak", child.name, streak)
return True
def _reverse_streak_freeze_earn(self, child: Child, streak_before: int) -> None:
"""Take back the token a now-undone completion earned, if it earned one."""
earned_at = getattr(child, "streak_freeze_earned_at", 0) or 0
if not earned_at or streak_before != earned_at or (child.current_streak or 0) >= earned_at:
return
child.streak_freeze_earned_at = 0
if (child.streak_freezes or 0) > 0:
child.streak_freezes -= 1
self._log_streak_freeze(child, "Streak freeze reversed")
async def async_adjust_streak_freezes(self, child_id: str, amount: int) -> int:
"""Grant (positive) or remove (negative) streak-freeze tokens.
A parent grant respects the cap like every other way of getting one;
removal floors at zero. Returns the child's new token count.
"""
child = self.get_child(child_id)
if not child:
raise ValueError(f"Child {child_id} not found")
if amount == 0:
raise ValueError("amount must not be 0")
cap = self.streak_freeze_max()
if cap <= 0 and amount > 0:
raise ValueError("Streak freezes are turned off (streak_freeze_max is 0)")
before = child.streak_freezes or 0
after = max(0, before + amount)
if amount > 0:
after = min(after, max(cap, before))
if after == before:
if amount > 0:
raise ValueError(f"{child.name} already holds the maximum of {cap} streak freezes")
return before
child.streak_freezes = after
delta = after - before
self._log_streak_freeze(child, f"Streak freezes adjusted ({delta:+d})")
self.storage.update_child(child)
await self.storage.async_save()
await self.async_refresh()
return after
async def _async_use_streak_freeze(self, child: Child, day: date) -> None:
"""Spend one token on ``day``: log it, tell the family, fire an event."""
child.streak_freezes -= 1
day_str = day.isoformat()
# Keep a month of covered days: enough for any gap the streak paths
# walk (an undo can move last_completion_date back), bounded so the
# child record can't grow without limit.
child.streak_freeze_dates = sorted(set(child.streak_freeze_dates or []) | {day_str})[-31:]
self._log_streak_freeze(child, f"Streak freeze used ({day_str})")
_LOGGER.info("Streak freeze used for %s on %s (%d left)", child.name, day_str, child.streak_freezes)
self.hass.bus.async_fire(
"taskmate_streak_freeze_used",
{
"child_id": child.id,
"child_name": child.name,
"date": day_str,
"streak": child.current_streak,
"freezes_left": child.streak_freezes,
"timestamp": dt_util.now().isoformat(),
},
)
if getattr(self, "notifications", None):
await self.notifications.fire(
"streak_freeze_used",
{
"child_name": child.name,
"child_id": child.id,
"date": day_str,
"streak": child.current_streak,
"freezes_left": child.streak_freezes,
},
)
def _gap_is_birthday_days_off(self, child, last_date: date, today: date) -> bool:
"""True when every day strictly between ``last_date`` and ``today`` was
the child's birthday day off — the only gap a completion bridges (#924)."""
d = last_date + timedelta(days=1)
if d >= today:
return False
while d < today:
if not self.is_birthday_day_off(child, d):
return False
d += timedelta(days=1)
return True
async def _async_check_streaks(self) -> None:
"""Check all children's streaks and reset/pause if they missed yesterday.
@@ -178,10 +347,17 @@ class PointsMixin:
Behaviour depends on streak_reset_mode setting:
- "reset" (default): streak goes back to 0 on missed day
- "pause": streak is preserved but not incremented until they complete again
Before either happens, each missed day spends one of the child's
streak-freeze tokens (#925) while they last — the streak then carries
on as if the day had been kept. A day already forgiven (vacation, the
child's own unavailability, a streak already frozen/paused) never
costs a token: there is nothing to protect.
"""
today = dt_util.now().date()
streak_mode = self.storage.get_setting("streak_reset_mode", "reset")
freezes_on = self.streak_freeze_max() > 0
children = self.storage.get_children()
changed = False
@@ -204,9 +380,20 @@ class PointsMixin:
# Streak is fine unless a genuine, non-vacation day was missed
# between the last completion and today.
if not self._streak_breaks_after_gap(last_date_str, today):
missed = self._unprotected_missed_days(last_date_str, today, child)
if not missed:
continue
# Spend a token per missed day while they last. Only a live,
# unpaused streak is at risk; a paused one resumes on its own.
if freezes_on and (child.current_streak or 0) > 0 and not getattr(child, "streak_paused", False):
while missed and (child.streak_freezes or 0) > 0:
await self._async_use_streak_freeze(child, missed.pop(0))
self.storage.update_child(child)
changed = True
if not missed:
continue
# They missed a (non-vacation) day
if (child.current_streak or 0) > 0:
# A streak already marked paused (e.g. frozen during an absence)
@@ -307,6 +494,14 @@ class PointsMixin:
"Points decay",
"Savings interest",
"Badge",
# Zero-point token movements (#925): nothing to reverse in points.
"Streak freeze",
# Wishlist (#932): taking points back out and removing a pledge are
# the undo for these; reversing a row alone would desync the wish.
"Wish savings",
"Wish refund",
"Wish pledge",
"Wish redeemed",
)
async def async_undo_transaction(self, transaction_id: str) -> None:
@@ -361,6 +556,8 @@ class PointsMixin:
self.storage.update_child(child)
self.storage.append_career_score_snapshot(child.id, dt_util.now().date().isoformat(), child.career_score)
self.storage.remove_points_transaction(transaction_id)
# An inspection's pass bonus: take the pass back with it (#996).
self._inspection_on_bonus_undone(transaction_id)
await self.storage.async_save()
await self.async_refresh()
@@ -667,6 +864,7 @@ class PointsMixin:
who was active, so the perfect-week path passes False).
"""
out: set[str] = set()
birthday_off = self.is_birthday_day_off(self.storage.get_child(child_id), day)
for chore in self.storage.get_chores():
if not getattr(chore, "enabled", True):
continue
@@ -676,7 +874,13 @@ class PointsMixin:
if child_id in getattr(chore, "disabled_for", []):
continue
mode = getattr(chore, "assignment_mode", "everyone")
if mode not in ("", "everyone"):
# Won at auction (#982): that day's occurrence is the winner's
# alone — due for them in any mode, and for nobody else.
winner = self.auction_winner(chore, day)
if winner:
if winner != child_id:
continue
elif mode not in ("", "everyone"):
if not include_rotation:
continue
if child_id not in self._compute_active_children(chore, day):
@@ -686,6 +890,9 @@ class PointsMixin:
# evening.
if int(getattr(chore, "weekly_target", 0) or 0) > 0:
continue
# Birthday day off (#924): only mandatory chores are still owed.
if not getattr(chore, "mandatory", False) and birthday_off:
continue
if not self._is_chore_scheduled_for_date(chore, day):
continue
out.add(chore.id)
@@ -731,8 +938,8 @@ class PointsMixin:
) -> int:
"""Award points to a child, update streak, and apply bonus systems.
Returns the total points awarded (base + weekend bonus), excluding
milestone bonuses (which are logged as separate transactions).
Returns the total points awarded (base + weekend + birthday bonus),
excluding milestone bonuses (which are logged as separate transactions).
If skip_streak is True, streak tracking is skipped (used for bonus
sub-task completions where the parent already counted).
@@ -754,7 +961,18 @@ class PointsMixin:
if effective_date.weekday() in (5, 6) and multiplier > 1.0:
weekend_bonus = round(points * (multiplier - 1.0))
total_points = points + weekend_bonus
# ── Birthday multiplier (#924) ──────────────────────────────────────
# Applied after the weekend bonus, so a birthday doubles whatever the
# chore would otherwise have paid that day.
# (Named "celebration_*" rather than after the birthday so CodeQL's
# personal-data heuristics don't read a points multiplier as PII.)
celebration_bonus = 0
if self.is_birthday(child, effective_date):
celebration_multiplier = self.birthday_multiplier()
if celebration_multiplier > 1.0:
celebration_bonus = round((points + weekend_bonus) * (celebration_multiplier - 1.0))
total_points = points + weekend_bonus + celebration_bonus
child.points += total_points
child.total_points_earned += total_points
child.total_chores_completed += 1
@@ -803,7 +1021,12 @@ class PointsMixin:
try:
last_date = date.fromisoformat(last_date_str)
yesterday = effective_date - timedelta(days=1)
if last_date == yesterday:
# A birthday day off in between doesn't break the run (#924).
if (
last_date == yesterday
or self._gap_bridged_by_freeze(child, last_date, effective_date)
or (last_date < yesterday and self._gap_is_birthday_days_off(child, last_date, effective_date))
):
child.current_streak = streak_before + 1
child.streak_paused = False
elif streak_mode == "pause" or streak_paused:
@@ -827,6 +1050,9 @@ class PointsMixin:
if child.current_streak > (child.best_streak or 0):
child.best_streak = child.current_streak
if child.current_streak > streak_before:
self._maybe_earn_streak_freeze(child)
self.hass.bus.async_fire(
"taskmate_streak_updated",
{
+60 -4
View File
@@ -8,6 +8,7 @@ from typing import TYPE_CHECKING
from homeassistant.util import dt as dt_util
from .coord_rejections import clean_reject_reason
from .models import Child, PointsTransaction, PoolAllocation, Reward, RewardClaim
from .timewindow import has_window, is_within_window
@@ -60,6 +61,10 @@ class RewardsMixin:
pool has since dipped below the cost — it is still not the claimer's
wallet that is on the hook for it.
"""
# A wishlist redemption (#932) is paid from the wish's own savings,
# which left the child's balance when they were moved in.
if getattr(claim, "wish_id", ""):
return True
reward = self.get_reward(claim.reward_id)
if not reward:
return False
@@ -94,8 +99,9 @@ class RewardsMixin:
pool_enabled: bool = False,
quantity: int | None = None,
expires_at: str | None = None,
refresh: bool = True,
) -> Reward:
"""Add a new reward."""
"""Add a new reward. ``refresh=False`` defers the refresh to a batch."""
reward = Reward(
name=name,
cost=cost,
@@ -109,7 +115,8 @@ class RewardsMixin:
)
self.storage.add_reward(reward)
await self.storage.async_save()
await self.async_refresh()
if refresh:
await self.async_refresh()
return reward
async def async_update_reward(self, reward: Reward) -> None:
@@ -129,6 +136,9 @@ class RewardsMixin:
has invalidated is cancelled with it.
"""
old = self.get_reward(reward.id)
# A streak freeze is bought by one child for themselves (#925).
if getattr(reward, "streak_freeze", False):
reward.is_jackpot = False
# Jackpots are always pool-mode (#552); keep stored data consistent.
if reward.is_jackpot:
reward.pool_enabled = True
@@ -401,6 +411,8 @@ class RewardsMixin:
blocking = [c for c in own_pending if c.reward_id == reward_id]
if blocking:
raise ValueError(f"A claim for '{reward.name}' is already waiting for approval")
if getattr(reward, "streak_freeze", False):
self._require_streak_freeze_room(child, pending_claims=pending)
if len(own_pending) >= _MAX_PENDING_CLAIMS_PER_CHILD:
raise ValueError("Too many reward claims are already waiting for approval")
@@ -438,6 +450,27 @@ class RewardsMixin:
return reward, child
def _require_streak_freeze_room(self, child: Child, pending_claims: list | None = None) -> None:
"""Refuse a streak-freeze purchase the child could not hold (#925).
Counts tokens already held plus streak-freeze claims still waiting for
approval, so a queue of claims can't push the child past the cap.
Pass ``pending_claims=None`` at approval time: the claim being approved
is itself pending, and only the tokens actually held matter then.
"""
cap = self.streak_freeze_max()
if cap <= 0:
raise ValueError("Streak freezes are turned off")
queued = 0
for claim in pending_claims or []:
if claim.child_id != child.id:
continue
queued_reward = self.get_reward(claim.reward_id)
if queued_reward and getattr(queued_reward, "streak_freeze", False):
queued += 1
if (child.streak_freezes or 0) + queued >= cap:
raise ValueError(f"{child.name} already has the maximum of {cap} streak freezes")
async def async_claim_reward(self, reward_id: str, child_id: str) -> RewardClaim:
"""Child claims a reward — creates a pending claim awaiting parent approval.
@@ -523,6 +556,11 @@ class RewardsMixin:
wallet-mode path deducts directly from child.points.
"""
claims = self.storage.get_reward_claims()
# A funded wish's claim (#932) has no Reward behind it.
wish_claim = next((c for c in claims if c.id == claim_id and c.wish_id and not c.approved), None)
if wish_claim is not None:
await self._async_approve_wish_claim(wish_claim)
return
for claim in claims:
if claim.id == claim_id:
if claim.approved:
@@ -551,6 +589,10 @@ class RewardsMixin:
# Spending cap: block approval if it would exceed the per-period budget.
self._enforce_spend_cap(claim.child_id, effective_cost)
# A streak freeze can't be handed over past the cap, whatever
# has happened since the claim was made (#925).
if getattr(reward, "streak_freeze", False):
self._require_streak_freeze_room(child)
# Detect pool mode: either a direct allocation, or a filled jackpot pool.
pool_alloc = self.storage.get_pool_allocation(claim.child_id, claim.reward_id)
@@ -596,6 +638,10 @@ class RewardsMixin:
# still have earmarked for this reward's pool.
self._refund_all_pool_allocations(reward, "Pool refund (reward sold out)")
if getattr(reward, "streak_freeze", False):
child.streak_freezes = (child.streak_freezes or 0) + 1
self.storage.update_child(child)
claim.approved = True
claim.approved_at = dt_util.now()
claim.approved_cost = effective_cost
@@ -649,7 +695,7 @@ class RewardsMixin:
return
_LOGGER.warning("Reward claim %s not found for approval", claim_id)
async def async_reject_reward(self, claim_id: str) -> None:
async def async_reject_reward(self, claim_id: str, reason: str = "") -> None:
"""Reject a *pending* reward claim — no refund needed as points were never deducted.
That "no refund needed" only holds while the claim is pending. Two
@@ -658,17 +704,25 @@ class RewardsMixin:
longer matches storage. Deleting an approved claim there would erase
the purchase from history while its points, its stock and any timed
unlock stayed spent, so the stale review is refused instead.
``reason`` (#976) is the parent's optional "why", shown to the child.
"""
reason = clean_reject_reason(reason)
claim = next((c for c in self.storage.get_reward_claims() if c.id == claim_id), None)
if claim is not None and claim.approved:
reward = self.get_reward(claim.reward_id)
name = reward.name if reward else "This reward"
raise ValueError(f"'{name}' has already been approved and can no longer be rejected")
if claim is not None and claim.wish_id:
await self._async_reject_wish_claim(claim, reason)
return
self.storage.remove_reward_claim(claim_id)
reward = self.get_reward(claim.reward_id) if claim else None
if claim:
self._record_rejection("reward", claim.child_id, claim.reward_id, getattr(reward, "name", ""), reason)
await self.storage.async_save()
await self.async_refresh()
if claim:
reward = self.get_reward(claim.reward_id)
child = self.get_child(claim.child_id)
self.hass.bus.async_fire(
"taskmate_reward_rejected",
@@ -678,12 +732,14 @@ class RewardsMixin:
"reward_id": claim.reward_id,
"reward_name": getattr(reward, "name", ""),
"claim_id": claim.id,
"reason": reason,
"timestamp": dt_util.now().isoformat(),
},
)
# Dismiss the mobile approval push for this reviewed claim.
if getattr(self, "notifications", None):
await self.notifications.clear_approval("pending_reward_claim", claim_id)
await self._async_notify_rejected("reward", child, getattr(reward, "name", ""), reason)
async def async_allocate_points_to_pool(self, child_id: str, reward_id: str, points: int) -> PoolAllocation:
"""Move `points` from a child's spendable balance into a reward pool.
+11 -3
View File
@@ -54,8 +54,11 @@ class TemplatesMixin:
return dict(tpl)
return self.storage.get_custom_template(template_id)
async def async_apply_template(self, chores: list[dict]) -> list[str]:
"""Create chores from a template's chore definitions. Returns created IDs."""
async def async_apply_template(self, chores: list[dict], refresh: bool = True) -> list[str]:
"""Create chores from a template's chore definitions. Returns created IDs.
``refresh=False`` defers the refresh to a batch (the setup wizard, #980).
"""
if not chores:
raise ValueError("Cannot apply template with no chores")
created_ids = []
@@ -90,10 +93,15 @@ class TemplatesMixin:
timed_rate_minutes=chore_def.get("timed_rate_minutes", 5),
timed_max_daily_minutes=chore_def.get("timed_max_daily_minutes", 0),
)
# The setup wizard's suggestions carry an icon (#980); template
# packs don't, and keep the chore's default.
if chore_def.get("icon"):
chore.icon = chore_def["icon"]
self.storage.add_chore(chore)
created_ids.append(chore.id)
await self.storage.async_save()
await self.async_refresh()
if refresh:
await self.async_refresh()
return created_ids
async def async_save_template_from_chores(self, chore_ids: list[str], name: str, icon: str) -> str:
+9 -2
View File
@@ -8,6 +8,7 @@ from typing import TYPE_CHECKING
from homeassistant.util import dt as dt_util
from .chore_undo import undo_window_seconds
from .models import ChoreCompletion, TimedSession
if TYPE_CHECKING:
@@ -130,6 +131,8 @@ class TimedMixin:
points_awarded=pts if not chore.requires_approval else 0,
submitted_points=pts,
timed_duration_seconds=total_seconds,
# The child's own stopped session: theirs to undo in the window (#918).
child_undo_allowed=undo_window_seconds(self.storage) > 0,
)
if not chore.requires_approval:
@@ -168,12 +171,16 @@ class TimedMixin:
assigned = getattr(chore, "assigned_to", []) or []
if assigned and child_id not in assigned:
return False
# Calendar moves/removals (#977) outrank the weekday list for today.
today = dt_util.as_local(dt_util.now()).date()
override = self.occurrence_override(chore, today)
if override is False:
return False
if getattr(chore, "schedule_mode", "specific_days") == "specific_days":
due_days = getattr(chore, "due_days", []) or []
if due_days:
today = dt_util.as_local(dt_util.now()).date()
dow = ("monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday")[today.weekday()]
if dow not in due_days:
if dow not in due_days and override is not True:
return False
return True
+248 -7
View File
@@ -4,12 +4,14 @@ from __future__ import annotations
import logging
import random
import re
from collections.abc import Callable
from contextlib import contextmanager
from datetime import date, datetime, timedelta
from typing import Any
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.event import async_track_time_change
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.util import dt as dt_util
@@ -17,30 +19,63 @@ from homeassistant.util import dt as dt_util
from . import photos
from .const import DOMAIN
from .coord_assignments import AssignmentsMixin
from .coord_auctions import AuctionsMixin
from .coord_avatars import AvatarsMixin
from .coord_badges import BadgeCoordinator
from .coord_birthdays import BirthdaysMixin
from .coord_bounties import BountiesMixin
from .coord_calendar import CalendarMixin
from .coord_challenges import ChallengesMixin
from .coord_chores import ChoresMixin
from .coord_guests import GuestsMixin
from .coord_inspections import InspectionsMixin
from .coord_mandatory import MandatoryMixin
from .coord_notifications import NotificationCoordinator
from .coord_points import PointsMixin
from .coord_quests import QuestsMixin
from .coord_recaps import RecapsMixin
from .coord_rejections import RejectionsMixin
from .coord_reports import ReportsMixin
from .coord_rewards import RewardsMixin
from .coord_roulette import RouletteMixin
from .coord_scheduled import ScheduledChangesMixin
from .coord_setup import SetupWizardMixin
from .coord_sounds import SoundsMixin
from .coord_tags import TagsMixin
from .coord_teamwork import TeamworkMixin
from .coord_templates import TemplatesMixin
from .coord_timed import TimedMixin
from .coord_tts import ReadAloudMixin
from .coord_unlocks import UnlocksMixin
from .models import Child
from .coord_wishlist import WishlistMixin
from .models import Child, Chore
from .storage import TaskMateStorage
_LOGGER = logging.getLogger(__name__)
# Shape of generate_id() — how a child id is recognised in a unique id (#946).
_CHILD_ID_RE = re.compile(r"[0-9a-f]{16}")
# A complete/claim button's unique id minus its ``<entry_id>_`` prefix, with
# both ids in generate_id() shape (#960). Anything else is never swept.
_BUTTON_UID_RE = re.compile(r"([0-9a-f]{16})_([0-9a-f]{16})_(complete|claim)")
def chore_has_button(chore: Chore, child_id: str) -> bool:
"""Whether ``child_id`` gets a complete button for ``chore``."""
if getattr(chore, "assignment_mode", "everyone") == "unassigned":
return False
return not chore.assigned_to or child_id in chore.assigned_to
def button_keys(children: list, chores: list, rewards: list) -> set[str]:
"""``<child>_<item>_<kind>`` keys of every button that should exist."""
keys: set[str] = set()
for child in children:
keys.update(f"{child.id}_{chore.id}_complete" for chore in chores if chore_has_button(chore, child.id))
keys.update(f"{child.id}_{reward.id}_claim" for reward in rewards)
return keys
class TaskMateCoordinator(
ChoresMixin,
@@ -61,6 +96,16 @@ class TaskMateCoordinator(
GuestsMixin,
UnlocksMixin,
SoundsMixin,
BirthdaysMixin,
TagsMixin,
TeamworkMixin,
WishlistMixin,
BountiesMixin,
AuctionsMixin,
RecapsMixin,
RejectionsMixin,
SetupWizardMixin,
InspectionsMixin,
DataUpdateCoordinator,
):
"""Coordinator to manage TaskMate data."""
@@ -79,7 +124,9 @@ class TaskMateCoordinator(
self.entry_id = entry_id
self._unsub_midnight: Callable[[], None] | None = None
self._unsub_prune: Callable[[], None] | None = None
self._unsub_daily_progress: Callable[[], None] | None = None
self._unsub_availability: Callable[[], None] | None = None
self._unsub_tag_scanned: Callable[[], None] | None = None
self._tracked_availability_entities: set[str] = set()
self._tracked_visibility_entities: set[str] = set()
# Bumped whenever a tracked external entity (child availability, chore
@@ -97,6 +144,8 @@ class TaskMateCoordinator(
self._data_snapshot_cache: tuple[int, dict[str, Any]] | None = None
# Scheduled reverts for timed unlock rewards (#678).
self._unlock_timers: list = []
# Wakes for the next chore-auction closing time or reminder (#982).
self._unsub_auction_timer: Callable[[], None] | None = None
def difficulty_multiplier(self, tier: str) -> float:
"""Return the points multiplier for a difficulty tier.
@@ -114,6 +163,24 @@ class TaskMateCoordinator(
except (ValueError, TypeError):
return default
def quality_rating_enabled(self) -> bool:
"""Whether parents may rate approvals 1-3 stars (#927). Off by default."""
v = self.storage.get_setting("quality_rating_enabled", False)
return v is True or str(v).lower() == "true"
def quality_rating_multipliers(self) -> dict[int, float]:
"""The points multiplier for each star rating, from settings (#927)."""
from .const import DEFAULT_QUALITY_RATING_MULTIPLIERS
out: dict[int, float] = {}
for rating, default in DEFAULT_QUALITY_RATING_MULTIPLIERS.items():
try:
value = float(self.storage.get_setting(f"quality_rating_multiplier_{rating}", default))
except (ValueError, TypeError):
value = default
out[rating] = max(0.0, value)
return out
def effective_chore_points(self, chore) -> int:
"""Base chore points scaled by its difficulty multiplier (never negative)."""
from .const import DEFAULT_DIFFICULTY
@@ -342,11 +409,26 @@ class TaskMateCoordinator(
await self.async_apply_due_scheduled_changes(refresh=False)
# A restart mid-unlock must never strand the TV on (#678).
await self.async_resume_unlocks()
# Birthday mode (#924): HA may have been off at midnight on the day.
await self.async_check_birthdays(refresh=False)
# Bounty board (#931): claims that ran out and bounties that expired
# while HA was off.
await self.async_sweep_bounties(refresh=False)
# Chore auctions (#982): settle any whose bidding closed while HA was
# off, then arm the timer for the next closing time. Auctions left
# behind by a chore deleted before they followed it go first (#999).
await self.async_remove_orphaned_auctions()
await self.async_sweep_auctions(refresh=False)
self._arm_auction_timer()
await self.async_refresh()
# Schedule midnight streak check at 00:00:05
self._unsub_midnight = async_track_time_change(
self.hass, self._async_midnight_streak_check, hour=0, minute=0, second=5
)
# Record each child's done/total for the day just before it ends (#966)
self._unsub_daily_progress = async_track_time_change(
self.hass, self._async_daily_progress_tick, hour=23, minute=59, second=0
)
# Schedule daily history pruning at 00:01:00
self._unsub_prune = async_track_time_change(self.hass, self._async_scheduled_prune, hour=0, minute=1, second=0)
# Re-evaluate availability-aware chore assignments when any HA entity
@@ -354,6 +436,9 @@ class TaskMateCoordinator(
# relevant flips trigger a recompute.
self._refresh_tracked_availability_entities()
self._unsub_availability = self.hass.bus.async_listen("state_changed", self._availability_state_changed)
# NFC / QR tag completion (#923): a scanned tag linked to a chore
# completes it for the scanning child.
self._unsub_tag_scanned = self.hass.bus.async_listen("tag_scanned", self._tag_scanned)
# Surprise-bonus daily roll at 16:00 (opt-in; no-op unless enabled)
self._unsub_surprise = async_track_time_change(
self.hass, self._async_surprise_bonus_check, hour=16, minute=0, second=0
@@ -366,6 +451,12 @@ class TaskMateCoordinator(
# Mandatory-chore period-end detection (#532)
self.arm_mandatory_schedules()
await self.async_catchup_mandatory_misses()
# Recaps (#929): build any recap whose period ended while HA was off,
# then arm the midnight build and the daily announcement.
await self.async_start_recaps()
# Surprise inspections (#981): close / remind what came due while HA
# was off, catch up on a missed daily pick, then arm the timers.
await self.async_start_inspections()
@callback
def _async_weekly_digest_check(self, now: datetime) -> None:
@@ -709,15 +800,32 @@ class TaskMateCoordinator(
"""Shutdown the coordinator and clean up listeners."""
self.cancel_unlock_timers()
self.notifications.cancel_schedules()
for attr in ("_unsub_midnight", "_unsub_prune", "_unsub_availability", "_unsub_surprise", "_unsub_weekly"):
if unsub := getattr(self, attr):
self.notifications.cancel_presence_tracking()
for attr in (
"_unsub_midnight",
"_unsub_prune",
"_unsub_daily_progress",
"_unsub_availability",
"_unsub_tag_scanned",
"_unsub_surprise",
"_unsub_weekly",
"_unsub_auction_timer",
):
if unsub := getattr(self, attr, None):
unsub()
setattr(self, attr, None)
self.disarm_mandatory_schedules()
self.disarm_recap_schedules()
self.disarm_inspection_schedules()
# Flush any pending debounced save so an entry unload/reload can't drop
# the last mutation (PERF-3).
await self.storage.async_save_now()
@callback
def _async_daily_progress_tick(self, now: datetime) -> None:
"""Scheduled callback at 23:59 to snapshot the day's chore progress."""
self.hass.async_create_task(self.async_record_daily_progress(now))
@callback
def _async_midnight_streak_check(self, now: datetime) -> None:
"""Scheduled callback at midnight to run all daily maintenance."""
@@ -735,6 +843,8 @@ class TaskMateCoordinator(
self.async_prune_roulette_state,
self.async_archive_expired_guests,
self._async_check_streaks,
# Birthday mode (#924): badge + celebration, once per child per day.
self.async_check_birthdays,
self._async_expire_one_shot_chores,
self._async_expire_dated_chores,
self._async_expire_deadline_chores,
@@ -750,6 +860,10 @@ class TaskMateCoordinator(
self.async_detect_anytime_mandatory_misses,
self.async_prune_orphan_misses,
self._async_sweep_orphan_photos,
# Finished bounties leave with the completion history (#931).
self.async_prune_bounties,
# Finished auctions likewise (#982).
self.async_prune_auctions,
]
# Check for perfect week bonus every Monday at midnight
if now.weekday() == 0:
@@ -761,6 +875,9 @@ class TaskMateCoordinator(
_LOGGER.exception("Midnight maintenance step %s failed", step.__name__)
# Prune all-chores-done daily flags older than today
self.storage.prune_all_done_flags(dt_util.now().date().isoformat())
# Teamwork joins are per occurrence (#928): yesterday's half-formed
# teams are dead, so drop them rather than carry them in the store.
self.storage.prune_team_joins(dt_util.as_local(dt_util.now()).date().isoformat())
await self.storage.async_save()
async def _async_sweep_orphan_photos(self) -> None:
@@ -796,7 +913,16 @@ class TaskMateCoordinator(
# we are already inside the refresh, and the snapshot below picks the
# change up in this same tick.
await self._async_expire_deadline_chores(refresh=False)
# Bounty claims last hours, not days (#931): lapse and expire them on
# this tick, and warn a claimer whose time is nearly up.
await self.async_sweep_bounties(refresh=False)
# Auction closings (#982) have their own timer; this is the backstop.
await self.async_sweep_auctions(refresh=False)
self._refresh_tracked_availability_entities()
# Presence-aware reminders (#926): follow child presence-entity edits.
# A no-op unless the child -> entity map actually changed.
if notifications := getattr(self, "notifications", None):
notifications.sync_presence_tracking()
version = self.storage.data_version
cached = getattr(self, "_data_snapshot_cache", None)
if cached is not None and cached[0] == version:
@@ -822,6 +948,7 @@ class TaskMateCoordinator(
"bonuses": self.storage.get_bonuses(),
"pool_allocations": self.storage.get_pool_allocations(),
"timed_sessions": self.storage.get_timed_sessions(),
"bounties": self.storage.get_bounties(),
}
# Child operations
@@ -834,8 +961,16 @@ class TaskMateCoordinator(
unavailability_entity: str = "",
pause_streak_when_unavailable: bool = False,
linked_user_id: str = "",
birthday: str = "",
presence_entity: str = "",
age_group: str = "",
refresh: bool = True,
) -> Child:
"""Add a new child."""
"""Add a new child.
``refresh=False`` lets a batch (the setup wizard, #980) add several
children and refresh once at the end.
"""
child = Child(
name=name,
avatar=avatar,
@@ -844,17 +979,25 @@ class TaskMateCoordinator(
unavailability_entity=unavailability_entity,
pause_streak_when_unavailable=pause_streak_when_unavailable,
linked_user_id=linked_user_id,
birthday=birthday,
presence_entity=presence_entity,
age_group=age_group,
)
self.storage.add_child(child)
await self.storage.async_save()
await self.async_refresh()
# A birthday entered on the day still gets its badge and celebration.
await self.async_check_birthdays(refresh=False)
if refresh:
await self.async_refresh()
return child
async def async_update_child(self, child: Child) -> None:
async def async_update_child(self, child: Child, refresh: bool = True) -> None:
"""Update a child."""
self.storage.update_child(child)
await self.storage.async_save()
await self.async_refresh()
await self.async_check_birthdays(refresh=False)
if refresh:
await self.async_refresh()
async def async_remove_child(self, child_id: str) -> None:
"""Remove a child and all associated data."""
@@ -865,12 +1008,27 @@ class TaskMateCoordinator(
self.storage.remove_last_completed_for_child(child_id)
self.storage.remove_pool_allocations_for_child(child_id)
self.storage.remove_career_score_history_for_child(child_id)
self.storage.remove_daily_progress_for_child(child_id)
self.storage.remove_quest_progress_for_child(child_id)
self.storage.remove_challenge_progress_for_child(child_id)
# Drop pending swap requests either side of this child (#785) — a
# handover to or from a deleted child can never complete, and the
# approval queue would render them as "?".
self.storage.remove_swap_requests_for_child(child_id)
# And out of any teamwork chore they had joined (#928), or the team
# would count a member who can never be paid.
self.storage.remove_team_joins_for_child(child_id)
# Their wishes (#932) go too — and the pictures stored for them.
await self._async_remove_wishes_for_child(child_id)
# Free any bounty they had claimed and take them off eligibility (#931).
self.remove_child_from_bounties(child_id)
# And their bids and auction eligibility (#982).
self.remove_child_from_auctions(child_id)
# Their missed-mandatory reviews, timed sessions and notification
# routes (#946) — nothing else would ever clear them.
self.storage.remove_mandatory_misses_for_child(child_id)
self.storage.remove_timed_sessions_for_child(child_id)
routes_changed = self.storage.remove_notification_recipient(f"child:{child_id}")
# Remove child from chore assigned_to lists, and clear any approved swap
# override that pointed at them so the chore isn't left assigned to a
# child who no longer exists.
@@ -897,8 +1055,91 @@ class TaskMateCoordinator(
chore.assignment_current_child_id = daily.get(chore.id, "")
self.storage.update_chore(chore)
await self.storage.async_save()
self._remove_child_entities(child_id)
if routes_changed:
# A bedtime/birthday route arms a per-child timer.
await self.notifications.async_setup_schedules()
await self.async_refresh()
def _child_entity_entries(self, child_ids: set[str] | None = None, *, orphans: bool = False) -> list:
"""Registry entries of per-child entities (#946).
Per-child unique ids are ``<entry_id>_<child_id>_...``. With
``orphans`` set, returns entries whose child id (a generated 16-hex id)
no longer belongs to any child; otherwise those for ``child_ids``.
"""
registry = er.async_get(self.hass)
entry_id = self.storage.entry_id
prefix = f"{entry_id}_"
known = {c.id for c in self.storage.get_children()}
found = []
for entry in er.async_entries_for_config_entry(registry, entry_id):
uid = entry.unique_id or ""
if not uid.startswith(prefix):
continue
rest = uid[len(prefix) :]
if orphans:
cid = rest.split("_", 1)[0]
if "_" in rest and _CHILD_ID_RE.fullmatch(cid) and cid not in known:
found.append(entry)
elif any(rest.startswith(f"{cid}_") for cid in child_ids or ()):
found.append(entry)
return found
def _remove_child_entities(self, child_id: str) -> None:
"""Drop a deleted child's entities from the entity registry (#946)."""
registry = er.async_get(self.hass)
for entry in self._child_entity_entries({child_id}):
registry.async_remove(entry.entity_id)
def async_prune_orphan_child_entities(self) -> int:
"""Remove registry entries left by children deleted before #946."""
registry = er.async_get(self.hass)
stale = self._child_entity_entries(orphans=True)
for entry in stale:
registry.async_remove(entry.entity_id)
if stale:
_LOGGER.info("Removed %d entities left behind by deleted children", len(stale))
return len(stale)
def remove_button_entities(self, keys: set[str]) -> None:
"""Drop buttons whose chore/reward or assignment went (#960).
``keys`` are ``<child>_<item>_<kind>`` as the button platform tracks
them; only exact ``<entry_id>_<key>`` unique ids are removed.
"""
if not keys:
return
registry = er.async_get(self.hass)
wanted = {f"{self.storage.entry_id}_{key}" for key in keys}
for entry in er.async_entries_for_config_entry(registry, self.storage.entry_id):
if entry.domain == "button" and entry.unique_id in wanted:
registry.async_remove(entry.entity_id)
def async_prune_orphan_button_entities(self) -> int:
"""Remove buttons left by chores/rewards deleted before #960.
Also catches a child's button for a chore they were since unassigned
from. Only unique ids that parse as a generated-id button are judged.
"""
registry = er.async_get(self.hass)
entry_id = self.storage.entry_id
prefix = f"{entry_id}_"
live = button_keys(self.storage.get_children(), self.storage.get_chores(), self.storage.get_rewards())
stale = [
entry
for entry in er.async_entries_for_config_entry(registry, entry_id)
if entry.domain == "button"
and (entry.unique_id or "").startswith(prefix)
and _BUTTON_UID_RE.fullmatch(entry.unique_id[len(prefix) :])
and entry.unique_id[len(prefix) :] not in live
]
for entry in stale:
registry.async_remove(entry.entity_id)
if stale:
_LOGGER.info("Removed %d buttons left behind by deleted chores or rewards", len(stale))
return len(stale)
def get_child(self, child_id: str) -> Child | None:
"""Get a child by ID."""
return self.storage.get_child(child_id)
+5
View File
@@ -49,6 +49,11 @@ CARDS: Final = [
"taskmate-calendar-card.js",
"taskmate-photo-gallery-card.js",
"taskmate-family-goal-card.js",
"taskmate-wishlist-card.js",
"taskmate-kiosk-card.js",
"taskmate-bounty-card.js",
"taskmate-auction-card.js",
"taskmate-recap-card.js",
]
# Cards that USED to ship but were removed. Their files no longer exist, so any
+2 -2
View File
@@ -105,9 +105,9 @@ def build_chore_events(coordinator, start_day: date, end_day: date) -> list[dict
for chore in chores:
if not _chore_applies_to_child(coordinator, chore, child.id, day):
continue
window = coordinator._time_category_window(getattr(chore, "time_category", "anytime"), day)
window = coordinator._chore_event_window(chore, day)
summary = f"{chore.name} — {child.name}"
desc = _chore_description(chore)
desc = _chore_description(chore, coordinator.auction_price_for(chore, child.id, day))
if window is None:
events.append(
{
+1 -1
View File
@@ -17,5 +17,5 @@
"iot_class": "calculated",
"issue_tracker": "https://github.com/tempus2016/taskmate/issues",
"requirements": [],
"version": "5.8.0"
"version": "6.0.0"
}
+555 -3
View File
@@ -5,9 +5,11 @@ from __future__ import annotations
import logging
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from datetime import date, datetime, timezone
from typing import Any
from .const import AGE_GROUPS, AUCTION_STATUSES, BOUNTY_STATUSES, MAX_CHORE_TAGS, TAG_ID_MAX_LENGTH
_LOGGER = logging.getLogger(__name__)
@@ -54,6 +56,17 @@ def parse_datetime(value: str | datetime | None) -> datetime | None:
return None
def parse_quality_rating(value: Any) -> int:
"""Parse a stored 1-3 star quality rating (#927); anything else is 0 (unrated)."""
if value is None or isinstance(value, bool):
return 0
try:
rating = int(value)
except (ValueError, TypeError, OverflowError):
return 0
return rating if 1 <= rating <= 3 else 0
def parse_optional_points(value: Any) -> int | None:
"""Parse a stored points figure, or None for "not recorded".
@@ -88,6 +101,24 @@ def format_datetime(dt: datetime | None) -> str | None:
return utc_dt.isoformat().replace("+00:00", "Z")
def normalize_tag_ids(raw: Any) -> list[str]:
"""Clean a chore's linked HA tag ids (#923).
Ids are compared exactly against ``tag_scanned`` events, so surrounding
whitespace (easy to paste in with a free-text id) would silently never
match. Blanks and duplicates are dropped, order is kept, and the list is
capped so a crafted import can't bloat the store.
"""
if not isinstance(raw, (list, tuple)):
return []
out: list[str] = []
for item in raw:
text = str(item or "").strip()[:TAG_ID_MAX_LENGTH]
if text and text not in out:
out.append(text)
return out[:MAX_CHORE_TAGS]
def optional_float(value: Any) -> float | None:
"""Coerce a stored/user value to a float, or None when it isn't set.
@@ -166,6 +197,11 @@ class BonusSubTask:
}
def normalize_age_group(value: Any) -> str:
"""A stored age group (#980): one of AGE_GROUPS, anything else is none ("")."""
return value if isinstance(value, str) and value in AGE_GROUPS else ""
@dataclass
class Child:
"""Represents a child."""
@@ -182,6 +218,14 @@ class Child:
last_completion_date: str | None = None # ISO date string of last chore completion (for streak tracking)
streak_paused: bool = False # True if streak is paused due to missed day (pause mode)
streak_milestones_achieved: list[int] = field(default_factory=list)
# Streak freeze tokens (#925): each one covers one missed day so the streak
# survives it. `streak_freeze_dates` are the ISO days a token has covered
# (only the ones after the last completion matter, so it stays short), and
# `streak_freeze_earned_at` is the streak length that last earned a token,
# so undoing that day's completion can take the token back.
streak_freezes: int = 0
streak_freeze_dates: list[str] = field(default_factory=list)
streak_freeze_earned_at: int = 0
awarded_perfect_weeks: list[str] = field(default_factory=list)
availability_entity: str = "" # HA entity id; empty = always available
availability_inverted: bool = False # When True, _AVAILABLE_STATES means UNAVAILABLE
@@ -193,11 +237,17 @@ class Child:
linked_user_id: str = "" # HA user id; when set, only that user (or an admin) may self-serve as this child
quiet_hours_start: str = "" # "HH:MM" — start of do-not-disturb window; empty = no quiet hours
quiet_hours_end: str = "" # "HH:MM" — end of do-not-disturb window; start>end means overnight
presence_entity: str = "" # person./device_tracker.; while not home, reminders wait for arrival (#926)
level: int = 1 # cached XP level (derived from total_points_earned)
# Guest profiles (#690): a visiting cousin gets a temporary child that
# expires on its own and stays out of the family leaderboard.
is_guest: bool = False
guest_expires_on: str = "" # ISO date; profile auto-archives the day after
# Birthday mode (#924): "YYYY-MM-DD", or "MM-DD" when no age should show.
birthday: str = ""
# Setup wizard age group (#980): one of AGE_GROUPS, or "" when none was
# picked (every child that predates the wizard). Only steers suggestions.
age_group: str = ""
id: str = field(default_factory=generate_id)
@classmethod
@@ -216,9 +266,14 @@ class Child:
last_completion_date=data.get("last_completion_date"),
streak_paused=data.get("streak_paused", False),
streak_milestones_achieved=list(data.get("streak_milestones_achieved", [])),
streak_freezes=max(0, int(data.get("streak_freezes", 0) or 0)),
streak_freeze_dates=[str(d) for d in (data.get("streak_freeze_dates") or []) if d],
streak_freeze_earned_at=max(0, int(data.get("streak_freeze_earned_at", 0) or 0)),
awarded_perfect_weeks=list(data.get("awarded_perfect_weeks", [])),
is_guest=bool(data.get("is_guest", False)),
guest_expires_on=str(data.get("guest_expires_on", "") or ""),
birthday=str(data.get("birthday", "") or ""),
age_group=normalize_age_group(data.get("age_group")),
availability_entity=data.get("availability_entity", ""),
availability_inverted=data.get("availability_inverted", False),
unavailability_entity=data.get("unavailability_entity", ""),
@@ -229,6 +284,7 @@ class Child:
linked_user_id=data.get("linked_user_id", ""),
quiet_hours_start=data.get("quiet_hours_start", ""),
quiet_hours_end=data.get("quiet_hours_end", ""),
presence_entity=str(data.get("presence_entity", "") or ""),
level=int(data.get("level", 1) or 1),
id=data.get("id") or generate_id(),
)
@@ -248,9 +304,14 @@ class Child:
"last_completion_date": self.last_completion_date,
"streak_paused": self.streak_paused,
"streak_milestones_achieved": self.streak_milestones_achieved,
"streak_freezes": self.streak_freezes,
"streak_freeze_dates": list(self.streak_freeze_dates),
"streak_freeze_earned_at": self.streak_freeze_earned_at,
"awarded_perfect_weeks": self.awarded_perfect_weeks,
"is_guest": self.is_guest,
"guest_expires_on": self.guest_expires_on,
"birthday": self.birthday,
"age_group": self.age_group,
"availability_entity": self.availability_entity,
"availability_inverted": self.availability_inverted,
"unavailability_entity": self.unavailability_entity,
@@ -261,6 +322,7 @@ class Child:
"linked_user_id": self.linked_user_id,
"quiet_hours_start": self.quiet_hours_start,
"quiet_hours_end": self.quiet_hours_end,
"presence_entity": self.presence_entity,
"level": self.level,
"id": self.id,
}
@@ -348,6 +410,15 @@ class Chore:
# the real points when approving. Forces parent approval — a child types
# the suggestion, so it must never self-award.
open_ended: bool = False
# Teamwork chores (#928): the chore only counts once team_size different
# children have joined the day's occurrence; then it submits for all of
# them at once. 0 = an ordinary chore. team_points_mode "each" pays every
# participant the full points, "split" divides them evenly (rounded down);
# team_bonus is added per participant either way. Who has joined so far
# is runtime state, kept in storage["team_joins"], not on the chore.
team_size: int = 0
team_points_mode: str = "each" # each | split
team_bonus: int = 0
# Dynamic assignment (sibling rotation)
assignment_mode: str = "everyone" # everyone | alternating | random
assignment_rotation_anchor: str = "" # ISO date; day-0 of the rotation for alternating
@@ -371,6 +442,11 @@ class Chore:
# idempotence (don't re-publish the same day) and projection cleanup
# (entries < today are pruned before each publish pass).
publish_calendar_published_dates: list[str] = field(default_factory=list)
# Per-occurrence calendar edits (#977): {original ISO date: new ISO date}.
# An empty value means that one occurrence was removed. Written by the
# two-way calendar; every due-date computation consults it through
# ``occurrence_override`` so a moved chore is due on its new day only.
moved_occurrences: dict[str, str] = field(default_factory=dict)
# Bonus sub-tasks: optional extra-credit tasks that unlock after the parent chore is completed
bonus_subtasks: list[BonusSubTask] = field(default_factory=list)
# Timed task fields
@@ -378,6 +454,9 @@ class Chore:
timed_rate_points: int = 10 # points awarded per rate window
timed_rate_minutes: int = 5 # rate window size in minutes
timed_max_daily_minutes: int = 0 # 0 = unlimited; caps total daily duration
# NFC / QR tag completion (#923): HA tag ids that complete this chore when
# scanned in the companion app. Empty = no tag linked.
tag_ids: list[str] = field(default_factory=list)
id: str = field(default_factory=generate_id)
@classmethod
@@ -432,6 +511,9 @@ class Chore:
mandatory_penalty_points=max(0, int(data.get("mandatory_penalty_points", 0) or 0)),
require_photo=data.get("require_photo", False),
open_ended=data.get("open_ended", False),
team_size=max(0, int(data.get("team_size", 0) or 0)),
team_points_mode="split" if data.get("team_points_mode") == "split" else "each",
team_bonus=max(0, int(data.get("team_bonus", 0) or 0)),
assignment_mode=data.get("assignment_mode", "everyone"),
assignment_rotation_anchor=data.get("assignment_rotation_anchor", ""),
assignment_current_child_id=data.get("assignment_current_child_id", ""),
@@ -448,11 +530,13 @@ class Chore:
list(data.get("publish_calendar_published_dates", []))
or ([data["publish_calendar_last_date"]] if data.get("publish_calendar_last_date") else [])
),
moved_occurrences=clean_moved_occurrences(data.get("moved_occurrences")),
bonus_subtasks=[BonusSubTask.from_dict(b) for b in data.get("bonus_subtasks", [])],
task_type=data.get("task_type", "standard"),
timed_rate_points=data.get("timed_rate_points", 10),
timed_rate_minutes=max(1, int(data.get("timed_rate_minutes", 5) or 5)),
timed_max_daily_minutes=max(0, int(data.get("timed_max_daily_minutes", 0) or 0)),
tag_ids=normalize_tag_ids(data.get("tag_ids", [])),
id=data.get("id") or generate_id(),
)
@@ -500,6 +584,9 @@ class Chore:
"mandatory_penalty_points": self.mandatory_penalty_points,
"require_photo": self.require_photo,
"open_ended": self.open_ended,
"team_size": self.team_size,
"team_points_mode": self.team_points_mode,
"team_bonus": self.team_bonus,
"assignment_mode": self.assignment_mode,
"assignment_rotation_anchor": self.assignment_rotation_anchor,
"assignment_current_child_id": self.assignment_current_child_id,
@@ -510,15 +597,41 @@ class Chore:
"assignment_swap_date": self.assignment_swap_date,
"publish_calendar_entities": self.publish_calendar_entities,
"publish_calendar_published_dates": self.publish_calendar_published_dates,
"moved_occurrences": self.moved_occurrences,
"bonus_subtasks": [b.to_dict() for b in self.bonus_subtasks],
"task_type": self.task_type,
"timed_rate_points": self.timed_rate_points,
"timed_rate_minutes": self.timed_rate_minutes,
"timed_max_daily_minutes": self.timed_max_daily_minutes,
"tag_ids": self.tag_ids,
"id": self.id,
}
def clean_moved_occurrences(raw: Any) -> dict[str, str]:
"""Coerce a stored ``moved_occurrences`` map to {ISO date: ISO date or ""}.
Anything unparseable is dropped rather than raised, so a hand-edited or
older store can never stop the integration loading (#977).
"""
if not isinstance(raw, dict):
return {}
out: dict[str, str] = {}
for key, value in raw.items():
try:
src = date.fromisoformat(str(key)).isoformat()
except ValueError:
continue
if value in ("", None):
out[src] = ""
continue
try:
out[src] = date.fromisoformat(str(value)).isoformat()
except ValueError:
continue
return out
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)):
@@ -566,9 +679,16 @@ class Reward:
available_days: list[int] = field(default_factory=list)
available_from: str = ""
available_until: str = ""
# Streak freeze (#925): approving a claim hands the child one streak-freeze
# token instead of anything physical. Always a fixed cost like any reward.
streak_freeze: bool = False
id: str = field(default_factory=generate_id)
def __post_init__(self) -> None:
# A streak freeze belongs to the one child who bought it, so it can
# never be a shared jackpot.
if self.streak_freeze:
self.is_jackpot = False
# Jackpots are inherently pooled (#552): everyone deposits into the shared
# jar, so pool mode is always on. Enforced here so every construction path
# — WS add, service add, and storage load (legacy migration) — stays
@@ -603,6 +723,7 @@ class Reward:
available_days=_clean_weekdays(data.get("available_days")),
available_from=str(data.get("available_from", "") or ""),
available_until=str(data.get("available_until", "") or ""),
streak_freeze=bool(data.get("streak_freeze", False)),
id=data.get("id") or generate_id(),
)
@@ -628,6 +749,7 @@ class Reward:
"available_days": list(self.available_days),
"available_from": self.available_from,
"available_until": self.available_until,
"streak_freeze": self.streak_freeze,
"id": self.id,
}
@@ -731,6 +853,228 @@ class Challenge:
}
def _safe_int(value: Any, default: int = 0) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
def _bounty_claim_count(data: dict[str, Any]) -> int:
"""The stored claim count, or the least a record from before it was kept
(#961) proves: every lapse was a claim, and a bounty that is or was held
was claimed at least once."""
count = max(0, _safe_int(data.get("claim_count", 0)))
count = max(count, _safe_int(data.get("lapse_count", 0)))
if data.get("status") in ("claimed", "pending", "completed"):
count = max(count, 1)
return count
@dataclass
class Bounty:
"""A one-off job on the bounty board (#931).
A parent posts it for a fixed number of points; any eligible child may
claim it, which locks it to them until ``claim_until``. Done goes through
the normal approval queue as a ``ChoreCompletion`` carrying ``bounty_id``,
so points, streaks, badges and undo behave exactly as for a chore. A claim
that runs out puts the bounty back on the board; an unclaimed bounty
expires at ``expires_at``. Completed and expired bounties stay as history
until the completion history is pruned.
"""
title: str
points: int = 0
description: str = ""
icon: str = "mdi:flag-outline"
expires_at: datetime | None = None # None = no expiry
eligible_child_ids: list[str] = field(default_factory=list) # empty = every child
claim_hours: int = 2
require_photo: bool = False
notify_children: bool = True
status: str = "open" # open | claimed | pending | completed | expired
# The claimer. Kept once the bounty is completed, as who did it.
claimed_by: str = ""
claimed_at: datetime | None = None
claim_until: datetime | None = None
lapse_warned: bool = False # the "about to lapse" push went out for this claim
completion_id: str = "" # the pending / approved ChoreCompletion
points_awarded: int = 0
lapse_count: int = 0 # how many claims ran out before someone finished it
# How many times a child claimed it off the board (#961). A rejection
# hands the same claim back, so it doesn't count again.
claim_count: int = 0
created_at: datetime | None = None
closed_at: datetime | None = None # when it was completed or expired
id: str = field(default_factory=generate_id)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> Bounty:
return cls(
title=str(data.get("title", "") or ""),
points=max(0, _safe_int(data.get("points", 0))),
description=str(data.get("description", "") or ""),
icon=str(data.get("icon", "") or "mdi:flag-outline"),
expires_at=parse_datetime(data.get("expires_at")),
eligible_child_ids=[str(c) for c in (data.get("eligible_child_ids") or []) if c],
claim_hours=max(1, _safe_int(data.get("claim_hours", 2), 2)),
require_photo=data.get("require_photo") is True,
notify_children=data.get("notify_children", True) is not False,
status=data.get("status") if data.get("status") in BOUNTY_STATUSES else "open",
claimed_by=str(data.get("claimed_by", "") or ""),
claimed_at=parse_datetime(data.get("claimed_at")),
claim_until=parse_datetime(data.get("claim_until")),
lapse_warned=data.get("lapse_warned") is True,
completion_id=str(data.get("completion_id", "") or ""),
points_awarded=max(0, _safe_int(data.get("points_awarded", 0))),
lapse_count=max(0, _safe_int(data.get("lapse_count", 0))),
claim_count=_bounty_claim_count(data),
created_at=parse_datetime(data.get("created_at")),
closed_at=parse_datetime(data.get("closed_at")),
id=data.get("id") or generate_id(),
)
def to_dict(self) -> dict[str, Any]:
return {
"title": self.title,
"points": self.points,
"description": self.description,
"icon": self.icon,
"expires_at": format_datetime(self.expires_at),
"eligible_child_ids": list(self.eligible_child_ids),
"claim_hours": self.claim_hours,
"require_photo": self.require_photo,
"notify_children": self.notify_children,
"status": self.status,
"claimed_by": self.claimed_by,
"claimed_at": format_datetime(self.claimed_at),
"claim_until": format_datetime(self.claim_until),
"lapse_warned": self.lapse_warned,
"completion_id": self.completion_id,
"points_awarded": self.points_awarded,
"lapse_count": self.lapse_count,
"claim_count": self.claim_count,
"created_at": format_datetime(self.created_at),
"closed_at": format_datetime(self.closed_at),
"id": self.id,
}
def is_eligible(self, child_id: str) -> bool:
return not self.eligible_child_ids or child_id in self.eligible_child_ids
def clear_claim(self) -> None:
self.claimed_by = ""
self.claimed_at = None
self.claim_until = None
self.lapse_warned = False
self.completion_id = ""
def _clean_auction_bids(raw: Any) -> dict[str, dict[str, Any]]:
"""Coerce stored bids to {child_id: {"points": int >= 1, "at": ISO}}."""
out: dict[str, dict[str, Any]] = {}
if not isinstance(raw, dict):
return out
for child_id, bid in raw.items():
if not child_id or not isinstance(bid, dict):
continue
points = _safe_int(bid.get("points", 0))
at = parse_datetime(bid.get("at"))
if points < 1 or at is None:
continue
out[str(child_id)] = {"points": points, "at": format_datetime(at)}
return out
@dataclass
class Auction:
"""A reverse auction on one occurrence of a chore (#982).
A parent opens it on a scheduled date of an existing chore with a maximum
price and a closing time. Eligible children place sealed bids — the fewest
points they would do it for. At closing the lowest bid wins (the earliest
bid on a tie; changing a bid re-times it) and the winner is assigned that
one occurrence at their price: ``winner_id`` / ``price`` are what the
assignment and completion paths read. No bids leaves the chore on its
normal assignment. Bids are secret: only a parent ever sees ``bids``.
"""
chore_id: str
occurrence: str # ISO date of the auctioned occurrence
max_points: int = 1
min_points: int = 1
closes_at: datetime | None = None
eligible_child_ids: list[str] = field(default_factory=list)
notify_children: bool = True
status: str = "open" # open | closed | cancelled
bids: dict[str, dict[str, Any]] = field(default_factory=dict)
winner_id: str = ""
price: int = 0
reminder_sent: bool = False # the "closes within the hour" push went out
# What the chore was called and worth when the auction opened, so the
# history still reads right after the chore is renamed or deleted.
chore_name: str = ""
chore_points: int = 0
created_at: datetime | None = None
closed_at: datetime | None = None
id: str = field(default_factory=generate_id)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> Auction:
max_points = max(1, _safe_int(data.get("max_points", 1), 1))
return cls(
chore_id=str(data.get("chore_id", "") or ""),
occurrence=str(data.get("occurrence", "") or ""),
max_points=max_points,
min_points=min(max_points, max(1, _safe_int(data.get("min_points", 1), 1))),
closes_at=parse_datetime(data.get("closes_at")),
eligible_child_ids=[str(c) for c in (data.get("eligible_child_ids") or []) if c],
notify_children=data.get("notify_children", True) is not False,
status=data.get("status") if data.get("status") in AUCTION_STATUSES else "open",
bids=_clean_auction_bids(data.get("bids")),
winner_id=str(data.get("winner_id", "") or ""),
price=max(0, _safe_int(data.get("price", 0))),
reminder_sent=data.get("reminder_sent") is True,
chore_name=str(data.get("chore_name", "") or ""),
chore_points=max(0, _safe_int(data.get("chore_points", 0))),
created_at=parse_datetime(data.get("created_at")),
closed_at=parse_datetime(data.get("closed_at")),
id=data.get("id") or generate_id(),
)
def to_dict(self) -> dict[str, Any]:
return {
"chore_id": self.chore_id,
"occurrence": self.occurrence,
"max_points": self.max_points,
"min_points": self.min_points,
"closes_at": format_datetime(self.closes_at),
"eligible_child_ids": list(self.eligible_child_ids),
"notify_children": self.notify_children,
"status": self.status,
"bids": {k: dict(v) for k, v in self.bids.items()},
"winner_id": self.winner_id,
"price": self.price,
"reminder_sent": self.reminder_sent,
"chore_name": self.chore_name,
"chore_points": self.chore_points,
"created_at": format_datetime(self.created_at),
"closed_at": format_datetime(self.closed_at),
"id": self.id,
}
def ranked_bids(self) -> list[tuple[str, int, datetime]]:
"""(child_id, points, placed_at), winner first: lowest, then earliest."""
ranked = []
for child_id, bid in self.bids.items():
at = parse_datetime(bid.get("at"))
if at is not None:
ranked.append((child_id, int(bid.get("points", 0)), at))
ranked.sort(key=lambda b: (b[1], b[2]))
return ranked
@dataclass
class ChoreCompletion:
"""Represents a chore completion record."""
@@ -751,6 +1095,16 @@ class ChoreCompletion:
# and weekend multipliers that _award_points adds on top. None on records
# written before the field existed.
submitted_points: int | None = None
# Parent's 1-3 star quality rating given at approval (#927); 0 = unrated.
quality_rating: int = 0
# True only on a completion a child submitted themselves, so the child
# may take it back inside the global undo window (#918). A parent's
# review clears it for good, and records written before the field existed
# load as False: they stay parent-only.
child_undo_allowed: bool = False
# Set when this completion is a bounty's (#931). chore_id then carries the
# bounty id too, so nothing that groups completions by chore mixes the two.
bounty_id: str = ""
@classmethod
def from_dict(cls, data: dict[str, Any]) -> ChoreCompletion:
@@ -772,11 +1126,14 @@ class ChoreCompletion:
suggested_points=int(data.get("suggested_points", 0) or 0),
id=data.get("id") or generate_id(),
submitted_points=parse_optional_points(data.get("submitted_points")),
quality_rating=parse_quality_rating(data.get("quality_rating")),
child_undo_allowed=data.get("child_undo_allowed") is True,
bounty_id=str(data.get("bounty_id", "") or ""),
)
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary."""
return {
data = {
"chore_id": self.chore_id,
"child_id": self.child_id,
"completed_at": format_datetime(self.completed_at),
@@ -790,7 +1147,15 @@ class ChoreCompletion:
"suggested_points": self.suggested_points,
"id": self.id,
"submitted_points": self.submitted_points,
"quality_rating": self.quality_rating,
}
# Written only when set: every completion ever made is stored, and the
# flag is False on nearly all of them.
if self.child_undo_allowed:
data["child_undo_allowed"] = True
if self.bounty_id:
data["bounty_id"] = self.bounty_id
return data
@dataclass
@@ -855,6 +1220,9 @@ class RewardClaim:
# What this purchase actually cost, recorded when it was approved. None on
# a pending claim, and on claims approved before the field existed.
approved_cost: int | None = None
# Set when the claim redeems a wishlist wish (#932) rather than a reward.
# reward_id then holds the wish id too, and no Reward record exists.
wish_id: str = ""
@classmethod
def from_dict(cls, data: dict[str, Any]) -> RewardClaim:
@@ -870,11 +1238,12 @@ class RewardClaim:
approved_at=approved_at,
id=data.get("id") or generate_id(),
approved_cost=parse_optional_points(data.get("approved_cost")),
wish_id=str(data.get("wish_id", "") or ""),
)
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary."""
return {
out = {
"reward_id": self.reward_id,
"child_id": self.child_id,
"claimed_at": format_datetime(self.claimed_at),
@@ -883,6 +1252,10 @@ class RewardClaim:
"id": self.id,
"approved_cost": self.approved_cost,
}
# Only a wishlist redemption carries it, so ordinary claims keep their shape.
if self.wish_id:
out["wish_id"] = self.wish_id
return out
@dataclass
@@ -1396,3 +1769,182 @@ class CustomSound:
"file": self.file,
"created_at": self.created_at,
}
# ── Wishlist with pledges (#932) ─────────────────────────────────────────
WISH_NAME_MAX = 60
WISH_LINK_MAX = 500
WISH_DECLINE_REASON_MAX = 200
# Why a parent rejected a chore or reward claim (#976) — same cap as a wish's
# decline reason, which it mirrors.
REJECT_REASON_MAX = WISH_DECLINE_REASON_MAX
PLEDGE_NAME_MAX = 40
PLEDGE_MESSAGE_MAX = 140
WISH_STATUSES = ("pending", "active", "redeem_requested", "redeemed", "declined")
def clean_label(value: Any, limit: int) -> str:
"""Single-line, trimmed, length-capped text for a user-supplied label."""
text = " ".join(str(value or "").split())
return text[:limit]
def clean_wish_link(value: Any) -> str:
"""Return ``value`` if it is a plain http(s) URL, else "".
A wish link is typed by a child and rendered as a clickable link, so
anything that is not an absolute http(s) URL — ``javascript:``, ``data:``,
a relative path, embedded whitespace or control characters — is dropped
rather than stored.
"""
from urllib.parse import urlsplit
text = str(value or "").strip()
if not text or len(text) > WISH_LINK_MAX:
return ""
if any(ch.isspace() or ord(ch) < 32 or ord(ch) == 127 for ch in text):
return ""
try:
parts = urlsplit(text)
except ValueError:
return ""
if parts.scheme.lower() not in ("http", "https") or not parts.netloc:
return ""
return text
def _clean_wish_image(value: Any) -> str:
"""Keep only one of TaskMate's own stored-image URLs (bare, unsigned)."""
from .images import normalize_taskmate_image_url
return normalize_taskmate_image_url(str(value or "")) or ""
def _points(value: Any) -> int:
try:
number = int(value)
except (TypeError, ValueError):
return 0
return max(0, number)
@dataclass
class WishPledge:
"""Points a relative chipped in towards a wish (#932).
Pledged points are a gift attached to the wish: they are never added to
the child's balance, so the only way they turn into anything is by the
wish being redeemed. ``name`` is free text typed by a parent ("Grandma").
"""
name: str
points: int
message: str = ""
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
id: str = field(default_factory=generate_id)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> WishPledge:
return cls(
name=clean_label(data.get("name"), PLEDGE_NAME_MAX),
points=_points(data.get("points")),
message=clean_label(data.get("message"), PLEDGE_MESSAGE_MAX),
created_at=parse_datetime(data.get("created_at")) or datetime.now(timezone.utc),
id=data.get("id") or generate_id(),
)
def to_dict(self) -> dict[str, Any]:
return {
"name": self.name,
"points": self.points,
"message": self.message,
"created_at": format_datetime(self.created_at),
"id": self.id,
}
@dataclass
class Wish:
"""Something a child is saving towards (#932).
Lifecycle: ``pending`` (added by the child, waiting for a parent) →
``active`` (approved; the child can move points in and relatives can
pledge) → ``redeem_requested`` (funded, a reward claim is waiting) →
``redeemed``. A parent can ``decline`` it instead, with a reason the child
sees. ``saved`` are the child's own points moved in — already taken out of
``Child.points``, exactly like a savings-jar allocation — and are the only
part a child can take back out.
"""
child_id: str
name: str
target: int
status: str = "pending"
suggested_target: int = 0
link: str = ""
image_url: str = ""
saved: int = 0
pledges: list[WishPledge] = field(default_factory=list)
decline_reason: str = ""
claim_id: str = ""
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
approved_at: datetime | None = None
declined_at: datetime | None = None
redeemed_at: datetime | None = None
id: str = field(default_factory=generate_id)
@property
def pledged(self) -> int:
return sum(p.points for p in self.pledges)
@property
def remaining(self) -> int:
"""Points still needed to reach the target (never negative)."""
return max(0, self.target - self.saved - self.pledged)
@property
def funded(self) -> bool:
return self.target > 0 and self.saved + self.pledged >= self.target
@classmethod
def from_dict(cls, data: dict[str, Any]) -> Wish:
status = data.get("status", "pending")
target = _points(data.get("target"))
return cls(
child_id=str(data.get("child_id", "") or ""),
name=clean_label(data.get("name"), WISH_NAME_MAX),
target=target,
status=status if status in WISH_STATUSES else "pending",
suggested_target=_points(data.get("suggested_target")) or target,
link=clean_wish_link(data.get("link")),
image_url=_clean_wish_image(data.get("image_url")),
saved=_points(data.get("saved")),
pledges=[WishPledge.from_dict(p) for p in data.get("pledges", []) or [] if isinstance(p, dict)],
decline_reason=clean_label(data.get("decline_reason"), WISH_DECLINE_REASON_MAX),
claim_id=str(data.get("claim_id", "") or ""),
created_at=parse_datetime(data.get("created_at")) or datetime.now(timezone.utc),
approved_at=parse_datetime(data.get("approved_at")),
declined_at=parse_datetime(data.get("declined_at")),
redeemed_at=parse_datetime(data.get("redeemed_at")),
id=data.get("id") or generate_id(),
)
def to_dict(self) -> dict[str, Any]:
return {
"child_id": self.child_id,
"name": self.name,
"target": self.target,
"status": self.status,
"suggested_target": self.suggested_target,
"link": self.link,
"image_url": self.image_url,
"saved": self.saved,
"pledges": [p.to_dict() for p in self.pledges],
"decline_reason": self.decline_reason,
"claim_id": self.claim_id,
"created_at": format_datetime(self.created_at),
"approved_at": format_datetime(self.approved_at),
"declined_at": format_datetime(self.declined_at),
"redeemed_at": format_datetime(self.redeemed_at),
"id": self.id,
}
+438 -13
View File
@@ -3,7 +3,8 @@
from __future__ import annotations
import logging
from datetime import date
from datetime import date, timedelta
from types import SimpleNamespace
from homeassistant.components.sensor import (
SensorEntity,
@@ -17,11 +18,13 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util
from . import images
from .const import DOMAIN
from .chore_undo import child_undo_metadata, undo_window_seconds
from .const import BOUNTY_RECENT_HOURS, DOMAIN, WISH_MAX_OPEN_PER_CHILD
from .coord_rewards import reward_is_time_locked
from .coordinator import TaskMateCoordinator
from .entity import taskmate_device_info
from .models import Child
from .watch import DEFAULT_STATE_STRINGS, watch_state, watch_summary
_LOGGER = logging.getLogger(__name__)
@@ -79,6 +82,11 @@ def _compute_common(coordinator: TaskMateCoordinator) -> dict:
child_lookup = {c.id: c for c in children}
chore_lookup = {c.id: c for c in chores}
reward_lookup = {r.id: r for r in rewards}
# Wishlist redemptions (#932) are reward claims with no Reward behind them.
wish_lookup = {w.id: w for w in coordinator.storage.get_wishes()}
# A bounty's completion (#931) carries the bounty id as its chore_id, so
# every "which chore is this?" lookup below falls back to this one.
bounty_lookup = {b.id: b for b in data.get("bounties", []) or []}
# Current-month leaderboard points per child (FEAT-2).
season_points = coordinator.storage.get_season_points(dt_util.now().strftime("%Y-%m"))
@@ -86,7 +94,7 @@ def _compute_common(coordinator: TaskMateCoordinator) -> dict:
# Pending chore points per child (awaiting approval -> points to be earned).
pending_points_by_child: dict[str, int] = {}
for comp in pending_completions:
chore = chore_lookup.get(comp.chore_id)
chore = chore_lookup.get(comp.chore_id) or bounty_lookup.get(comp.chore_id)
if chore:
pending_points_by_child[comp.child_id] = pending_points_by_child.get(comp.child_id, 0) + chore.points
@@ -120,8 +128,11 @@ def _compute_common(coordinator: TaskMateCoordinator) -> dict:
"child_lookup": child_lookup,
"chore_lookup": chore_lookup,
"reward_lookup": reward_lookup,
"wish_lookup": wish_lookup,
"bounty_lookup": bounty_lookup,
"season_points": season_points,
"all_completions": all_completions,
"chore_undo_seconds": undo_window_seconds(coordinator.storage),
"pending_completions": pending_completions,
"pending_reward_claim_objs": pending_reward_claim_objs,
"pool_alloc_objs": pool_alloc_objs,
@@ -130,11 +141,22 @@ def _compute_common(coordinator: TaskMateCoordinator) -> dict:
"pool_by_child_reward": pool_by_child_reward,
"pool_total_by_reward": pool_total_by_reward,
"total_allocated_by_child": total_allocated_by_child,
# Sent back to redo by an inspection (#981): not "done" on the card.
"inspection_redo_ids": _inspection_redo_ids(coordinator),
}
setattr(coordinator, _COMMON_CACHE_ATTR, {"data_id": data_id, "common": common})
return common
def _inspection_redo_ids(coordinator) -> set[str]:
getter = getattr(coordinator, "inspection_redo_completion_ids", None)
try:
return set(getter()) if callable(getter) else set()
except Exception: # noqa: BLE001
_LOGGER.debug("inspection redo ids unavailable", exc_info=True)
return set()
def _build_children_summary(coordinator: TaskMateCoordinator, common: dict) -> list[dict]:
"""Build compact per-child summary used on the overview sensor."""
children = common["children"]
@@ -142,6 +164,7 @@ def _build_children_summary(coordinator: TaskMateCoordinator, common: dict) -> l
committed = common["committed_points_by_child"]
allocated = common["total_allocated_by_child"]
season = common["season_points"]
freezes_on = _safe_int(((common.get("data") or {}).get("settings") or {}).get("streak_freeze_max"), 2) > 0
summary = []
for c in children:
committed_amount = committed.get(c.id, 0)
@@ -196,7 +219,12 @@ def _build_children_summary(coordinator: TaskMateCoordinator, common: dict) -> l
"last_completion_date": getattr(c, "last_completion_date", None),
"streak_paused": getattr(c, "streak_paused", False),
"on_vacation": coordinator._is_child_on_vacation(c),
# Birthday mode (#924): only on the day, so it costs nothing otherwise.
**({"birthday": bday} if (bday := coordinator.birthday_summary(c)) else {}),
"streak_milestones_achieved": getattr(c, "streak_milestones_achieved", None) or [],
# Streak freeze tokens (#925): only while the feature is on, so
# the cards can tell "none left" apart from "not in use".
**({"streak_freezes": getattr(c, "streak_freezes", 0) or 0} if freezes_on else {}),
"awarded_perfect_weeks": getattr(c, "awarded_perfect_weeks", None) or [],
"career_score": getattr(c, "career_score", 0) or 0,
"total_penalties_received": getattr(c, "total_penalties_received", 0) or 0,
@@ -204,6 +232,11 @@ def _build_children_summary(coordinator: TaskMateCoordinator, common: dict) -> l
"avatar_options": coordinator.avatar_options_for_child(c),
"challenges": coordinator.challenge_progress_for_child(c.id),
**({"weekly_chore_progress": weekly_progress} if weekly_progress else {}),
# Reject reasons (#976): why a chore / claim was just sent back,
# only while there's one to show.
**({"rejections": rej} if (rej := coordinator.rejections_for_child(c.id)) else {}),
# Surprise inspections (#981): today's banner(s), only when any.
**({"inspections": insp} if (insp := coordinator.inspections_for_child(c.id)) else {}),
}
)
return summary
@@ -223,6 +256,9 @@ def _build_chores_list(coordinator: TaskMateCoordinator, common: dict) -> list[d
"""
chores = common["chores"]
chores_list = []
today = dt_util.as_local(dt_util.now()).date()
# Calendar moves older than the calendar card's week view are history only.
moved_floor = (today - timedelta(days=7)).isoformat()
for c in chores:
assigned_to = c.assigned_to if isinstance(c.assigned_to, list) else []
depends_on = c.depends_on if isinstance(getattr(c, "depends_on", None), list) else []
@@ -242,9 +278,22 @@ def _build_chores_list(coordinator: TaskMateCoordinator, common: dict) -> list[d
difficulty = getattr(c, "difficulty", "medium") or "medium"
if difficulty != "medium":
record["difficulty"] = difficulty
effective_points = coordinator.effective_chore_points(c)
# A teamwork chore (#928) pays each participant their share, so that
# is the figure the cards quote as "+N".
team_size = coordinator.teamwork_size(c) if int(getattr(c, "team_size", 0) or 0) >= 2 else 0
effective_points = coordinator.team_share_points(c) if team_size else coordinator.effective_chore_points(c)
if effective_points != c.points:
record["effective_points"] = effective_points
# Teamwork (#928): only on teamwork chores, and only the non-default
# parts — the joined list is the day's progress the card draws.
if team_size:
team: dict = {"size": team_size}
joined = coordinator.team_joined_ids(c)
if joined:
team["joined"] = joined
if getattr(c, "team_points_mode", "each") == "split":
team["split"] = True
record["team"] = team
# Optional fields — emit only when non-default to save bytes.
description = getattr(c, "description", "") or ""
if description:
@@ -263,6 +312,35 @@ def _build_chores_list(coordinator: TaskMateCoordinator, common: dict) -> list[d
due_days = getattr(c, "due_days", []) or []
if due_days:
record["due_days"] = due_days
# Occurrences moved or removed from the HA calendar (#977). Only chores
# that have one carry these: `occ_today` settles today for the cards'
# weekday filters, the map lets the calendar card place other days.
moved = getattr(c, "moved_occurrences", None) or {}
if moved:
override = coordinator.occurrence_override(c, today)
if override is not None:
record["occ_today"] = override
recent = {src: dst for src, dst in moved.items() if src >= moved_floor or (dst and dst >= moved_floor)}
if recent:
record["moved_occurrences"] = recent
# A one-off belongs to its own date only (#992) — a calendar-added one
# for later in the week, or yesterday's before the midnight sweep. Same
# flag, so every card's today filter drops it as the backend does.
if record["schedule_mode"] == "one_shot" and record["enabled"] and _one_off_not_today(c, today):
record["occ_today"] = False
# Won at auction (#982). Only chores with a won occurrence this week
# carry these: `auction` settles today for the cards (the winner alone,
# at the winning price, which is also what the chore pays today); the
# map lets the calendar card place the other days.
wins = coordinator.auction_wins_for_chore(c, today, 7)
if isinstance(wins, dict) and wins:
record["auction_wins"] = {day: w["child_id"] for day, w in wins.items()}
# What each won day pays, so a future one shows it too (#998).
record["auction_prices"] = {day: w["points"] for day, w in wins.items()}
today_win = wins.get(today.isoformat())
if today_win:
record["auction"] = today_win
record["effective_points"] = today_win["points"]
requires_approval = getattr(c, "requires_approval", True)
if not requires_approval:
record["requires_approval"] = False
@@ -351,6 +429,21 @@ def _build_chores_list(coordinator: TaskMateCoordinator, common: dict) -> list[d
return chores_list
def _one_off_not_today(chore, today: date) -> bool:
"""True when a one-off chore is dated for a day other than ``today``.
Mirrors the one_shot branch of is_chore_available_for_child: no date, or
one that doesn't parse, counts as today.
"""
created_date = getattr(chore, "created_date", "") or ""
if not created_date:
return False
try:
return date.fromisoformat(created_date) != today
except ValueError:
return False
def _build_chore_availability(coordinator: TaskMateCoordinator, common: dict) -> dict:
"""Build the chore availability matrix: {chore_id: {child_id: bool}}.
@@ -390,14 +483,25 @@ def _completion_award(completion, recalculated: int) -> int:
return recalculated
def _bounty_title(common: dict, comp) -> str:
"""The bounty's title for a bounty completion (#931), else ""."""
bounty = common.get("bounty_lookup", {}).get(getattr(comp, "bounty_id", "") or "")
return bounty.title if bounty else ""
def _build_todays_completions(common: dict) -> list[dict]:
"""Build today's completions (both approved and pending)."""
now = dt_util.now()
today = now.date()
child_lookup = common["child_lookup"]
chore_lookup = common["chore_lookup"]
redo_ids = common.get("inspection_redo_ids") or set()
out = []
for comp in common["all_completions"]:
# An inspection sent this one back to redo (#981): the chore is owed
# again today, so the card must not see it as done.
if comp.id in redo_ids:
continue
comp_dt = comp.completed_at
if hasattr(comp_dt, "astimezone"):
comp_dt = dt_util.as_local(comp_dt)
@@ -411,7 +515,7 @@ def _build_todays_completions(common: dict) -> list[dict]:
display_name = f"{matched_chore.name} › {subtask.name}" if subtask else matched_chore.name
display_points = subtask.points if subtask else 0
else:
display_name = matched_chore.name if matched_chore else ""
display_name = matched_chore.name if matched_chore else _bounty_title(common, comp)
display_points = matched_chore.points if matched_chore else 0
timed_secs = getattr(comp, "timed_duration_seconds", 0) or 0
if timed_secs > 0 and matched_chore and getattr(matched_chore, "task_type", "") == "timed":
@@ -435,6 +539,10 @@ def _build_todays_completions(common: dict) -> list[dict]:
}
if timed_secs > 0:
rec["timed_duration_seconds"] = timed_secs
# Child undo (#918): child_undo_pending / child_undo_until, present only
# on a completion the child may still take back, so the attribute
# stays small (and empty whenever the window is 0).
rec.update(child_undo_metadata(comp, common["all_completions"], common.get("chore_undo_seconds", 0)))
# Emit the bare (unsigned) photo path. A card's <img> carries no bearer
# token, so the card signs each path per-viewer via auth/sign_path before
# rendering — this keeps a self-authenticating URL out of this
@@ -544,6 +652,9 @@ def _build_rewards_list(common: dict) -> list[dict]:
# 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.
# Streak freeze (#925): flag only the rewards that are one.
if getattr(r, "streak_freeze", False):
out[-1]["streak_freeze"] = True
if getattr(r, "time_lock_enabled", False):
out[-1]["time_lock"] = {
"days": list(getattr(r, "available_days", []) or []),
@@ -555,13 +666,25 @@ def _build_rewards_list(common: dict) -> list[dict]:
return out
def _wish_as_reward(wish) -> SimpleNamespace | None:
"""Present a wish's redemption claim (#932) the way a reward claim reads.
Approval cards and the activity feed look a claim's reward up by id; a
wish has no Reward record, so they get its name, a heart and its target.
"""
if wish is None:
return None
return SimpleNamespace(id=wish.id, name=wish.name, icon="mdi:heart", cost=wish.target)
def _build_pending_reward_claims(common: dict) -> list[dict]:
"""Build enriched pending reward claims list."""
reward_lookup = common["reward_lookup"]
child_lookup = common["child_lookup"]
out = []
wish_lookup = common.get("wish_lookup", {})
for rc in common["pending_reward_claim_objs"]:
reward = reward_lookup.get(rc.reward_id)
reward = reward_lookup.get(rc.reward_id) or _wish_as_reward(wish_lookup.get(rc.wish_id))
child = child_lookup.get(rc.child_id)
if not reward or not child:
continue
@@ -607,12 +730,14 @@ def _build_recent_completions(common: dict, limit: int = 35) -> list[dict]:
"child_name": "Parent"
if comp.child_id == "__parent__"
else (child_lookup[comp.child_id].name if comp.child_id in child_lookup else ""),
"chore_name": matched_chore.name if matched_chore else "",
"chore_name": matched_chore.name if matched_chore else _bounty_title(common, comp),
"points": _completion_award(comp, display_points),
"approved": comp.approved,
"completed_at": comp.completed_at.isoformat()
if hasattr(comp.completed_at, "isoformat")
else str(comp.completed_at),
# Star rating the parent gave at approval (#927) — only when rated.
**({"rating": comp.quality_rating} if getattr(comp, "quality_rating", 0) else {}),
}
)
return out
@@ -636,7 +761,7 @@ def _build_photo_gallery(common: dict, limit: int = 40) -> list[dict]:
{
"completion_id": comp.id,
"child_name": child_lookup[comp.child_id].name if comp.child_id in child_lookup else "",
"chore_name": chore.name if chore else "",
"chore_name": chore.name if chore else _bounty_title(common, comp),
"approved": comp.approved,
"completed_at": comp.completed_at.isoformat()
if hasattr(comp.completed_at, "isoformat")
@@ -647,7 +772,16 @@ def _build_photo_gallery(common: dict, limit: int = 40) -> list[dict]:
return out
def _build_recent_transactions(common: dict, limit: int = 20) -> list[dict]:
_FEED_REJECTIONS_MAX = 8
_FEED_INSPECTIONS_MAX = 8
def _build_recent_transactions(
common: dict,
limit: int = 20,
rejections: list[dict] | None = None,
inspections: list[dict] | None = None,
) -> list[dict]:
"""Unified activity feed of manual point adjustments and reward claims.
Capped at 20 so the combined activity slice (completions + transactions)
@@ -676,9 +810,10 @@ def _build_recent_transactions(common: dict, limit: int = 20) -> list[dict]:
}
)
wish_lookup = common.get("wish_lookup", {})
for rc in all_reward_claims:
child = child_lookup.get(rc.child_id)
reward = reward_lookup.get(rc.reward_id)
reward = reward_lookup.get(rc.reward_id) or _wish_as_reward(wish_lookup.get(getattr(rc, "wish_id", "")))
if not child or not reward:
continue
event_type = "reward_approved" if rc.approved else "reward_claimed"
@@ -700,6 +835,37 @@ def _build_recent_transactions(common: dict, limit: int = 20) -> list[dict]:
}
)
# Reasoned rejections (#976): the completion / claim itself is gone, so
# these come from the rejection log rather than the records above. A
# reason can run to 200 characters, so only the newest few make the feed
# (the 16 KB cap).
for r in (rejections or [])[-_FEED_REJECTIONS_MAX:]:
child = child_lookup.get(r.get("child_id"))
if not child:
continue
kind = "reward" if r.get("kind") == "reward" else "chore"
events.append(
{
"transaction_id": r.get("id", ""),
"type": f"{kind}_rejected",
"child_id": child.id,
"child_name": child.name,
f"{kind}_id": r.get("item_id", ""),
f"{kind}_name": r.get("item_name", ""),
"reason": r.get("reason", ""),
"created_at": str(r.get("rejected_at", "")),
}
)
# Surprise inspections (#981): started / didn't pass / sent back (a pass
# with a bonus is already here as its "Inspection passed:" transaction).
for e in (inspections or [])[-_FEED_INSPECTIONS_MAX:]:
child = child_lookup.get(e.get("child_id"))
if not child:
continue
row = {k: v for k, v in e.items() if k != "id"}
events.append({**row, "transaction_id": e.get("id", ""), "child_name": child.name})
events.sort(key=lambda e: e["created_at"], reverse=True)
return events[:limit]
@@ -754,12 +920,16 @@ async def async_setup_entry(
entities.append(TaskMateRewardsSensor(coordinator, entry))
entities.append(TaskMateActivitySensor(coordinator, entry))
entities.append(TaskMateIncentivesSensor(coordinator, entry))
entities.append(TaskMateBountiesSensor(coordinator, entry))
entities.append(TaskMateAuctionsSensor(coordinator, entry))
# Add sensors for each child
for child in coordinator.data.get("children", []):
entities.append(ChildPointsSensor(coordinator, entry, child))
entities.append(ChildStatsSensor(coordinator, entry, child))
entities.append(ChildBadgesSensor(coordinator, entry, child))
entities.append(ChildWishlistSensor(coordinator, entry, child))
entities.append(ChildWatchSensor(coordinator, entry, child))
tracked_child_ids.add(child.id)
# Add pending approvals sensor
@@ -778,6 +948,8 @@ async def async_setup_entry(
new_entities.append(ChildPointsSensor(coordinator, entry, child))
new_entities.append(ChildStatsSensor(coordinator, entry, child))
new_entities.append(ChildBadgesSensor(coordinator, entry, child))
new_entities.append(ChildWishlistSensor(coordinator, entry, child))
new_entities.append(ChildWatchSensor(coordinator, entry, child))
tracked_child_ids.add(child.id)
if new_entities:
@@ -897,6 +1069,8 @@ class TaskMateOverallStatsSensor(_CachedAttrsSensor):
"perfect_week_enabled": settings.get("perfect_week_enabled", "true") == "true",
"perfect_week_bonus": _safe_int(settings.get("perfect_week_bonus"), 50),
"streak_requires_all_chores": settings.get("streak_requires_all_chores", "false") in (True, "true"),
"streak_freeze_max": max(0, _safe_int(settings.get("streak_freeze_max"), 2)),
"streak_freeze_earn_every": max(0, _safe_int(settings.get("streak_freeze_earn_every"), 7)),
"perfect_week_requires_all_chores": settings.get("perfect_week_requires_all_chores", "false")
in (True, "true"),
"total_children": len(children),
@@ -1094,7 +1268,11 @@ class TaskMateActivitySensor(_CachedAttrsSensor):
career_history[c.id] = self.coordinator.storage.get_career_score_history(c.id)
return {
"recent_completions": _build_recent_completions(common),
"recent_transactions": _build_recent_transactions(common),
"recent_transactions": _build_recent_transactions(
common,
rejections=self.coordinator.recent_rejections(),
inspections=self.coordinator.recent_inspection_events(),
),
"career_score_history": career_history,
"photo_gallery": _build_photo_gallery(common),
}
@@ -1131,6 +1309,120 @@ class TaskMateIncentivesSensor(_CachedAttrsSensor):
}
def _build_bounties_list(coordinator: TaskMateCoordinator, common: dict) -> list[dict]:
"""The bounty board for the cards (#931): live bounties plus the last day's
completed ones. Expired and older history stays in storage for the panel.
Compact: optional fields only appear when they carry something, so a board
of a handful of jobs costs a few hundred bytes.
"""
now = dt_util.now()
recent_cutoff = now - timedelta(hours=BOUNTY_RECENT_HOURS)
completions = {c.id: c for c in common["all_completions"]}
window = common.get("chore_undo_seconds", 0)
out = []
for b in common["data"].get("bounties", []) or []:
if b.status == "expired":
continue
if b.status == "completed" and (b.closed_at is None or b.closed_at < recent_cutoff):
continue
rec = {
"id": b.id,
"title": b.title,
"points": b.points,
"icon": b.icon,
"status": b.status,
"claim_hours": b.claim_hours,
}
if b.description:
rec["description"] = b.description
if b.eligible_child_ids:
rec["eligible"] = list(b.eligible_child_ids)
if b.expires_at is not None:
rec["expires_at"] = b.expires_at.isoformat()
if b.require_photo:
rec["require_photo"] = True
if b.status in ("claimed", "pending", "completed") and b.claimed_by:
rec["claimed_by"] = b.claimed_by
if b.status == "claimed":
if b.claimed_at is not None:
rec["claimed_at"] = b.claimed_at.isoformat()
if b.claim_until is not None:
rec["claim_until"] = b.claim_until.isoformat()
if b.status == "pending" and b.completion_id:
rec["completion_id"] = b.completion_id
comp = completions.get(b.completion_id)
if comp is not None and child_undo_metadata(comp, common["all_completions"], window).get(
"child_undo_pending"
):
rec["undo"] = True
if b.status == "completed":
rec["points_awarded"] = b.points_awarded
rec["completed_at"] = b.closed_at.isoformat()
out.append(rec)
return out
class TaskMateBountiesSensor(_CachedAttrsSensor):
"""The bounty board (#931): open bounties as the state, the board as an
attribute for the bounty card."""
_unrecorded_attributes = frozenset({"bounties"})
def __init__(
self,
coordinator: TaskMateCoordinator,
entry: ConfigEntry,
) -> None:
super().__init__(coordinator, entry)
self._attr_unique_id = f"{entry.entry_id}_bounties"
self._attr_name = "TaskMate Bounties"
@property
def native_value(self) -> int:
return sum(1 for b in self.coordinator.data.get("bounties", []) or [] if b.status == "open")
@property
def icon(self) -> str:
return "mdi:flag-checkered"
def _build_attributes(self) -> dict:
common = _compute_common(self.coordinator)
return {"bounties": _build_bounties_list(self.coordinator, common)}
class TaskMateAuctionsSensor(_CachedAttrsSensor):
"""Chore auctions (#982): live auctions as the state, and a public digest
the auction card watches to know when to re-fetch its view.
Bids are secret, so the digest carries only how many are in — never who
bid or how much. The card fetches its own bid over the
``taskmate/auctions/list`` WebSocket command, which enforces that.
"""
_unrecorded_attributes = frozenset({"auctions"})
def __init__(
self,
coordinator: TaskMateCoordinator,
entry: ConfigEntry,
) -> None:
super().__init__(coordinator, entry)
self._attr_unique_id = f"{entry.entry_id}_auctions"
self._attr_name = "TaskMate Auctions"
@property
def native_value(self) -> int:
return sum(1 for a in self.coordinator.storage.get_auctions() if a.status == "open")
@property
def icon(self) -> str:
return "mdi:gavel"
def _build_attributes(self) -> dict:
return {"auctions": self.coordinator.auctions_public_state()}
class ChildPointsSensor(TaskMateBaseSensor):
"""Sensor for a child's points."""
@@ -1232,6 +1524,10 @@ class ChildStatsSensor(TaskMateBaseSensor):
def _included(c):
if not (child.id in c.assigned_to or not c.assigned_to):
return False
# Won at auction (#982): today's occurrence is the winner's alone.
winner = self.coordinator.auction_winner(c)
if isinstance(winner, str) and winner:
return winner == child.id
mode = getattr(c, "assignment_mode", "everyone")
if mode not in ("everyone", "first_come") and getattr(c, "assignment_current_child_id", "") != child.id:
return False
@@ -1260,6 +1556,123 @@ class ChildStatsSensor(TaskMateBaseSensor):
}
class ChildWishlistSensor(TaskMateBaseSensor):
"""A child's open wishes (#932), for the wishlist card.
One per child rather than one for the family: a wish can carry a name,
a link, a picture and a row of pledge chips, and a family's worth of
those would not fit under the recorder's 16 KB attribute limit. The card
finds this sensor by ``wishlist_child_id``. The redeemed history is left
to the admin panel.
"""
_attr_icon = "mdi:heart-outline"
_unrecorded_attributes = frozenset({"wishes"})
def __init__(
self,
coordinator: TaskMateCoordinator,
entry: ConfigEntry,
child: Child,
) -> None:
super().__init__(coordinator, entry)
self.child_id = child.id
self._attr_unique_id = f"{entry.entry_id}_{child.id}_wishlist"
self._attr_name = f"{child.name} Wishlist"
@property
def native_value(self) -> int:
"""Wishes on the go: waiting, saving, or waiting to be handed over."""
return sum(
1
for w in self.coordinator.storage.get_wishes()
if w.child_id == self.child_id and w.status in ("pending", "active", "redeem_requested")
)
@property
def extra_state_attributes(self) -> dict:
return {
"wishlist_child_id": self.child_id,
"wishes": self.coordinator.wishlist_sensor_rows(self.child_id),
"wish_max_open": WISH_MAX_OPEN_PER_CHILD,
}
class ChildWatchSensor(TaskMateBaseSensor):
"""A child's day in a few characters, for watch complications (#978).
The state is a short, already-translated phrase ("3 left", "All done")
because the companion apps render complication templates server-side and
show the raw state — the frontend's state translation never reaches the
wrist. The attributes are a handful of scalars so templates can build
gauges and rings (``progress`` is 0–1) without touching the heavy sensors.
"""
_attr_icon = "mdi:watch"
_attr_translation_key = "child_watch"
def __init__(
self,
coordinator: TaskMateCoordinator,
entry: ConfigEntry,
child: Child,
) -> None:
super().__init__(coordinator, entry)
self.child_id = child.id
self._attr_unique_id = f"{entry.entry_id}_{child.id}_watch"
self._attr_name = f"{child.name} Watch"
self._state_strings: dict[str, str] = dict(DEFAULT_STATE_STRINGS)
self._summary_key: tuple | None = None
self._summary: dict | None = None
def _current_summary(self) -> dict | None:
"""The summary once per coordinator update — state and attributes share it."""
key = (
id(self.coordinator.data),
getattr(self.coordinator, "external_state_version", 0),
dt_util.as_local(dt_util.now()).date(),
)
if key != self._summary_key:
self._summary = watch_summary(self.coordinator, self.child_id)
self._summary_key = key
return self._summary
async def async_added_to_hass(self) -> None:
"""Load the state phrases in the server's language."""
await super().async_added_to_hass()
self._state_strings = await _async_watch_state_strings(self.hass)
@property
def native_value(self) -> str | None:
return watch_state(self._current_summary(), self._state_strings)
@property
def extra_state_attributes(self) -> dict:
return self._current_summary() or {}
async def _async_watch_state_strings(hass: HomeAssistant) -> dict[str, str]:
"""The watch sensor's state phrases from TaskMate's entity translations.
Falls back to English per phrase, so a missing or broken translation
never leaves the sensor without a state.
"""
strings = dict(DEFAULT_STATE_STRINGS)
try:
from homeassistant.helpers.translation import async_get_translations
translations = await async_get_translations(hass, hass.config.language, "entity", {DOMAIN})
except Exception: # noqa: BLE001 - translations are cosmetic; never block the sensor
_LOGGER.debug("Could not load TaskMate watch translations", exc_info=True)
return strings
prefix = f"component.{DOMAIN}.entity.sensor.child_watch.state."
for key in strings:
value = translations.get(prefix + key) if isinstance(translations, dict) else None
if isinstance(value, str) and value:
strings[key] = value
return strings
class ChildBadgesSensor(TaskMateBaseSensor):
"""Sensor exposing a child's earned and available badges."""
@@ -1390,7 +1803,8 @@ class PendingApprovalsSensor(TaskMateBaseSensor):
completion_details = []
for comp in pending_completions:
child = self.coordinator.get_child(comp.child_id)
chore = self.coordinator.get_chore(comp.chore_id)
# A bounty's completion (#931) is approved in the same queue.
chore = self.coordinator.get_chore(comp.chore_id) or self.coordinator.bounty_completion_chore(comp)
if child and chore:
# Bonus sub-task completions carry the sub-task's own name and
# points; mirror _build_todays_completions so cards reading this
@@ -1422,6 +1836,8 @@ class PendingApprovalsSensor(TaskMateBaseSensor):
}
if timed_secs > 0:
detail["timed_duration_seconds"] = timed_secs
if getattr(comp, "bounty_id", ""):
detail["bounty_id"] = comp.bounty_id
# Bare (unsigned) path; the card signs per-viewer via
# auth/sign_path so no self-authenticating URL lands in this
# world-readable attribute.
@@ -1443,6 +1859,8 @@ class PendingApprovalsSensor(TaskMateBaseSensor):
for claim in pending_rewards:
child = self.coordinator.get_child(claim.child_id)
reward = self.coordinator.get_reward(claim.reward_id)
if reward is None and getattr(claim, "wish_id", ""):
reward = _wish_as_reward(self.coordinator.get_wish(claim.wish_id))
if child and reward:
reward_details.append(
{
@@ -1458,7 +1876,7 @@ class PendingApprovalsSensor(TaskMateBaseSensor):
)
mandatory_misses = self.coordinator.mandatory_misses_state()
return {
attrs = {
"pending_chore_completions": len(pending_completions),
"pending_reward_claims": len(pending_rewards),
"pending_mandatory_misses": len(mandatory_misses),
@@ -1466,3 +1884,10 @@ class PendingApprovalsSensor(TaskMateBaseSensor):
"reward_claims": reward_details,
"mandatory_misses": mandatory_misses,
}
# Quality rating (#927): tells approval cards to draw the star picker,
# and what each star pays. Absent entirely while the feature is off.
if self.coordinator.quality_rating_enabled():
attrs["quality_rating"] = {
"multipliers": [round(m, 3) for _, m in sorted(self.coordinator.quality_rating_multipliers().items())]
}
return attrs
+664
View File
@@ -86,6 +86,15 @@ approve_chore:
min: 0
max: 9999
mode: box
rating:
name: Quality rating
description: Optional 1-3 star rating of how well the chore was done. When quality ratings are enabled it scales the points by that star's multiplier; leave empty to pay 100%.
required: false
selector:
number:
min: 1
max: 3
mode: slider
approve_all_chores:
name: Approve All Chores
@@ -97,6 +106,15 @@ approve_all_chores:
required: false
selector:
object:
rating:
name: Quality rating
description: Optional 1-3 star rating applied to every chore approved by this call (quality ratings must be enabled).
required: false
selector:
number:
min: 1
max: 3
mode: slider
reject_chore:
name: Reject Chore
@@ -108,6 +126,12 @@ reject_chore:
required: true
selector:
text:
reason:
name: Reason
description: Optional reason shown to the child
required: false
selector:
text:
apply_mandatory_penalty:
name: Apply Mandatory Penalty
@@ -153,6 +177,17 @@ undo_transaction:
selector:
text:
undo_chore:
name: Undo Chore
description: Let a child take back their own chore — a pending submission until a parent reviews it, or an automatically approved one within the chore undo window. Off unless the window is set above 0 in TaskMate settings; anything a parent has reviewed needs a parent to undo it.
fields:
completion_id:
name: Completion ID
description: The ID of the child's chore completion to take back
required: true
selector:
text:
undo_chore_approval:
name: Undo Chore Approval
description: Reverse an accidental chore approval — removes the awarded points and returns the completion to pending so it can be approved again.
@@ -217,6 +252,26 @@ gift_points:
max: 10000
mode: box
adjust_streak_freezes:
name: Adjust Streak Freezes
description: Give a child streak-freeze tokens (positive amount) or take some away (negative). A grant never goes past the streak_freeze_max cap.
fields:
child_id:
name: Child ID
description: The ID of the child whose tokens change
required: true
selector:
text:
amount:
name: Amount
description: Tokens to add (positive) or remove (negative)
required: true
selector:
number:
min: -10
max: 10
mode: box
request_swap:
name: Request Chore Swap
description: A child requests to take over today's rotation assignment of a chore (pending parent approval).
@@ -364,6 +419,12 @@ reject_reward:
required: true
selector:
text:
reason:
name: Reason
description: Optional reason shown to the child
required: false
selector:
text:
preview_sound:
name: Preview Sound
@@ -726,6 +787,41 @@ add_chore:
min: 0
max: 10000
mode: box
team_size:
name: Team size
description: >-
Make this a teamwork chore that only counts once this many different children have joined
it. Each child's Done tap joins; when the team is full it submits for all of them. 0 = a
normal chore.
required: false
default: 0
selector:
number:
min: 0
max: 10
mode: box
team_points_mode:
name: Team points
description: >-
How a teamwork chore pays out — "each" gives every participant the full points, "split"
divides them evenly (rounded down).
required: false
default: each
selector:
select:
options:
- each
- split
team_bonus:
name: Team bonus
description: Extra points each participant earns when a teamwork chore is completed.
required: false
default: 0
selector:
number:
min: 0
max: 10000
mode: box
read_aloud:
name: Read Chores Aloud
@@ -887,6 +983,252 @@ start_timed_task:
selector:
text:
leave_team_chore:
name: Leave Teamwork Chore
description: Take a child back out of a teamwork chore they joined today, before the team is complete
fields:
chore_id:
name: Chore ID
description: The ID of the teamwork chore
required: true
selector:
text:
child_id:
name: Child ID
description: The ID of the child leaving the team
required: true
selector:
text:
complete_next_chore:
name: Complete Next Chore
description: >-
Complete the child's next outstanding chore, in the order the child card shows them. Chores that need a photo, a note, a timer or a team are skipped. Made for watch buttons and shortcuts; normal approval rules apply.
fields:
child_id:
name: Child ID
description: The ID of the child whose next chore to complete
required: true
selector:
text:
post_bounty:
name: Post Bounty
description: >-
Post a one-off job to the bounty board for a fixed number of points. Any eligible child can claim it; finishing it goes through the normal approval queue.
fields:
title:
name: Title
description: "What the job is, e.g. \"Wash the car\""
required: true
selector:
text:
points:
name: Points
description: "Fixed points paid when a parent approves the finished job"
required: true
selector:
number:
min: 1
max: 100000
mode: box
description:
name: Description
description: "Optional extra detail for the children"
required: false
selector:
text:
multiline: true
icon:
name: Icon
description: "MDI icon shown on the board"
required: false
selector:
icon:
expires_at:
name: Expires at
description: "Optional date and time the bounty leaves the board if nobody has claimed it. Leave empty for no expiry."
required: false
selector:
datetime:
eligible_child_ids:
name: Eligible children
description: "IDs of the children who may claim it. Empty = every child."
required: false
selector:
object:
claim_hours:
name: Claim lock (hours)
description: "How long a claim lasts before the bounty goes back on the board"
required: false
default: 2
selector:
number:
min: 1
max: 48
unit_of_measurement: h
mode: box
require_photo:
name: Require photo
description: "The child must attach a photo when marking it done"
required: false
default: false
selector:
boolean:
notify_children:
name: Notify children
description: "Send the \"New bounty\" notification to the eligible children"
required: false
default: true
selector:
boolean:
update_bounty:
name: Update Bounty
description: >-
Edit a bounty. Once it has been claimed, only its points and expiry can change.
fields:
bounty_id:
name: Bounty ID
description: "The ID of the bounty to edit"
required: true
selector:
text:
title:
name: Title
description: "What the job is, e.g. \"Wash the car\""
required: false
selector:
text:
points:
name: Points
description: "Fixed points paid when a parent approves the finished job"
required: false
selector:
number:
min: 1
max: 100000
mode: box
description:
name: Description
description: "Optional extra detail for the children"
required: false
selector:
text:
multiline: true
icon:
name: Icon
description: "MDI icon shown on the board"
required: false
selector:
icon:
expires_at:
name: Expires at
description: "Optional date and time the bounty leaves the board if nobody has claimed it. Leave empty for no expiry."
required: false
selector:
datetime:
eligible_child_ids:
name: Eligible children
description: "IDs of the children who may claim it. Empty = every child."
required: false
selector:
object:
claim_hours:
name: Claim lock (hours)
description: "How long a claim lasts before the bounty goes back on the board"
required: false
selector:
number:
min: 1
max: 48
unit_of_measurement: h
mode: box
require_photo:
name: Require photo
description: "The child must attach a photo when marking it done"
required: false
selector:
boolean:
notify_children:
name: Notify children
description: "Send the \"New bounty\" notification to the eligible children"
required: false
selector:
boolean:
remove_bounty:
name: Remove Bounty
description: >-
Take a bounty off the board. Not allowed while a finished job waits for approval.
fields:
bounty_id:
name: Bounty ID
description: "The ID of the bounty to remove"
required: true
selector:
text:
claim_bounty:
name: Claim Bounty
description: >-
Claim a bounty for a child, locking it to them for its claim period. A child can hold one claim at a time.
fields:
bounty_id:
name: Bounty ID
description: "The ID of the bounty to claim"
required: true
selector:
text:
child_id:
name: Child ID
description: "The ID of the child claiming it"
required: true
selector:
text:
give_back_bounty:
name: Give Back Bounty
description: >-
Hand a claimed bounty back to the board so another child can take it
fields:
bounty_id:
name: Bounty ID
description: "The ID of the claimed bounty"
required: true
selector:
text:
child_id:
name: Child ID
description: "The ID of the child who claimed it"
required: true
selector:
text:
complete_bounty:
name: Complete Bounty
description: >-
Mark a claimed bounty as done. It goes to the approval queue like a chore.
fields:
bounty_id:
name: Bounty ID
description: "The ID of the claimed bounty"
required: true
selector:
text:
child_id:
name: Child ID
description: "The ID of the child who claimed it"
required: true
selector:
text:
photo_url:
name: Photo URL
description: "Photo of the finished job (required when the bounty asks for one)"
required: false
selector:
text:
pause_timed_task:
name: Pause Timed Task
description: Pause a running timed task timer
@@ -1075,3 +1417,325 @@ revoke_badge:
rebuild_badges:
name: Rebuild Badges
description: Re-evaluate all badges across all children silently (no notifications, no point bonuses).
# Wishlist (#932)
add_wish:
name: Add Wish
description: A child adds something they want to save for. It waits for a parent to approve it.
fields:
child_id:
name: Child ID
description: The ID of the child making the wish
required: true
selector:
text:
name:
name: Name
description: What the child wants
required: true
selector:
text:
target:
name: Target
description: Suggested number of points it costs. The parent can change it when approving.
required: true
selector:
number:
min: 1
max: 100000
mode: box
link:
name: Link
description: Optional web address (http or https) for the thing
required: false
selector:
text:
type: url
photo_url:
name: Picture
description: Optional picture just uploaded through the TaskMate photo endpoint
required: false
selector:
text:
withdraw_wish:
name: Withdraw Wish
description: A child takes back a wish that is still waiting for approval, or clears one that was declined.
fields:
wish_id:
name: Wish ID
description: The ID of the wish
required: true
selector:
text:
child_id:
name: Child ID
description: The ID of the child whose wish it is
required: true
selector:
text:
move_points_to_wish:
name: Move Points to Wish
description: Move points from a child's spendable balance into one of their approved wishes.
fields:
wish_id:
name: Wish ID
description: The ID of the wish
required: true
selector:
text:
child_id:
name: Child ID
description: The ID of the child whose wish it is
required: true
selector:
text:
points:
name: Points
description: How many points to move (capped at the balance and at what the wish still needs)
required: true
selector:
number:
min: 1
max: 100000
mode: box
take_points_from_wish:
name: Take Points from Wish
description: Give some of a child's own saved points back from a wish. Pledged points never come out.
fields:
wish_id:
name: Wish ID
description: The ID of the wish
required: true
selector:
text:
child_id:
name: Child ID
description: The ID of the child whose wish it is
required: true
selector:
text:
points:
name: Points
description: How many saved points to take back (capped at what the child saved)
required: true
selector:
number:
min: 1
max: 100000
mode: box
request_wish_redeem:
name: Ask to Redeem Wish
description: A fully funded wish becomes a reward claim waiting for the parent's approval.
fields:
wish_id:
name: Wish ID
description: The ID of the wish
required: true
selector:
text:
child_id:
name: Child ID
description: The ID of the child whose wish it is
required: true
selector:
text:
approve_wish:
name: Approve Wish
description: Turn a waiting wish into a savings goal, optionally at a different target.
fields:
wish_id:
name: Wish ID
description: The ID of the wish
required: true
selector:
text:
target:
name: Target
description: Points the wish costs. Leave empty to keep the child's suggestion.
required: false
selector:
number:
min: 1
max: 100000
mode: box
decline_wish:
name: Decline Wish
description: Say no to a wish. Saved points go back to the child and pledges are voided.
fields:
wish_id:
name: Wish ID
description: The ID of the wish
required: true
selector:
text:
reason:
name: Reason
description: Optional reason shown to the child
required: false
selector:
text:
multiline: true
pledge_to_wish:
name: Pledge to Wish
description: Record points a relative chips in towards a child's wish. They never touch any balance.
fields:
wish_id:
name: Wish ID
description: The ID of the wish
required: true
selector:
text:
name:
name: From
description: Who the pledge is from, e.g. Grandma
required: true
selector:
text:
points:
name: Points
description: How many points to pledge (capped at what the wish still needs)
required: true
selector:
number:
min: 1
max: 100000
mode: box
message:
name: Message
description: Optional message for the child
required: false
selector:
text:
remove_wish_pledge:
name: Remove Wish Pledge
description: Undo a pledge. Allowed until the wish is redeemed.
fields:
wish_id:
name: Wish ID
description: The ID of the wish
required: true
selector:
text:
pledge_id:
name: Pledge ID
description: The ID of the pledge to remove
required: true
selector:
text:
remove_wish:
name: Remove Wish
description: Delete a wish. Saved points go back to the child and pledges are voided.
fields:
wish_id:
name: Wish ID
description: The ID of the wish
required: true
selector:
text:
start_inspection:
name: Start Inspection
description: >-
Flag a chore approved in the last 24 hours for a surprise inspection. Pass or fail it before the window closes; an undecided inspection closes quietly.
fields:
completion_id:
name: Completion ID
description: The ID of the approved chore completion to inspect
required: true
selector:
text:
bonus:
name: Bonus
description: Points paid if it passes. Defaults to the Settings value.
required: false
selector:
number:
min: 0
max: 10000
mode: box
window:
name: Window
description: "How long you have to check it: 1h, 2h, 4h or bed (until bedtime). Defaults to the Settings value."
required: false
selector:
select:
options:
- "1h"
- "2h"
- "4h"
- "bed"
tell_child:
name: Tell the child
description: Show "Inspection coming" on the child's card now. Turn off for a secret check.
required: false
selector:
boolean:
pass_inspection:
name: Pass Inspection
description: The inspected chore passed. Pays the bonus as a normal points entry and celebrates on the child's card.
fields:
inspection_id:
name: Inspection ID
description: The ID of the open inspection
required: true
selector:
text:
bonus:
name: Bonus
description: Points to pay. Defaults to the bonus set when the inspection started.
required: false
selector:
number:
min: 0
max: 10000
mode: box
note:
name: Note
description: Optional note shown to the child
required: false
selector:
text:
fail_inspection:
name: Fail Inspection
description: The inspected chore didn't pass. No points are taken. It can be sent back to redo; the redo earns nothing extra.
fields:
inspection_id:
name: Inspection ID
description: The ID of the open inspection
required: true
selector:
text:
redo:
name: Send back to redo
description: Put the chore back on the child's list as not done for today. Defaults to the Settings value.
required: false
selector:
boolean:
note:
name: Note
description: Optional note shown to the child (what needs fixing)
required: false
selector:
text:
cancel_inspection:
name: Cancel Inspection
description: Call off an open inspection, as if it never started.
fields:
inspection_id:
name: Inspection ID
description: The ID of the open inspection
required: true
selector:
text:
+359 -5
View File
@@ -9,11 +9,13 @@ from typing import Any
from homeassistant.core import HomeAssistant
from homeassistant.helpers.storage import Store
from .const import DEFAULT_NOTIFICATION_NAV_URL, DOMAIN
from .const import DEFAULT_NOTIFICATION_NAV_URL, DEFAULT_PRESENCE_ARRIVAL_MIN_AWAY, DOMAIN
from .models import (
Auction,
AwardedBadge,
Badge,
Bonus,
Bounty,
Challenge,
Child,
Chore,
@@ -33,6 +35,7 @@ from .models import (
ScheduledChange,
TaskGroup,
TimedSession,
Wish,
parse_datetime,
)
@@ -122,6 +125,10 @@ class TaskMateStorage:
if "career_score_history" not in self._data:
self._data["career_score_history"] = {}
# Per-child daily done/total for the admin panel's Today page (#966)
if "daily_progress" not in self._data:
self._data["daily_progress"] = {}
# Ensure templates store exists
if "templates" not in self._data:
self._data["templates"] = []
@@ -168,6 +175,12 @@ class TaskMateStorage:
self._migrate_nav_url_default()
# Deleting a child or parent used to leave their notification routes
# and missed-mandatory records behind (#946). Sweep up what existing
# installs already carry; like the swap-request prune above it is a
# cheap filter, so it runs on every load.
self._drop_dead_recipient_records()
# Badge migration / seeding
self._seed_builtin_badges(is_fresh=is_fresh)
@@ -368,6 +381,44 @@ class TaskMateStorage:
"""Remove a child and cascade-delete their awarded badges."""
self._data["children"] = [c for c in self._data.get("children", []) if c.get("id") != child_id]
self.remove_awards_for_child(child_id)
self.set_kiosk_pin_hash(child_id, "")
if self._data.get("rejections"):
self._data["rejections"] = [r for r in self.get_rejections() if r.get("child_id") != child_id]
if self.get_inspections():
self.set_inspections([i for i in self.get_inspections() if i.get("child_id") != child_id])
# Per-child maps kept in settings (#946): the birthday guard and a
# Custom recap schedule would otherwise outlive the child.
settings = self._data.get("settings", {})
for key in ("birthday_celebrated", "recap_child_frequencies"):
value = settings.get(key)
if isinstance(value, dict) and child_id in value:
settings[key] = {k: v for k, v in value.items() if k != child_id}
# Kiosk PINs (#930): {child_id: salted hash}. Kept apart from the child
# records so the hash never rides along wherever children are serialised
# (get_state, sensor attributes). A missing or malformed map reads as empty.
def _kiosk_pins(self) -> dict:
pins = self._data.get("kiosk_pins")
return pins if isinstance(pins, dict) else {}
def get_kiosk_pin_hash(self, child_id: str) -> str:
value = self._kiosk_pins().get(child_id, "")
return value if isinstance(value, str) else ""
def get_kiosk_pin_child_ids(self) -> list[str]:
return [cid for cid, value in self._kiosk_pins().items() if isinstance(value, str) and value]
def set_kiosk_pin_hash(self, child_id: str, value: str) -> None:
"""Store a child's PIN hash; an empty value removes it."""
pins = dict(self._kiosk_pins())
if value:
pins[child_id] = value
else:
pins.pop(child_id, None)
if pins:
self._data["kiosk_pins"] = pins
else:
self._data.pop("kiosk_pins", None)
# Chores management
def get_chores(self) -> list[Chore]:
@@ -490,6 +541,12 @@ class TaskMateStorage:
"""Remove a mandatory-miss item by id."""
self._data["mandatory_misses"] = [m for m in self._data.get("mandatory_misses", []) if m.get("id") != miss_id]
def remove_mandatory_misses_for_child(self, child_id: str) -> None:
"""Drop a deleted child's mandatory-miss review items (#946)."""
self._data["mandatory_misses"] = [
m for m in self._data.get("mandatory_misses", []) if m.get("child_id") != child_id
]
def replace_mandatory_misses(self, misses: list[MandatoryMiss]) -> None:
"""Replace the whole mandatory-miss collection."""
self._data["mandatory_misses"] = [m.to_dict() for m in misses]
@@ -772,6 +829,65 @@ class TaskMateStorage:
return
rows.append(p.to_dict())
def remove_notification_recipient(self, recipient_id: str) -> bool:
"""Drop a recipient from every notification route and custom reminder.
``recipient_id`` is the prefixed id (``child:<id>`` / ``parent:<id>``).
Returns True if anything changed.
"""
changed = False
for raw in (self._data.get("notification_config", {}) or {}).values():
routes = raw.get("routes") if isinstance(raw, dict) else None
if isinstance(routes, dict) and recipient_id in routes:
routes.pop(recipient_id)
changed = True
for row in self._data.get("custom_notifications", []) or []:
ids = row.get("recipient_ids") if isinstance(row, dict) else None
if isinstance(ids, list) and recipient_id in ids:
row["recipient_ids"] = [r for r in ids if r != recipient_id]
changed = True
return changed
def _drop_dead_recipient_records(self) -> None:
"""Prune routes, reminders and misses naming a deleted child or parent (#946).
Called from ``async_load``. Only ``child:``/``parent:`` recipient ids
are judged — anything else is left alone.
"""
child_ids = {c.get("id") for c in self._data.get("children", [])}
parent_ids = {p.get("id") for p in self._data.get("parent_recipients", [])}
def _dead(rid: str) -> bool:
if rid.startswith("child:"):
return rid.split(":", 1)[1] not in child_ids
if rid.startswith("parent:"):
return rid not in parent_ids
return False
dead: set[str] = set()
for raw in (self._data.get("notification_config", {}) or {}).values():
routes = raw.get("routes") if isinstance(raw, dict) else None
if isinstance(routes, dict):
dead.update(rid for rid in routes if isinstance(rid, str) and _dead(rid))
for row in self._data.get("custom_notifications", []) or []:
ids = row.get("recipient_ids") if isinstance(row, dict) else None
if isinstance(ids, list):
dead.update(rid for rid in ids if isinstance(rid, str) and _dead(rid))
for rid in dead:
self.remove_notification_recipient(rid)
misses = self._data.get("mandatory_misses")
kept = [m for m in misses or [] if m.get("child_id") in child_ids]
if misses and len(kept) != len(misses):
self._data["mandatory_misses"] = kept
if dead or (misses and len(kept) != len(misses)):
_LOGGER.debug(
"Dropped %d orphaned notification recipient(s) and %d orphaned mandatory miss(es)",
len(dead),
len(misses or []) - len(kept),
)
def delete_parent_recipient(self, parent_id: str) -> None:
self._data["parent_recipients"] = [
r for r in self._data.get("parent_recipients", []) if r.get("id") != parent_id
@@ -841,6 +957,20 @@ class TaskMateStorage:
return []
return [x for x in raw if isinstance(x, str) and x]
def get_chore_undo_seconds(self) -> int:
"""Seconds a child has to undo their own auto-approved chore (#918).
0 (the default) switches child undo off entirely. Anything stored that
isn't a whole number reads as 0, and the value is clamped to 0..3600.
"""
value = (self._data.get("settings", {}) or {}).get("chore_undo_seconds", 0)
if isinstance(value, bool):
return 0
try:
return max(0, min(3600, int(value)))
except (TypeError, ValueError, OverflowError):
return 0
def get_require_linked_child(self) -> bool:
"""True when acting *as* a child requires that child to be linked.
@@ -880,6 +1010,20 @@ class TaskMateStorage:
s["mandatory_escalation_reminder_minutes"] = max(1, int(reminder_minutes))
s["mandatory_escalation_parent_minutes"] = max(1, int(parent_minutes))
# --- presence-aware reminders (#926) ---
def get_presence_arrival_min_away(self) -> int:
"""Minutes a child must have been away for their arrival to earn a nudge."""
try:
value = (self._data.get("settings", {}) or {}).get(
"presence_arrival_min_away", DEFAULT_PRESENCE_ARRIVAL_MIN_AWAY
)
return max(0, int(value))
except (TypeError, ValueError):
return DEFAULT_PRESENCE_ARRIVAL_MIN_AWAY
def set_presence_arrival_min_away(self, minutes: int) -> None:
self._data.setdefault("settings", {})["presence_arrival_min_away"] = max(0, int(minutes))
# Task groups management
def get_task_groups(self) -> list[TaskGroup]:
"""Get all task groups."""
@@ -960,8 +1104,13 @@ class TaskMateStorage:
"""Get all points transactions."""
return [PointsTransaction.from_dict(t) for t in self._data.get("points_transactions", [])]
def add_points_transaction(self, transaction: PointsTransaction) -> None:
"""Add a points transaction record."""
def add_points_transaction(self, transaction: PointsTransaction, *, count_for_season: bool = True) -> None:
"""Add a points transaction record.
``count_for_season=False`` records a positive row that returns the
child's own points rather than awarding new ones (e.g. taking savings
back out of a wish, #932), so it must not count towards the leaderboard.
"""
if "points_transactions" not in self._data:
self._data["points_transactions"] = []
self._data["points_transactions"].append(transaction.to_dict())
@@ -969,7 +1118,7 @@ class TaskMateStorage:
# Accumulate season (leaderboard) points from every positive award here,
# the single choke point all awards flow through — the rolling 200-cap on
# transactions makes them unreliable for a monthly total (FEAT-2).
if transaction.points > 0:
if transaction.points > 0 and count_for_season:
self.record_season_points(transaction.child_id, transaction.points, transaction.created_at)
# Keep only the last 200 transactions to avoid unbounded storage growth
@@ -1059,6 +1208,41 @@ class TaskMateStorage:
self._data["audit_log"] = []
self._data["audit_log_dropped"] = 0
# ── Reject reasons (#976) ────────────────────────────────────────────
# Rejection deletes the completion / claim, so the reason a parent gave is
# kept here instead: small dicts (see coord_rejections), newest last.
def get_rejections(self) -> list[dict]:
rows = self._data.get("rejections")
return [r for r in rows if isinstance(r, dict)] if isinstance(rows, list) else []
def set_rejections(self, rows: list[dict]) -> None:
self._data["rejections"] = list(rows)
# ── Surprise inspections (#981) ──────────────────────────────────────
# Their own key: {"items": [inspection dicts, see coord_inspections],
# "last_pick": ISO date of the last random daily pick, "last_pick_child"}.
def _inspection_store(self) -> dict:
store = self._data.get("inspections")
if not isinstance(store, dict):
store = {"items": []}
self._data["inspections"] = store
return store
def get_inspections(self) -> list[dict]:
store = self._data.get("inspections")
rows = store.get("items") if isinstance(store, dict) else None
return [dict(r) for r in rows if isinstance(r, dict)] if isinstance(rows, list) else []
def set_inspections(self, rows: list[dict]) -> None:
self._inspection_store()["items"] = [dict(r) for r in rows]
def get_inspection_meta(self, key: str, default: str = "") -> str:
store = self._data.get("inspections")
return str(store.get(key) or default) if isinstance(store, dict) else default
def set_inspection_meta(self, key: str, value: str) -> None:
self._inspection_store()[key] = value
# ── Chore swap requests ──────────────────────────────────────────────
def get_swap_requests(self) -> list[dict]:
return list(self._data.get("swap_requests", []))
@@ -1132,6 +1316,137 @@ class TaskMateStorage:
if r.get("requester_id") != child_id and r.get("from_child_id") != child_id
]
# ── Teamwork chore joins (#928) ─────────────────────────────────────
# {chore_id: {"date": "YYYY-MM-DD", "joined": [{"child_id", "joined_at",
# "photo_url"?}]}}. Runtime state for the occurrence in progress, kept out
# of the chore record so an editor save can never wipe (or resurrect) it.
# Only the current day's entry counts; anything older is dead on read.
def get_team_joins(self, chore_id: str, day: str) -> list[dict]:
entry = self._data.get("team_joins", {}).get(chore_id)
if not isinstance(entry, dict) or entry.get("date") != day:
return []
return [dict(j) for j in entry.get("joined", []) if isinstance(j, dict) and j.get("child_id")]
def set_team_joins(self, chore_id: str, day: str, joined: list[dict]) -> None:
store = self._data.setdefault("team_joins", {})
if joined:
store[chore_id] = {"date": day, "joined": [dict(j) for j in joined]}
else:
store.pop(chore_id, None)
def remove_team_joins_for_chore(self, chore_id: str) -> None:
self._data.get("team_joins", {}).pop(chore_id, None)
def remove_team_joins_for_child(self, child_id: str) -> None:
"""Take a deleted child out of every team they had joined."""
store = self._data.get("team_joins", {})
for chore_id in list(store):
entry = store[chore_id]
kept = [j for j in entry.get("joined", []) if j.get("child_id") != child_id]
if kept:
entry["joined"] = kept
else:
store.pop(chore_id, None)
def prune_team_joins(self, keep_date: str) -> None:
"""Drop joins from any day but keep_date — the occurrence rolled over."""
store = self._data.get("team_joins", {})
for chore_id in list(store):
if store[chore_id].get("date") != keep_date:
store.pop(chore_id, None)
# ── Wishlist (#932) ──────────────────────────────────────────────────
# Wishes are their own records, not rewards: a child creates them and a
# parent approves them. A wish's own saved points and pledges live on the
# record; the reward claim a funded wish turns into points back at it.
def get_wishes(self) -> list[Wish]:
return [Wish.from_dict(w) for w in self._data.get("wishes", []) if isinstance(w, dict)]
def get_wish(self, wish_id: str) -> Wish | None:
for w in self._data.get("wishes", []):
if isinstance(w, dict) and w.get("id") == wish_id:
return Wish.from_dict(w)
return None
def upsert_wish(self, wish: Wish) -> None:
rows = self._data.setdefault("wishes", [])
for i, row in enumerate(rows):
if isinstance(row, dict) and row.get("id") == wish.id:
rows[i] = wish.to_dict()
return
rows.append(wish.to_dict())
def remove_wish(self, wish_id: str) -> None:
self._data["wishes"] = [
w for w in self._data.get("wishes", []) if not (isinstance(w, dict) and w.get("id") == wish_id)
]
def remove_wishes_for_child(self, child_id: str) -> list[Wish]:
"""Drop a deleted child's wishes; returns them so their images can go too."""
removed = [w for w in self.get_wishes() if w.child_id == child_id]
self._data["wishes"] = [
w for w in self._data.get("wishes", []) if not (isinstance(w, dict) and w.get("child_id") == child_id)
]
return removed
# ── Bounty board (#931) ──────────────────────────────────────────────
# Their own list, not chores: a bounty is a one-off with a claim lock and
# a lifecycle of its own. Missing on installs from before the feature, so
# every reader defaults it.
def get_bounties(self) -> list[Bounty]:
return [Bounty.from_dict(b) for b in self._data.get("bounties", []) if isinstance(b, dict)]
def get_bounty(self, bounty_id: str) -> Bounty | None:
for b in self._data.get("bounties", []):
if isinstance(b, dict) and b.get("id") == bounty_id:
return Bounty.from_dict(b)
return None
def add_bounty(self, bounty: Bounty) -> None:
self._data.setdefault("bounties", []).append(bounty.to_dict())
def update_bounty(self, bounty: Bounty) -> None:
items = self._data.setdefault("bounties", [])
for i, b in enumerate(items):
if isinstance(b, dict) and b.get("id") == bounty.id:
items[i] = bounty.to_dict()
return
items.append(bounty.to_dict())
def remove_bounty(self, bounty_id: str) -> None:
self._data["bounties"] = [
b for b in self._data.get("bounties", []) if not (isinstance(b, dict) and b.get("id") == bounty_id)
]
# ── Chore auctions (#982) ────────────────────────────────────────────
# Their own list: an auction is about one occurrence of a chore, not the
# chore itself, so the chore record is never touched. Missing on installs
# from before the feature, so every reader defaults it.
def get_auctions(self) -> list[Auction]:
return [Auction.from_dict(a) for a in self._data.get("auctions", []) if isinstance(a, dict)]
def get_auction(self, auction_id: str) -> Auction | None:
for a in self._data.get("auctions", []):
if isinstance(a, dict) and a.get("id") == auction_id:
return Auction.from_dict(a)
return None
def add_auction(self, auction: Auction) -> None:
self._data.setdefault("auctions", []).append(auction.to_dict())
def update_auction(self, auction: Auction) -> None:
items = self._data.setdefault("auctions", [])
for i, a in enumerate(items):
if isinstance(a, dict) and a.get("id") == auction.id:
items[i] = auction.to_dict()
return
items.append(auction.to_dict())
def remove_auction(self, auction_id: str) -> None:
self._data["auctions"] = [
a for a in self._data.get("auctions", []) if not (isinstance(a, dict) and a.get("id") == auction_id)
]
# ── Quests (chore chains) ────────────────────────────────────────────
def get_quests(self) -> list[Quest]:
return [Quest.from_dict(q) for q in self._data.get("quests", [])]
@@ -1249,6 +1564,9 @@ class TaskMateStorage:
"timed_sessions",
"quests",
"challenges",
"wishes",
"bounties",
"auctions",
)
for k in list_keys:
if not isinstance(self._data.get(k), list):
@@ -1293,6 +1611,8 @@ class TaskMateStorage:
"best_streak",
"career_score",
"total_penalties_received",
"streak_freezes",
"streak_freeze_earned_at",
),
"chores": (
"points",
@@ -1308,7 +1628,7 @@ class TaskMateStorage:
"rewards": ("cost", "restock_amount", "unlock_minutes"),
"penalties": ("points",),
"bonuses": ("points",),
"completions": ("points_awarded", "timed_duration_seconds"),
"completions": ("points_awarded", "timed_duration_seconds", "quality_rating"),
"points_transactions": ("points",),
"mandatory_misses": ("penalty_points", "postpone_count", "escalation_stage"),
"pool_allocations": ("allocated_points",),
@@ -1322,10 +1642,15 @@ class TaskMateStorage:
}
_NUMERIC_SETTINGS = (
"history_days",
"chore_undo_seconds",
"weekend_multiplier",
"birthday_points_multiplier",
"difficulty_multiplier_easy",
"difficulty_multiplier_medium",
"difficulty_multiplier_hard",
"quality_rating_multiplier_1",
"quality_rating_multiplier_2",
"quality_rating_multiplier_3",
"calendar_projection_days",
"surprise_bonus_chance",
"surprise_bonus_min",
@@ -1432,6 +1757,13 @@ class TaskMateStorage:
if not is_valid_completion_sound(str(chore.get("completion_sound") or "")):
chore["completion_sound"] = "coin"
# Wishes (#932): a restored backup must not smuggle in a javascript:
# link or a foreign image URL. The model normalises every field it
# reads, so a round trip through it is the sanitiser.
self._data["wishes"] = [
Wish.from_dict(w).to_dict() for w in self._data.get("wishes", []) or [] if isinstance(w, dict)
]
for grp in self._data.get("task_groups", []):
if isinstance(grp, dict) and grp.get("policy") not in TASK_GROUP_POLICIES:
grp["policy"] = "sticky"
@@ -1652,6 +1984,12 @@ class TaskMateStorage:
"""Remove a timed session."""
self._data["timed_sessions"] = [s for s in self._data.get("timed_sessions", []) if s.get("id") != session_id]
def remove_timed_sessions_for_child(self, child_id: str) -> None:
"""Drop a deleted child's timed sessions (#946)."""
self._data["timed_sessions"] = [
s for s in self._data.get("timed_sessions", []) if s.get("child_id") != child_id
]
# Generic settings
def get_setting(self, key: str, default: Any = "") -> Any:
"""Get a generic setting value (may be a bool/number/list, not just str)."""
@@ -1730,6 +2068,22 @@ class TaskMateStorage:
history = self._data.get("career_score_history", {})
history.pop(child_id, None)
# Daily progress (#966): {child_id: [{"date", "due", "done"}]}
def get_daily_progress(self, child_id: str) -> list[dict]:
"""Stored daily done/total entries for a child, oldest first."""
return list(self._data.get("daily_progress", {}).get(child_id, []))
def upsert_daily_progress(self, child_id: str, date_str: str, due: int, done: int, cutoff: str) -> None:
"""Record one day's done/total for a child and drop entries before ``cutoff``."""
history = self._data.setdefault("daily_progress", {})
entries = [e for e in history.get(child_id, []) if e.get("date") != date_str and e.get("date", "") >= cutoff]
entries.append({"date": date_str, "due": due, "done": done})
history[child_id] = sorted(entries, key=lambda e: e["date"])
def remove_daily_progress_for_child(self, child_id: str) -> None:
"""Drop a deleted child's daily progress history."""
self._data.get("daily_progress", {}).pop(child_id, None)
def prune_all_done_flags(self, keep_date: str) -> None:
"""Drop all-chores-done flags for dates other than keep_date.
+491
View File
@@ -15,6 +15,9 @@
},
"abort": {
"already_configured": "TaskMate is already configured"
},
"create_entry": {
"default": "TaskMate is ready. [Open the TaskMate panel](/taskmate-admin) to add your children with the setup wizard, which suggests chores for their ages."
}
},
"selector": {
@@ -179,6 +182,10 @@
"points": {
"name": "Points",
"description": "Award this many points instead of the chore's own value. Leave empty to use the chore's points."
},
"rating": {
"name": "Quality rating",
"description": "Optional 1-3 star rating of how well the chore was done. When quality ratings are enabled it scales the points by that star's multiplier; leave empty to pay 100%."
}
}
},
@@ -189,6 +196,10 @@
"completion_ids": {
"name": "Completion IDs",
"description": "Specific completion IDs to approve; omit to approve all pending"
},
"rating": {
"name": "Quality rating",
"description": "Optional 1-3 star rating applied to every chore approved by this call (quality ratings must be enabled)."
}
}
},
@@ -199,6 +210,20 @@
"completion_id": {
"name": "Completion ID",
"description": "The ID of the chore completion to reject"
},
"reason": {
"name": "Reason",
"description": "Optional reason shown to the child"
}
}
},
"undo_chore": {
"name": "Undo Chore",
"description": "Let a child take back their own chore — a pending submission until a parent reviews it, or an automatically approved one within the chore undo window. Off unless the window is set above 0 in TaskMate settings; anything a parent has reviewed needs a parent to undo it.",
"fields": {
"completion_id": {
"name": "Completion ID",
"description": "The ID of the child's chore completion to take back"
}
}
},
@@ -307,6 +332,10 @@
"claim_id": {
"name": "Claim ID",
"description": "The ID of the reward claim to reject"
},
"reason": {
"name": "Reason",
"description": "Optional reason shown to the child"
}
}
},
@@ -525,6 +554,18 @@
"requires_approval": {
"name": "Requires Approval",
"description": "If true, a parent must approve the completion before points are awarded"
},
"team_size": {
"name": "Team size",
"description": "Make this a teamwork chore that only counts once this many different children have joined it. When the team is full it submits for all of them. 0 = a normal chore."
},
"team_points_mode": {
"name": "Team points",
"description": "How a teamwork chore pays out: \"each\" gives every participant the full points, \"split\" divides them evenly (rounded down)."
},
"team_bonus": {
"name": "Team bonus",
"description": "Extra points each participant earns when a teamwork chore is completed."
}
}
},
@@ -616,6 +657,174 @@
}
}
},
"leave_team_chore": {
"name": "Leave Teamwork Chore",
"description": "Take a child back out of a teamwork chore they joined today, before the team is complete",
"fields": {
"chore_id": {
"name": "Chore ID",
"description": "The ID of the teamwork chore"
},
"child_id": {
"name": "Child ID",
"description": "The ID of the child leaving the team"
}
}
},
"complete_next_chore": {
"name": "Complete Next Chore",
"description": "Complete the child's next outstanding chore, in the order the child card shows them. Chores that need a photo, a note, a timer or a team are skipped. Made for watch buttons and shortcuts; normal approval rules apply.",
"fields": {
"child_id": {
"name": "Child ID",
"description": "The ID of the child whose next chore to complete"
}
}
},
"post_bounty": {
"name": "Post Bounty",
"description": "Post a one-off job to the bounty board for a fixed number of points. Any eligible child can claim it; finishing it goes through the normal approval queue.",
"fields": {
"title": {
"name": "Title",
"description": "What the job is, e.g. \"Wash the car\""
},
"points": {
"name": "Points",
"description": "Fixed points paid when a parent approves the finished job"
},
"description": {
"name": "Description",
"description": "Optional extra detail for the children"
},
"icon": {
"name": "Icon",
"description": "MDI icon shown on the board"
},
"expires_at": {
"name": "Expires at",
"description": "Optional date and time the bounty leaves the board if nobody has claimed it. Leave empty for no expiry."
},
"eligible_child_ids": {
"name": "Eligible children",
"description": "IDs of the children who may claim it. Empty = every child."
},
"claim_hours": {
"name": "Claim lock (hours)",
"description": "How long a claim lasts before the bounty goes back on the board"
},
"require_photo": {
"name": "Require photo",
"description": "The child must attach a photo when marking it done"
},
"notify_children": {
"name": "Notify children",
"description": "Send the \"New bounty\" notification to the eligible children"
}
}
},
"update_bounty": {
"name": "Update Bounty",
"description": "Edit a bounty. Once it has been claimed, only its points and expiry can change.",
"fields": {
"bounty_id": {
"name": "Bounty ID",
"description": "The ID of the bounty to edit"
},
"title": {
"name": "Title",
"description": "What the job is, e.g. \"Wash the car\""
},
"points": {
"name": "Points",
"description": "Fixed points paid when a parent approves the finished job"
},
"description": {
"name": "Description",
"description": "Optional extra detail for the children"
},
"icon": {
"name": "Icon",
"description": "MDI icon shown on the board"
},
"expires_at": {
"name": "Expires at",
"description": "Optional date and time the bounty leaves the board if nobody has claimed it. Leave empty for no expiry."
},
"eligible_child_ids": {
"name": "Eligible children",
"description": "IDs of the children who may claim it. Empty = every child."
},
"claim_hours": {
"name": "Claim lock (hours)",
"description": "How long a claim lasts before the bounty goes back on the board"
},
"require_photo": {
"name": "Require photo",
"description": "The child must attach a photo when marking it done"
},
"notify_children": {
"name": "Notify children",
"description": "Send the \"New bounty\" notification to the eligible children"
}
}
},
"remove_bounty": {
"name": "Remove Bounty",
"description": "Take a bounty off the board. Not allowed while a finished job waits for approval.",
"fields": {
"bounty_id": {
"name": "Bounty ID",
"description": "The ID of the bounty to remove"
}
}
},
"claim_bounty": {
"name": "Claim Bounty",
"description": "Claim a bounty for a child, locking it to them for its claim period. A child can hold one claim at a time.",
"fields": {
"bounty_id": {
"name": "Bounty ID",
"description": "The ID of the bounty to claim"
},
"child_id": {
"name": "Child ID",
"description": "The ID of the child claiming it"
}
}
},
"give_back_bounty": {
"name": "Give Back Bounty",
"description": "Hand a claimed bounty back to the board so another child can take it",
"fields": {
"bounty_id": {
"name": "Bounty ID",
"description": "The ID of the claimed bounty"
},
"child_id": {
"name": "Child ID",
"description": "The ID of the child who claimed it"
}
}
},
"complete_bounty": {
"name": "Complete Bounty",
"description": "Mark a claimed bounty as done. It goes to the approval queue like a chore.",
"fields": {
"bounty_id": {
"name": "Bounty ID",
"description": "The ID of the claimed bounty"
},
"child_id": {
"name": "Child ID",
"description": "The ID of the child who claimed it"
},
"photo_url": {
"name": "Photo URL",
"description": "Photo of the finished job (required when the bounty asks for one)"
}
}
},
"pause_timed_task": {
"name": "Pause Timed Task",
"description": "Pause a running timed task timer",
@@ -818,6 +1027,20 @@
}
}
},
"adjust_streak_freezes": {
"name": "Adjust Streak Freezes",
"description": "Give a child streak-freeze tokens (positive amount) or take some away (negative). A grant never goes past the streak_freeze_max cap.",
"fields": {
"child_id": {
"name": "Child ID",
"description": "The ID of the child whose tokens change"
},
"amount": {
"name": "Amount",
"description": "Tokens to add (positive) or remove (negative)"
}
}
},
"postpone_mandatory_chore": {
"name": "Postpone Mandatory Chore",
"description": "Give a missed mandatory chore another period (or tomorrow) without a penalty, and clear the review item.",
@@ -865,6 +1088,238 @@
"description": "Points to convert to cash and deduct"
}
}
},
"add_wish": {
"name": "Add Wish",
"description": "A child adds something they want to save for. It waits for a parent to approve it.",
"fields": {
"child_id": {
"name": "Child ID",
"description": "The ID of the child making the wish"
},
"name": {
"name": "Name",
"description": "What the child wants"
},
"target": {
"name": "Target",
"description": "Suggested number of points it costs. The parent can change it when approving."
},
"link": {
"name": "Link",
"description": "Optional web address (http or https) for the thing"
},
"photo_url": {
"name": "Picture",
"description": "Optional picture just uploaded through the TaskMate photo endpoint"
}
}
},
"withdraw_wish": {
"name": "Withdraw Wish",
"description": "A child takes back a wish that is still waiting for approval, or clears one that was declined.",
"fields": {
"wish_id": {
"name": "Wish ID",
"description": "The ID of the wish"
},
"child_id": {
"name": "Child ID",
"description": "The ID of the child whose wish it is"
}
}
},
"move_points_to_wish": {
"name": "Move Points to Wish",
"description": "Move points from a child's spendable balance into one of their approved wishes.",
"fields": {
"wish_id": {
"name": "Wish ID",
"description": "The ID of the wish"
},
"child_id": {
"name": "Child ID",
"description": "The ID of the child whose wish it is"
},
"points": {
"name": "Points",
"description": "How many points to move (capped at the balance and at what the wish still needs)"
}
}
},
"take_points_from_wish": {
"name": "Take Points from Wish",
"description": "Give some of a child's own saved points back from a wish. Pledged points never come out.",
"fields": {
"wish_id": {
"name": "Wish ID",
"description": "The ID of the wish"
},
"child_id": {
"name": "Child ID",
"description": "The ID of the child whose wish it is"
},
"points": {
"name": "Points",
"description": "How many saved points to take back (capped at what the child saved)"
}
}
},
"request_wish_redeem": {
"name": "Ask to Redeem Wish",
"description": "A fully funded wish becomes a reward claim waiting for the parent's approval.",
"fields": {
"wish_id": {
"name": "Wish ID",
"description": "The ID of the wish"
},
"child_id": {
"name": "Child ID",
"description": "The ID of the child whose wish it is"
}
}
},
"approve_wish": {
"name": "Approve Wish",
"description": "Turn a waiting wish into a savings goal, optionally at a different target.",
"fields": {
"wish_id": {
"name": "Wish ID",
"description": "The ID of the wish"
},
"target": {
"name": "Target",
"description": "Points the wish costs. Leave empty to keep the child's suggestion."
}
}
},
"decline_wish": {
"name": "Decline Wish",
"description": "Say no to a wish. Saved points go back to the child and pledges are voided.",
"fields": {
"wish_id": {
"name": "Wish ID",
"description": "The ID of the wish"
},
"reason": {
"name": "Reason",
"description": "Optional reason shown to the child"
}
}
},
"pledge_to_wish": {
"name": "Pledge to Wish",
"description": "Record points a relative chips in towards a child's wish. They never touch any balance.",
"fields": {
"wish_id": {
"name": "Wish ID",
"description": "The ID of the wish"
},
"name": {
"name": "From",
"description": "Who the pledge is from, e.g. Grandma"
},
"points": {
"name": "Points",
"description": "How many points to pledge (capped at what the wish still needs)"
},
"message": {
"name": "Message",
"description": "Optional message for the child"
}
}
},
"remove_wish_pledge": {
"name": "Remove Wish Pledge",
"description": "Undo a pledge. Allowed until the wish is redeemed.",
"fields": {
"wish_id": {
"name": "Wish ID",
"description": "The ID of the wish"
},
"pledge_id": {
"name": "Pledge ID",
"description": "The ID of the pledge to remove"
}
}
},
"remove_wish": {
"name": "Remove Wish",
"description": "Delete a wish. Saved points go back to the child and pledges are voided.",
"fields": {
"wish_id": {
"name": "Wish ID",
"description": "The ID of the wish"
}
}
},
"start_inspection": {
"name": "Start Inspection",
"description": "Flag a chore approved in the last 24 hours for a surprise inspection. Pass or fail it before the window closes; an undecided inspection closes quietly.",
"fields": {
"completion_id": {
"name": "Completion ID",
"description": "The ID of the approved chore completion to inspect"
},
"bonus": {
"name": "Bonus",
"description": "Points paid if it passes. Defaults to the Settings value."
},
"window": {
"name": "Window",
"description": "How long you have to check it: 1h, 2h, 4h or bed (until bedtime). Defaults to the Settings value."
},
"tell_child": {
"name": "Tell the child",
"description": "Show \"Inspection coming\" on the child's card now. Turn off for a secret check."
}
}
},
"pass_inspection": {
"name": "Pass Inspection",
"description": "The inspected chore passed. Pays the bonus as a normal points entry and celebrates on the child's card.",
"fields": {
"inspection_id": {
"name": "Inspection ID",
"description": "The ID of the open inspection"
},
"bonus": {
"name": "Bonus",
"description": "Points to pay. Defaults to the bonus set when the inspection started."
},
"note": {
"name": "Note",
"description": "Optional note shown to the child"
}
}
},
"fail_inspection": {
"name": "Fail Inspection",
"description": "The inspected chore didn't pass. No points are taken. It can be sent back to redo; the redo earns nothing extra.",
"fields": {
"inspection_id": {
"name": "Inspection ID",
"description": "The ID of the open inspection"
},
"redo": {
"name": "Send back to redo",
"description": "Put the chore back on the child's list as not done for today. Defaults to the Settings value."
},
"note": {
"name": "Note",
"description": "Optional note shown to the child (what needs fixing)"
}
}
},
"cancel_inspection": {
"name": "Cancel Inspection",
"description": "Call off an open inspection, as if it never started.",
"fields": {
"inspection_id": {
"name": "Inspection ID",
"description": "The ID of the open inspection"
}
}
}
},
"entity": {
@@ -874,6 +1329,13 @@
},
"child_badges": {
"unit_of_measurement": "badges"
},
"child_watch": {
"state": {
"left": "# left",
"all_done": "All done",
"no_chores": "No chores"
}
}
},
"number": {
@@ -892,5 +1354,34 @@
"name": "Card design"
}
}
},
"exceptions": {
"calendar_unknown_event": {
"message": "This event isn't a TaskMate chore on this child's calendar."
},
"calendar_series_not_supported": {
"message": "A repeating chore can only be moved or removed one day at a time from the calendar. To change the whole series, edit the chore in TaskMate."
},
"calendar_repeating_create": {
"message": "Repeating events can't be added from the calendar. Create a repeating chore in TaskMate instead."
},
"calendar_rename_series": {
"message": "Rename a repeating chore in TaskMate. From the calendar you can only move or remove a single day."
},
"calendar_time_follows_chore": {
"message": "A repeating chore keeps its time of day. Only the date of a single occurrence can be changed from the calendar."
},
"calendar_past_date": {
"message": "Chores can't be added to, moved to or moved from a day in the past."
},
"calendar_already_scheduled": {
"message": "{chore} is already on {date}."
},
"calendar_empty_title": {
"message": "Give the chore a title."
},
"calendar_own_event": {
"message": "This event was published by TaskMate itself, so it isn't added as a new chore."
}
}
}
File diff suppressed because it is too large Load Diff
@@ -15,6 +15,9 @@
},
"abort": {
"already_configured": "TaskMate ist bereits konfiguriert"
},
"create_entry": {
"default": "TaskMate ist bereit. [Öffnen Sie das TaskMate-Panel](/taskmate-admin), um Ihre Kinder mit dem Einrichtungsassistenten hinzuzufügen, der altersgerechte Aufgaben vorschlägt."
}
},
"selector": {
@@ -179,6 +182,10 @@
"points": {
"name": "Punkte",
"description": "Diese Punktzahl anstelle des Aufgabenwerts vergeben. Leer lassen, um die Punkte der Aufgabe zu verwenden."
},
"rating": {
"name": "Qualitätsbewertung",
"description": "Optionale Bewertung mit 1-3 Sternen, wie gut die Aufgabe erledigt wurde. Bei aktivierten Qualitätsbewertungen werden die Punkte mit dem Multiplikator dieses Sterns skaliert; leer lassen, um 100 % zu vergeben."
}
}
},
@@ -189,6 +196,10 @@
"completion_ids": {
"name": "Erledigungs-IDs",
"description": "Bestimmte Erledigungs-IDs zum Genehmigen; leer lassen, um alle ausstehenden zu genehmigen"
},
"rating": {
"name": "Qualitätsbewertung",
"description": "Optionale Bewertung mit 1-3 Sternen für jede mit diesem Aufruf genehmigte Aufgabe (Qualitätsbewertungen müssen aktiviert sein)."
}
}
},
@@ -199,6 +210,20 @@
"completion_id": {
"name": "Abschluss-ID",
"description": "Die ID des abzulehnenden Aufgabenabschlusses"
},
"reason": {
"name": "Grund",
"description": "Optionaler Grund, der dem Kind angezeigt wird"
}
}
},
"undo_chore": {
"name": "Aufgabe rückgängig machen",
"description": "Lässt ein Kind seine eigene Aufgabe zurücknehmen — eine offene Einreichung, bis ein Elternteil sie prüft, oder eine automatisch genehmigte innerhalb des Rückgängig-Zeitfensters. Aus, solange das Zeitfenster in den TaskMate-Einstellungen nicht über 0 liegt; alles, was ein Elternteil geprüft hat, kann nur ein Elternteil rückgängig machen.",
"fields": {
"completion_id": {
"name": "Erledigungs-ID",
"description": "Die ID der erledigten Aufgabe des Kindes, die zurückgenommen werden soll"
}
}
},
@@ -307,6 +332,10 @@
"claim_id": {
"name": "Anspruchs-ID",
"description": "Die ID des abzulehnenden Prämienanspruchs"
},
"reason": {
"name": "Grund",
"description": "Optionaler Grund, der dem Kind angezeigt wird"
}
}
},
@@ -525,6 +554,18 @@
"requires_approval": {
"name": "Erfordert eine Genehmigung",
"description": "Wenn dies zutrifft, muss ein Elternteil den Abschluss genehmigen, bevor Punkte vergeben werden"
},
"team_size": {
"name": "Teamgröße",
"description": "Macht daraus eine Teamaufgabe, die erst zählt, wenn so viele verschiedene Kinder mitmachen. Sobald das Team voll ist, wird sie für alle eingereicht. 0 = normale Aufgabe."
},
"team_points_mode": {
"name": "Team-Punkte",
"description": "Wie eine Teamaufgabe auszahlt: „each“ gibt jedem Kind die vollen Punkte, „split“ teilt sie gleichmäßig auf (abgerundet)."
},
"team_bonus": {
"name": "Teambonus",
"description": "Zusätzliche Punkte für jedes beteiligte Kind, wenn eine Teamaufgabe erledigt ist."
}
}
},
@@ -616,6 +657,174 @@
}
}
},
"leave_team_chore": {
"name": "Teamaufgabe verlassen",
"description": "Nimmt ein Kind wieder aus einer Teamaufgabe heraus, der es heute beigetreten ist, bevor das Team vollständig ist",
"fields": {
"chore_id": {
"name": "Aufgaben-ID",
"description": "Die ID der Teamaufgabe"
},
"child_id": {
"name": "Kinder-ID",
"description": "Die ID des Kindes, das das Team verlässt"
}
}
},
"complete_next_chore": {
"name": "Nächste Aufgabe erledigen",
"description": "Erledigt die nächste offene Aufgabe des Kindes in der Reihenfolge, die die Kinderkarte anzeigt. Aufgaben, die ein Foto, eine Notiz, einen Timer oder ein Team brauchen, werden übersprungen. Gedacht für Uhr-Tasten und Verknüpfungen; die normalen Genehmigungsregeln gelten.",
"fields": {
"child_id": {
"name": "Kinder-ID",
"description": "Die ID des Kindes, dessen nächste Aufgabe erledigt werden soll"
}
}
},
"post_bounty": {
"name": "Sonderjob ausschreiben",
"description": "Einen einmaligen Sonderjob für eine feste Punktzahl an die Sonderjob-Tafel hängen. Jedes berechtigte Kind kann ihn übernehmen; die Erledigung läuft durch die normale Freigabe.",
"fields": {
"title": {
"name": "Titel",
"description": "Worum es geht, z. B. \"Auto waschen\""
},
"points": {
"name": "Punkte",
"description": "Feste Punkte, die gezahlt werden, wenn ein Elternteil den erledigten Job freigibt"
},
"description": {
"name": "Beschreibung",
"description": "Optionale Details für die Kinder"
},
"icon": {
"name": "Symbol",
"description": "MDI-Symbol auf der Tafel"
},
"expires_at": {
"name": "Läuft ab am",
"description": "Optionales Datum mit Uhrzeit, zu dem der Sonderjob verschwindet, wenn ihn niemand übernommen hat. Leer lassen für kein Ablaufdatum."
},
"eligible_child_ids": {
"name": "Berechtigte Kinder",
"description": "IDs der Kinder, die ihn übernehmen dürfen. Leer = alle Kinder."
},
"claim_hours": {
"name": "Reservierung (Stunden)",
"description": "Wie lange eine Übernahme gilt, bevor der Sonderjob zurück an die Tafel geht"
},
"require_photo": {
"name": "Foto erforderlich",
"description": "Das Kind muss beim Erledigen ein Foto anhängen"
},
"notify_children": {
"name": "Kinder benachrichtigen",
"description": "Die Benachrichtigung \"Neuer Sonderjob\" an die berechtigten Kinder senden"
}
}
},
"update_bounty": {
"name": "Sonderjob bearbeiten",
"description": "Einen Sonderjob bearbeiten. Nach der Übernahme lassen sich nur noch Punkte und Ablaufzeit ändern.",
"fields": {
"bounty_id": {
"name": "Sonderjob-ID",
"description": "Die ID des zu bearbeitenden Sonderjobs"
},
"title": {
"name": "Titel",
"description": "Worum es geht, z. B. \"Auto waschen\""
},
"points": {
"name": "Punkte",
"description": "Feste Punkte, die gezahlt werden, wenn ein Elternteil den erledigten Job freigibt"
},
"description": {
"name": "Beschreibung",
"description": "Optionale Details für die Kinder"
},
"icon": {
"name": "Symbol",
"description": "MDI-Symbol auf der Tafel"
},
"expires_at": {
"name": "Läuft ab am",
"description": "Optionales Datum mit Uhrzeit, zu dem der Sonderjob verschwindet, wenn ihn niemand übernommen hat. Leer lassen für kein Ablaufdatum."
},
"eligible_child_ids": {
"name": "Berechtigte Kinder",
"description": "IDs der Kinder, die ihn übernehmen dürfen. Leer = alle Kinder."
},
"claim_hours": {
"name": "Reservierung (Stunden)",
"description": "Wie lange eine Übernahme gilt, bevor der Sonderjob zurück an die Tafel geht"
},
"require_photo": {
"name": "Foto erforderlich",
"description": "Das Kind muss beim Erledigen ein Foto anhängen"
},
"notify_children": {
"name": "Kinder benachrichtigen",
"description": "Die Benachrichtigung \"Neuer Sonderjob\" an die berechtigten Kinder senden"
}
}
},
"remove_bounty": {
"name": "Sonderjob entfernen",
"description": "Einen Sonderjob von der Tafel nehmen. Nicht möglich, solange ein erledigter Job auf Freigabe wartet.",
"fields": {
"bounty_id": {
"name": "Sonderjob-ID",
"description": "Die ID des zu entfernenden Sonderjobs"
}
}
},
"claim_bounty": {
"name": "Sonderjob übernehmen",
"description": "Einen Sonderjob für ein Kind übernehmen und für die Reservierungszeit sperren. Ein Kind kann jeweils nur einen halten.",
"fields": {
"bounty_id": {
"name": "Sonderjob-ID",
"description": "Die ID des zu übernehmenden Sonderjobs"
},
"child_id": {
"name": "Kind-ID",
"description": "Die ID des Kindes, das ihn übernimmt"
}
}
},
"give_back_bounty": {
"name": "Sonderjob zurückgeben",
"description": "Einen übernommenen Sonderjob zurück an die Tafel geben, damit ein anderes Kind ihn nehmen kann",
"fields": {
"bounty_id": {
"name": "Sonderjob-ID",
"description": "Die ID des übernommenen Sonderjobs"
},
"child_id": {
"name": "Kind-ID",
"description": "Die ID des Kindes, das ihn übernommen hat"
}
}
},
"complete_bounty": {
"name": "Sonderjob erledigen",
"description": "Einen übernommenen Sonderjob als erledigt melden. Er geht wie eine Aufgabe in die Freigabe.",
"fields": {
"bounty_id": {
"name": "Sonderjob-ID",
"description": "Die ID des übernommenen Sonderjobs"
},
"child_id": {
"name": "Kind-ID",
"description": "Die ID des Kindes, das ihn übernommen hat"
},
"photo_url": {
"name": "Foto-URL",
"description": "Foto des erledigten Jobs (erforderlich, wenn der Sonderjob eines verlangt)"
}
}
},
"pause_timed_task": {
"name": "Zeitbasierte Aufgabe pausieren",
"description": "Pausieren Sie den laufenden Timer einer zeitbasierten Aufgabe",
@@ -818,6 +1027,20 @@
}
}
},
"adjust_streak_freezes": {
"name": "Serienschutz anpassen",
"description": "Gibt einem Kind Serienschutz (positive Anzahl) oder nimmt welchen weg (negative). Eine Gutschrift überschreitet nie die Grenze streak_freeze_max.",
"fields": {
"child_id": {
"name": "Kind-ID",
"description": "Die ID des Kindes, dessen Serienschutz sich ändert"
},
"amount": {
"name": "Anzahl",
"description": "Hinzuzufügender (positiv) oder zu entfernender (negativ) Serienschutz"
}
}
},
"postpone_mandatory_chore": {
"name": "Pflichtaufgabe verschieben",
"description": "Gewährt einer verpassten Pflichtaufgabe ohne Strafe einen weiteren Zeitraum (oder morgen) und entfernt den Prüfeintrag.",
@@ -865,6 +1088,238 @@
"description": "Punkte, die in Bargeld umgerechnet und abgezogen werden"
}
}
},
"add_wish": {
"name": "Wunsch hinzufügen",
"description": "Ein Kind fügt etwas hinzu, auf das es sparen möchte. Es wartet auf die Freigabe durch ein Elternteil.",
"fields": {
"child_id": {
"name": "Kind-ID",
"description": "Die ID des Kindes, das sich etwas wünscht"
},
"name": {
"name": "Name",
"description": "Was sich das Kind wünscht"
},
"target": {
"name": "Ziel",
"description": "Vorgeschlagene Punktzahl, die es kostet. Das Elternteil kann sie bei der Freigabe ändern."
},
"link": {
"name": "Link",
"description": "Optionale Webadresse (http oder https) zum Artikel"
},
"photo_url": {
"name": "Bild",
"description": "Optionales Bild, das gerade über den TaskMate-Foto-Endpunkt hochgeladen wurde"
}
}
},
"withdraw_wish": {
"name": "Wunsch zurückziehen",
"description": "Ein Kind zieht einen Wunsch zurück, der noch auf Freigabe wartet, oder entfernt einen abgelehnten.",
"fields": {
"wish_id": {
"name": "Wunsch-ID",
"description": "Die ID des Wunsches"
},
"child_id": {
"name": "Kind-ID",
"description": "Die ID des Kindes, dem der Wunsch gehört"
}
}
},
"move_points_to_wish": {
"name": "Punkte auf Wunsch übertragen",
"description": "Überträgt Punkte vom verfügbaren Guthaben eines Kindes auf einen seiner freigegebenen Wünsche.",
"fields": {
"wish_id": {
"name": "Wunsch-ID",
"description": "Die ID des Wunsches"
},
"child_id": {
"name": "Kind-ID",
"description": "Die ID des Kindes, dem der Wunsch gehört"
},
"points": {
"name": "Punkte",
"description": "Wie viele Punkte übertragen werden (höchstens das Guthaben und was dem Wunsch noch fehlt)"
}
}
},
"take_points_from_wish": {
"name": "Punkte aus Wunsch nehmen",
"description": "Gibt einem Kind einen Teil seiner eigenen gesparten Punkte aus einem Wunsch zurück. Zugesagte Punkte bleiben immer drin.",
"fields": {
"wish_id": {
"name": "Wunsch-ID",
"description": "Die ID des Wunsches"
},
"child_id": {
"name": "Kind-ID",
"description": "Die ID des Kindes, dem der Wunsch gehört"
},
"points": {
"name": "Punkte",
"description": "Wie viele gesparte Punkte zurückgenommen werden (höchstens das Gesparte des Kindes)"
}
}
},
"request_wish_redeem": {
"name": "Wunsch einlösen",
"description": "Ein voll finanzierter Wunsch wird zu einer Belohnungsanfrage, die auf die Freigabe der Eltern wartet.",
"fields": {
"wish_id": {
"name": "Wunsch-ID",
"description": "Die ID des Wunsches"
},
"child_id": {
"name": "Kind-ID",
"description": "Die ID des Kindes, dem der Wunsch gehört"
}
}
},
"approve_wish": {
"name": "Wunsch freigeben",
"description": "Macht aus einem wartenden Wunsch ein Sparziel, optional mit einem anderen Zielwert.",
"fields": {
"wish_id": {
"name": "Wunsch-ID",
"description": "Die ID des Wunsches"
},
"target": {
"name": "Ziel",
"description": "Punkte, die der Wunsch kostet. Leer lassen, um den Vorschlag des Kindes zu übernehmen."
}
}
},
"decline_wish": {
"name": "Wunsch ablehnen",
"description": "Lehnt einen Wunsch ab. Gesparte Punkte gehen an das Kind zurück, Zusagen verfallen.",
"fields": {
"wish_id": {
"name": "Wunsch-ID",
"description": "Die ID des Wunsches"
},
"reason": {
"name": "Grund",
"description": "Optionaler Grund, der dem Kind angezeigt wird"
}
}
},
"pledge_to_wish": {
"name": "Für Wunsch zusagen",
"description": "Erfasst Punkte, die ein Verwandter zum Wunsch eines Kindes beisteuert. Sie berühren nie ein Guthaben.",
"fields": {
"wish_id": {
"name": "Wunsch-ID",
"description": "Die ID des Wunsches"
},
"name": {
"name": "Von",
"description": "Von wem die Zusage kommt, z. B. Oma"
},
"points": {
"name": "Punkte",
"description": "Wie viele Punkte zugesagt werden (höchstens was dem Wunsch noch fehlt)"
},
"message": {
"name": "Nachricht",
"description": "Optionale Nachricht an das Kind"
}
}
},
"remove_wish_pledge": {
"name": "Zusage entfernen",
"description": "Macht eine Zusage rückgängig. Möglich, bis der Wunsch eingelöst ist.",
"fields": {
"wish_id": {
"name": "Wunsch-ID",
"description": "Die ID des Wunsches"
},
"pledge_id": {
"name": "Zusage-ID",
"description": "Die ID der Zusage, die entfernt werden soll"
}
}
},
"remove_wish": {
"name": "Wunsch entfernen",
"description": "Löscht einen Wunsch. Gesparte Punkte gehen an das Kind zurück, Zusagen verfallen.",
"fields": {
"wish_id": {
"name": "Wunsch-ID",
"description": "Die ID des Wunsches"
}
}
},
"start_inspection": {
"name": "Kontrolle starten",
"description": "Markiert eine in den letzten 24 Stunden bestätigte Aufgabe für eine Überraschungskontrolle. Bestehen oder durchfallen lassen, bevor das Zeitfenster endet; eine offene Kontrolle endet still.",
"fields": {
"completion_id": {
"name": "Erledigungs-ID",
"description": "Die ID der bestätigten Aufgaben-Erledigung, die kontrolliert wird"
},
"bonus": {
"name": "Bonus",
"description": "Punkte, wenn sie besteht. Standard ist der Wert aus den Einstellungen."
},
"window": {
"name": "Zeitfenster",
"description": "Wie lange du zum Prüfen hast: 1h, 2h, 4h oder bed (bis zur Schlafenszeit). Standard ist der Wert aus den Einstellungen."
},
"tell_child": {
"name": "Kind informieren",
"description": "Zeigt jetzt „Kontrolle kommt“ auf der Karte des Kindes. Für eine geheime Kontrolle ausschalten."
}
}
},
"pass_inspection": {
"name": "Kontrolle bestanden",
"description": "Die kontrollierte Aufgabe hat bestanden. Zahlt den Bonus als normalen Punkteeintrag und feiert auf der Karte des Kindes.",
"fields": {
"inspection_id": {
"name": "Kontroll-ID",
"description": "Die ID der offenen Kontrolle"
},
"bonus": {
"name": "Bonus",
"description": "Auszuzahlende Punkte. Standard ist der beim Start festgelegte Bonus."
},
"note": {
"name": "Notiz",
"description": "Optionale Notiz für das Kind"
}
}
},
"fail_inspection": {
"name": "Kontrolle nicht bestanden",
"description": "Die kontrollierte Aufgabe hat nicht bestanden. Es werden keine Punkte abgezogen. Sie kann zum Nachbessern zurückgeschickt werden; das bringt keine zusätzlichen Punkte.",
"fields": {
"inspection_id": {
"name": "Kontroll-ID",
"description": "Die ID der offenen Kontrolle"
},
"redo": {
"name": "Zum Nachbessern zurückschicken",
"description": "Setzt die Aufgabe für heute wieder als nicht erledigt auf die Liste des Kindes. Standard ist der Wert aus den Einstellungen."
},
"note": {
"name": "Notiz",
"description": "Optionale Notiz für das Kind (was nachgebessert werden muss)"
}
}
},
"cancel_inspection": {
"name": "Kontrolle abbrechen",
"description": "Bricht eine offene Kontrolle ab, als hätte sie nie begonnen.",
"fields": {
"inspection_id": {
"name": "Kontroll-ID",
"description": "Die ID der offenen Kontrolle"
}
}
}
},
"entity": {
@@ -874,6 +1329,13 @@
},
"child_badges": {
"unit_of_measurement": "Abzeichen"
},
"child_watch": {
"state": {
"left": "Noch #",
"all_done": "Alles erledigt",
"no_chores": "Keine Aufgaben"
}
}
},
"number": {
@@ -892,5 +1354,34 @@
"name": "Kartendesign"
}
}
},
"exceptions": {
"calendar_unknown_event": {
"message": "Dieser Termin ist keine TaskMate-Aufgabe im Kalender dieses Kindes."
},
"calendar_series_not_supported": {
"message": "Eine wiederkehrende Aufgabe kann im Kalender nur tageweise verschoben oder entfernt werden. Um die ganze Serie zu ändern, bearbeite die Aufgabe in TaskMate."
},
"calendar_repeating_create": {
"message": "Wiederkehrende Termine können nicht im Kalender angelegt werden. Lege stattdessen in TaskMate eine wiederkehrende Aufgabe an."
},
"calendar_rename_series": {
"message": "Benenne eine wiederkehrende Aufgabe in TaskMate um. Im Kalender kannst du nur einen einzelnen Tag verschieben oder entfernen."
},
"calendar_time_follows_chore": {
"message": "Eine wiederkehrende Aufgabe behält ihre Tageszeit. Im Kalender lässt sich nur das Datum eines einzelnen Termins ändern."
},
"calendar_past_date": {
"message": "Aufgaben können nicht an einem vergangenen Tag angelegt, dorthin verschoben oder von dort verschoben werden."
},
"calendar_already_scheduled": {
"message": "{chore} steht am {date} bereits an."
},
"calendar_empty_title": {
"message": "Gib der Aufgabe einen Titel."
},
"calendar_own_event": {
"message": "Dieser Termin wurde von TaskMate selbst veröffentlicht und wird deshalb nicht als neue Aufgabe angelegt."
}
}
}
@@ -15,6 +15,9 @@
},
"abort": {
"already_configured": "TaskMate is already configured"
},
"create_entry": {
"default": "TaskMate is ready. [Open the TaskMate panel](/taskmate-admin) to add your children with the setup wizard, which suggests chores for their ages."
}
},
"selector": {
@@ -179,6 +182,10 @@
"points": {
"name": "Points",
"description": "Award this many points instead of the chore's own value. Leave empty to use the chore's points."
},
"rating": {
"name": "Quality rating",
"description": "Optional 1-3 star rating of how well the chore was done. When quality ratings are enabled it scales the points by that star's multiplier; leave empty to pay 100%."
}
}
},
@@ -189,6 +196,10 @@
"completion_ids": {
"name": "Completion IDs",
"description": "Specific completion IDs to approve; omit to approve all pending"
},
"rating": {
"name": "Quality rating",
"description": "Optional 1-3 star rating applied to every chore approved by this call (quality ratings must be enabled)."
}
}
},
@@ -199,6 +210,20 @@
"completion_id": {
"name": "Completion ID",
"description": "The ID of the chore completion to reject"
},
"reason": {
"name": "Reason",
"description": "Optional reason shown to the child"
}
}
},
"undo_chore": {
"name": "Undo Chore",
"description": "Let a child take back their own chore — a pending submission until a parent reviews it, or an automatically approved one within the chore undo window. Off unless the window is set above 0 in TaskMate settings; anything a parent has reviewed needs a parent to undo it.",
"fields": {
"completion_id": {
"name": "Completion ID",
"description": "The ID of the child's chore completion to take back"
}
}
},
@@ -307,6 +332,10 @@
"claim_id": {
"name": "Claim ID",
"description": "The ID of the reward claim to reject"
},
"reason": {
"name": "Reason",
"description": "Optional reason shown to the child"
}
}
},
@@ -525,6 +554,18 @@
"requires_approval": {
"name": "Requires Approval",
"description": "If true, a parent must approve the completion before points are awarded"
},
"team_size": {
"name": "Team size",
"description": "Make this a teamwork chore that only counts once this many different children have joined it. When the team is full it submits for all of them. 0 = a normal chore."
},
"team_points_mode": {
"name": "Team points",
"description": "How a teamwork chore pays out: \"each\" gives every participant the full points, \"split\" divides them evenly (rounded down)."
},
"team_bonus": {
"name": "Team bonus",
"description": "Extra points each participant earns when a teamwork chore is completed."
}
}
},
@@ -616,6 +657,174 @@
}
}
},
"leave_team_chore": {
"name": "Leave Teamwork Chore",
"description": "Take a child back out of a teamwork chore they joined today, before the team is complete",
"fields": {
"chore_id": {
"name": "Chore ID",
"description": "The ID of the teamwork chore"
},
"child_id": {
"name": "Child ID",
"description": "The ID of the child leaving the team"
}
}
},
"complete_next_chore": {
"name": "Complete Next Chore",
"description": "Complete the child's next outstanding chore, in the order the child card shows them. Chores that need a photo, a note, a timer or a team are skipped. Made for watch buttons and shortcuts; normal approval rules apply.",
"fields": {
"child_id": {
"name": "Child ID",
"description": "The ID of the child whose next chore to complete"
}
}
},
"post_bounty": {
"name": "Post Bounty",
"description": "Post a one-off job to the bounty board for a fixed number of points. Any eligible child can claim it; finishing it goes through the normal approval queue.",
"fields": {
"title": {
"name": "Title",
"description": "What the job is, e.g. \"Wash the car\""
},
"points": {
"name": "Points",
"description": "Fixed points paid when a parent approves the finished job"
},
"description": {
"name": "Description",
"description": "Optional extra detail for the children"
},
"icon": {
"name": "Icon",
"description": "MDI icon shown on the board"
},
"expires_at": {
"name": "Expires at",
"description": "Optional date and time the bounty leaves the board if nobody has claimed it. Leave empty for no expiry."
},
"eligible_child_ids": {
"name": "Eligible children",
"description": "IDs of the children who may claim it. Empty = every child."
},
"claim_hours": {
"name": "Claim lock (hours)",
"description": "How long a claim lasts before the bounty goes back on the board"
},
"require_photo": {
"name": "Require photo",
"description": "The child must attach a photo when marking it done"
},
"notify_children": {
"name": "Notify children",
"description": "Send the \"New bounty\" notification to the eligible children"
}
}
},
"update_bounty": {
"name": "Update Bounty",
"description": "Edit a bounty. Once it has been claimed, only its points and expiry can change.",
"fields": {
"bounty_id": {
"name": "Bounty ID",
"description": "The ID of the bounty to edit"
},
"title": {
"name": "Title",
"description": "What the job is, e.g. \"Wash the car\""
},
"points": {
"name": "Points",
"description": "Fixed points paid when a parent approves the finished job"
},
"description": {
"name": "Description",
"description": "Optional extra detail for the children"
},
"icon": {
"name": "Icon",
"description": "MDI icon shown on the board"
},
"expires_at": {
"name": "Expires at",
"description": "Optional date and time the bounty leaves the board if nobody has claimed it. Leave empty for no expiry."
},
"eligible_child_ids": {
"name": "Eligible children",
"description": "IDs of the children who may claim it. Empty = every child."
},
"claim_hours": {
"name": "Claim lock (hours)",
"description": "How long a claim lasts before the bounty goes back on the board"
},
"require_photo": {
"name": "Require photo",
"description": "The child must attach a photo when marking it done"
},
"notify_children": {
"name": "Notify children",
"description": "Send the \"New bounty\" notification to the eligible children"
}
}
},
"remove_bounty": {
"name": "Remove Bounty",
"description": "Take a bounty off the board. Not allowed while a finished job waits for approval.",
"fields": {
"bounty_id": {
"name": "Bounty ID",
"description": "The ID of the bounty to remove"
}
}
},
"claim_bounty": {
"name": "Claim Bounty",
"description": "Claim a bounty for a child, locking it to them for its claim period. A child can hold one claim at a time.",
"fields": {
"bounty_id": {
"name": "Bounty ID",
"description": "The ID of the bounty to claim"
},
"child_id": {
"name": "Child ID",
"description": "The ID of the child claiming it"
}
}
},
"give_back_bounty": {
"name": "Give Back Bounty",
"description": "Hand a claimed bounty back to the board so another child can take it",
"fields": {
"bounty_id": {
"name": "Bounty ID",
"description": "The ID of the claimed bounty"
},
"child_id": {
"name": "Child ID",
"description": "The ID of the child who claimed it"
}
}
},
"complete_bounty": {
"name": "Complete Bounty",
"description": "Mark a claimed bounty as done. It goes to the approval queue like a chore.",
"fields": {
"bounty_id": {
"name": "Bounty ID",
"description": "The ID of the claimed bounty"
},
"child_id": {
"name": "Child ID",
"description": "The ID of the child who claimed it"
},
"photo_url": {
"name": "Photo URL",
"description": "Photo of the finished job (required when the bounty asks for one)"
}
}
},
"pause_timed_task": {
"name": "Pause Timed Task",
"description": "Pause a running timed task timer",
@@ -818,6 +1027,20 @@
}
}
},
"adjust_streak_freezes": {
"name": "Adjust Streak Freezes",
"description": "Give a child streak-freeze tokens (positive amount) or take some away (negative). A grant never goes past the streak_freeze_max cap.",
"fields": {
"child_id": {
"name": "Child ID",
"description": "The ID of the child whose tokens change"
},
"amount": {
"name": "Amount",
"description": "Tokens to add (positive) or remove (negative)"
}
}
},
"postpone_mandatory_chore": {
"name": "Postpone Mandatory Chore",
"description": "Give a missed mandatory chore another period (or tomorrow) without a penalty, and clear the review item.",
@@ -865,6 +1088,238 @@
"description": "Points to convert to cash and deduct"
}
}
},
"add_wish": {
"name": "Add Wish",
"description": "A child adds something they want to save for. It waits for a parent to approve it.",
"fields": {
"child_id": {
"name": "Child ID",
"description": "The ID of the child making the wish"
},
"name": {
"name": "Name",
"description": "What the child wants"
},
"target": {
"name": "Target",
"description": "Suggested number of points it costs. The parent can change it when approving."
},
"link": {
"name": "Link",
"description": "Optional web address (http or https) for the thing"
},
"photo_url": {
"name": "Picture",
"description": "Optional picture just uploaded through the TaskMate photo endpoint"
}
}
},
"withdraw_wish": {
"name": "Withdraw Wish",
"description": "A child takes back a wish that is still waiting for approval, or clears one that was declined.",
"fields": {
"wish_id": {
"name": "Wish ID",
"description": "The ID of the wish"
},
"child_id": {
"name": "Child ID",
"description": "The ID of the child whose wish it is"
}
}
},
"move_points_to_wish": {
"name": "Move Points to Wish",
"description": "Move points from a child's spendable balance into one of their approved wishes.",
"fields": {
"wish_id": {
"name": "Wish ID",
"description": "The ID of the wish"
},
"child_id": {
"name": "Child ID",
"description": "The ID of the child whose wish it is"
},
"points": {
"name": "Points",
"description": "How many points to move (capped at the balance and at what the wish still needs)"
}
}
},
"take_points_from_wish": {
"name": "Take Points from Wish",
"description": "Give some of a child's own saved points back from a wish. Pledged points never come out.",
"fields": {
"wish_id": {
"name": "Wish ID",
"description": "The ID of the wish"
},
"child_id": {
"name": "Child ID",
"description": "The ID of the child whose wish it is"
},
"points": {
"name": "Points",
"description": "How many saved points to take back (capped at what the child saved)"
}
}
},
"request_wish_redeem": {
"name": "Ask to Redeem Wish",
"description": "A fully funded wish becomes a reward claim waiting for the parent's approval.",
"fields": {
"wish_id": {
"name": "Wish ID",
"description": "The ID of the wish"
},
"child_id": {
"name": "Child ID",
"description": "The ID of the child whose wish it is"
}
}
},
"approve_wish": {
"name": "Approve Wish",
"description": "Turn a waiting wish into a savings goal, optionally at a different target.",
"fields": {
"wish_id": {
"name": "Wish ID",
"description": "The ID of the wish"
},
"target": {
"name": "Target",
"description": "Points the wish costs. Leave empty to keep the child's suggestion."
}
}
},
"decline_wish": {
"name": "Decline Wish",
"description": "Say no to a wish. Saved points go back to the child and pledges are voided.",
"fields": {
"wish_id": {
"name": "Wish ID",
"description": "The ID of the wish"
},
"reason": {
"name": "Reason",
"description": "Optional reason shown to the child"
}
}
},
"pledge_to_wish": {
"name": "Pledge to Wish",
"description": "Record points a relative chips in towards a child's wish. They never touch any balance.",
"fields": {
"wish_id": {
"name": "Wish ID",
"description": "The ID of the wish"
},
"name": {
"name": "From",
"description": "Who the pledge is from, e.g. Grandma"
},
"points": {
"name": "Points",
"description": "How many points to pledge (capped at what the wish still needs)"
},
"message": {
"name": "Message",
"description": "Optional message for the child"
}
}
},
"remove_wish_pledge": {
"name": "Remove Wish Pledge",
"description": "Undo a pledge. Allowed until the wish is redeemed.",
"fields": {
"wish_id": {
"name": "Wish ID",
"description": "The ID of the wish"
},
"pledge_id": {
"name": "Pledge ID",
"description": "The ID of the pledge to remove"
}
}
},
"remove_wish": {
"name": "Remove Wish",
"description": "Delete a wish. Saved points go back to the child and pledges are voided.",
"fields": {
"wish_id": {
"name": "Wish ID",
"description": "The ID of the wish"
}
}
},
"start_inspection": {
"name": "Start Inspection",
"description": "Flag a chore approved in the last 24 hours for a surprise inspection. Pass or fail it before the window closes; an undecided inspection closes quietly.",
"fields": {
"completion_id": {
"name": "Completion ID",
"description": "The ID of the approved chore completion to inspect"
},
"bonus": {
"name": "Bonus",
"description": "Points paid if it passes. Defaults to the Settings value."
},
"window": {
"name": "Window",
"description": "How long you have to check it: 1h, 2h, 4h or bed (until bedtime). Defaults to the Settings value."
},
"tell_child": {
"name": "Tell the child",
"description": "Show \"Inspection coming\" on the child's card now. Turn off for a secret check."
}
}
},
"pass_inspection": {
"name": "Pass Inspection",
"description": "The inspected chore passed. Pays the bonus as a normal points entry and celebrates on the child's card.",
"fields": {
"inspection_id": {
"name": "Inspection ID",
"description": "The ID of the open inspection"
},
"bonus": {
"name": "Bonus",
"description": "Points to pay. Defaults to the bonus set when the inspection started."
},
"note": {
"name": "Note",
"description": "Optional note shown to the child"
}
}
},
"fail_inspection": {
"name": "Fail Inspection",
"description": "The inspected chore didn't pass. No points are taken. It can be sent back to redo; the redo earns nothing extra.",
"fields": {
"inspection_id": {
"name": "Inspection ID",
"description": "The ID of the open inspection"
},
"redo": {
"name": "Send back to redo",
"description": "Put the chore back on the child's list as not done for today. Defaults to the Settings value."
},
"note": {
"name": "Note",
"description": "Optional note shown to the child (what needs fixing)"
}
}
},
"cancel_inspection": {
"name": "Cancel Inspection",
"description": "Call off an open inspection, as if it never started.",
"fields": {
"inspection_id": {
"name": "Inspection ID",
"description": "The ID of the open inspection"
}
}
}
},
"entity": {
@@ -874,6 +1329,13 @@
},
"child_badges": {
"unit_of_measurement": "badges"
},
"child_watch": {
"state": {
"left": "# left",
"all_done": "All done",
"no_chores": "No chores"
}
}
},
"number": {
@@ -892,5 +1354,34 @@
"name": "Card design"
}
}
},
"exceptions": {
"calendar_unknown_event": {
"message": "This event isn't a TaskMate chore on this child's calendar."
},
"calendar_series_not_supported": {
"message": "A repeating chore can only be moved or removed one day at a time from the calendar. To change the whole series, edit the chore in TaskMate."
},
"calendar_repeating_create": {
"message": "Repeating events can't be added from the calendar. Create a repeating chore in TaskMate instead."
},
"calendar_rename_series": {
"message": "Rename a repeating chore in TaskMate. From the calendar you can only move or remove a single day."
},
"calendar_time_follows_chore": {
"message": "A repeating chore keeps its time of day. Only the date of a single occurrence can be changed from the calendar."
},
"calendar_past_date": {
"message": "Chores can't be added to, moved to or moved from a day in the past."
},
"calendar_already_scheduled": {
"message": "{chore} is already on {date}."
},
"calendar_empty_title": {
"message": "Give the chore a title."
},
"calendar_own_event": {
"message": "This event was published by TaskMate itself, so it isn't added as a new chore."
}
}
}
@@ -15,6 +15,9 @@
},
"abort": {
"already_configured": "TaskMate is already configured"
},
"create_entry": {
"default": "TaskMate is ready. [Open the TaskMate panel](/taskmate-admin) to add your children with the setup wizard, which suggests chores for their ages."
}
},
"selector": {
@@ -179,6 +182,10 @@
"points": {
"name": "Points",
"description": "Award this many points instead of the chore's own value. Leave empty to use the chore's points."
},
"rating": {
"name": "Quality rating",
"description": "Optional 1-3 star rating of how well the chore was done. When quality ratings are enabled it scales the points by that star's multiplier; leave empty to pay 100%."
}
}
},
@@ -189,6 +196,10 @@
"completion_ids": {
"name": "Completion IDs",
"description": "Specific completion IDs to approve; omit to approve all pending"
},
"rating": {
"name": "Quality rating",
"description": "Optional 1-3 star rating applied to every chore approved by this call (quality ratings must be enabled)."
}
}
},
@@ -199,6 +210,20 @@
"completion_id": {
"name": "Completion ID",
"description": "The ID of the chore completion to reject"
},
"reason": {
"name": "Reason",
"description": "Optional reason shown to the child"
}
}
},
"undo_chore": {
"name": "Undo Chore",
"description": "Let a child take back their own chore — a pending submission until a parent reviews it, or an automatically approved one within the chore undo window. Off unless the window is set above 0 in TaskMate settings; anything a parent has reviewed needs a parent to undo it.",
"fields": {
"completion_id": {
"name": "Completion ID",
"description": "The ID of the child's chore completion to take back"
}
}
},
@@ -307,6 +332,10 @@
"claim_id": {
"name": "Claim ID",
"description": "The ID of the reward claim to reject"
},
"reason": {
"name": "Reason",
"description": "Optional reason shown to the child"
}
}
},
@@ -525,6 +554,18 @@
"requires_approval": {
"name": "Requires Approval",
"description": "If true, a parent must approve the completion before points are awarded"
},
"team_size": {
"name": "Team size",
"description": "Make this a teamwork chore that only counts once this many different children have joined it. When the team is full it submits for all of them. 0 = a normal chore."
},
"team_points_mode": {
"name": "Team points",
"description": "How a teamwork chore pays out: \"each\" gives every participant the full points, \"split\" divides them evenly (rounded down)."
},
"team_bonus": {
"name": "Team bonus",
"description": "Extra points each participant earns when a teamwork chore is completed."
}
}
},
@@ -616,6 +657,174 @@
}
}
},
"leave_team_chore": {
"name": "Leave Teamwork Chore",
"description": "Take a child back out of a teamwork chore they joined today, before the team is complete",
"fields": {
"chore_id": {
"name": "Chore ID",
"description": "The ID of the teamwork chore"
},
"child_id": {
"name": "Child ID",
"description": "The ID of the child leaving the team"
}
}
},
"complete_next_chore": {
"name": "Complete Next Chore",
"description": "Complete the child's next outstanding chore, in the order the child card shows them. Chores that need a photo, a note, a timer or a team are skipped. Made for watch buttons and shortcuts; normal approval rules apply.",
"fields": {
"child_id": {
"name": "Child ID",
"description": "The ID of the child whose next chore to complete"
}
}
},
"post_bounty": {
"name": "Post Bounty",
"description": "Post a one-off job to the bounty board for a fixed number of points. Any eligible child can claim it; finishing it goes through the normal approval queue.",
"fields": {
"title": {
"name": "Title",
"description": "What the job is, e.g. \"Wash the car\""
},
"points": {
"name": "Points",
"description": "Fixed points paid when a parent approves the finished job"
},
"description": {
"name": "Description",
"description": "Optional extra detail for the children"
},
"icon": {
"name": "Icon",
"description": "MDI icon shown on the board"
},
"expires_at": {
"name": "Expires at",
"description": "Optional date and time the bounty leaves the board if nobody has claimed it. Leave empty for no expiry."
},
"eligible_child_ids": {
"name": "Eligible children",
"description": "IDs of the children who may claim it. Empty = every child."
},
"claim_hours": {
"name": "Claim lock (hours)",
"description": "How long a claim lasts before the bounty goes back on the board"
},
"require_photo": {
"name": "Require photo",
"description": "The child must attach a photo when marking it done"
},
"notify_children": {
"name": "Notify children",
"description": "Send the \"New bounty\" notification to the eligible children"
}
}
},
"update_bounty": {
"name": "Update Bounty",
"description": "Edit a bounty. Once it has been claimed, only its points and expiry can change.",
"fields": {
"bounty_id": {
"name": "Bounty ID",
"description": "The ID of the bounty to edit"
},
"title": {
"name": "Title",
"description": "What the job is, e.g. \"Wash the car\""
},
"points": {
"name": "Points",
"description": "Fixed points paid when a parent approves the finished job"
},
"description": {
"name": "Description",
"description": "Optional extra detail for the children"
},
"icon": {
"name": "Icon",
"description": "MDI icon shown on the board"
},
"expires_at": {
"name": "Expires at",
"description": "Optional date and time the bounty leaves the board if nobody has claimed it. Leave empty for no expiry."
},
"eligible_child_ids": {
"name": "Eligible children",
"description": "IDs of the children who may claim it. Empty = every child."
},
"claim_hours": {
"name": "Claim lock (hours)",
"description": "How long a claim lasts before the bounty goes back on the board"
},
"require_photo": {
"name": "Require photo",
"description": "The child must attach a photo when marking it done"
},
"notify_children": {
"name": "Notify children",
"description": "Send the \"New bounty\" notification to the eligible children"
}
}
},
"remove_bounty": {
"name": "Remove Bounty",
"description": "Take a bounty off the board. Not allowed while a finished job waits for approval.",
"fields": {
"bounty_id": {
"name": "Bounty ID",
"description": "The ID of the bounty to remove"
}
}
},
"claim_bounty": {
"name": "Claim Bounty",
"description": "Claim a bounty for a child, locking it to them for its claim period. A child can hold one claim at a time.",
"fields": {
"bounty_id": {
"name": "Bounty ID",
"description": "The ID of the bounty to claim"
},
"child_id": {
"name": "Child ID",
"description": "The ID of the child claiming it"
}
}
},
"give_back_bounty": {
"name": "Give Back Bounty",
"description": "Hand a claimed bounty back to the board so another child can take it",
"fields": {
"bounty_id": {
"name": "Bounty ID",
"description": "The ID of the claimed bounty"
},
"child_id": {
"name": "Child ID",
"description": "The ID of the child who claimed it"
}
}
},
"complete_bounty": {
"name": "Complete Bounty",
"description": "Mark a claimed bounty as done. It goes to the approval queue like a chore.",
"fields": {
"bounty_id": {
"name": "Bounty ID",
"description": "The ID of the claimed bounty"
},
"child_id": {
"name": "Child ID",
"description": "The ID of the child who claimed it"
},
"photo_url": {
"name": "Photo URL",
"description": "Photo of the finished job (required when the bounty asks for one)"
}
}
},
"pause_timed_task": {
"name": "Pause Timed Task",
"description": "Pause a running timed task timer",
@@ -818,6 +1027,20 @@
}
}
},
"adjust_streak_freezes": {
"name": "Adjust Streak Freezes",
"description": "Give a child streak-freeze tokens (positive amount) or take some away (negative). A grant never goes past the streak_freeze_max cap.",
"fields": {
"child_id": {
"name": "Child ID",
"description": "The ID of the child whose tokens change"
},
"amount": {
"name": "Amount",
"description": "Tokens to add (positive) or remove (negative)"
}
}
},
"postpone_mandatory_chore": {
"name": "Postpone Mandatory Chore",
"description": "Give a missed mandatory chore another period (or tomorrow) without a penalty, and clear the review item.",
@@ -865,6 +1088,238 @@
"description": "Points to convert to cash and deduct"
}
}
},
"add_wish": {
"name": "Add Wish",
"description": "A child adds something they want to save for. It waits for a parent to approve it.",
"fields": {
"child_id": {
"name": "Child ID",
"description": "The ID of the child making the wish"
},
"name": {
"name": "Name",
"description": "What the child wants"
},
"target": {
"name": "Target",
"description": "Suggested number of points it costs. The parent can change it when approving."
},
"link": {
"name": "Link",
"description": "Optional web address (http or https) for the thing"
},
"photo_url": {
"name": "Picture",
"description": "Optional picture just uploaded through the TaskMate photo endpoint"
}
}
},
"withdraw_wish": {
"name": "Withdraw Wish",
"description": "A child takes back a wish that is still waiting for approval, or clears one that was declined.",
"fields": {
"wish_id": {
"name": "Wish ID",
"description": "The ID of the wish"
},
"child_id": {
"name": "Child ID",
"description": "The ID of the child whose wish it is"
}
}
},
"move_points_to_wish": {
"name": "Move Points to Wish",
"description": "Move points from a child's spendable balance into one of their approved wishes.",
"fields": {
"wish_id": {
"name": "Wish ID",
"description": "The ID of the wish"
},
"child_id": {
"name": "Child ID",
"description": "The ID of the child whose wish it is"
},
"points": {
"name": "Points",
"description": "How many points to move (capped at the balance and at what the wish still needs)"
}
}
},
"take_points_from_wish": {
"name": "Take Points from Wish",
"description": "Give some of a child's own saved points back from a wish. Pledged points never come out.",
"fields": {
"wish_id": {
"name": "Wish ID",
"description": "The ID of the wish"
},
"child_id": {
"name": "Child ID",
"description": "The ID of the child whose wish it is"
},
"points": {
"name": "Points",
"description": "How many saved points to take back (capped at what the child saved)"
}
}
},
"request_wish_redeem": {
"name": "Ask to Redeem Wish",
"description": "A fully funded wish becomes a reward claim waiting for the parent's approval.",
"fields": {
"wish_id": {
"name": "Wish ID",
"description": "The ID of the wish"
},
"child_id": {
"name": "Child ID",
"description": "The ID of the child whose wish it is"
}
}
},
"approve_wish": {
"name": "Approve Wish",
"description": "Turn a waiting wish into a savings goal, optionally at a different target.",
"fields": {
"wish_id": {
"name": "Wish ID",
"description": "The ID of the wish"
},
"target": {
"name": "Target",
"description": "Points the wish costs. Leave empty to keep the child's suggestion."
}
}
},
"decline_wish": {
"name": "Decline Wish",
"description": "Say no to a wish. Saved points go back to the child and pledges are voided.",
"fields": {
"wish_id": {
"name": "Wish ID",
"description": "The ID of the wish"
},
"reason": {
"name": "Reason",
"description": "Optional reason shown to the child"
}
}
},
"pledge_to_wish": {
"name": "Pledge to Wish",
"description": "Record points a relative chips in towards a child's wish. They never touch any balance.",
"fields": {
"wish_id": {
"name": "Wish ID",
"description": "The ID of the wish"
},
"name": {
"name": "From",
"description": "Who the pledge is from, e.g. Grandma"
},
"points": {
"name": "Points",
"description": "How many points to pledge (capped at what the wish still needs)"
},
"message": {
"name": "Message",
"description": "Optional message for the child"
}
}
},
"remove_wish_pledge": {
"name": "Remove Wish Pledge",
"description": "Undo a pledge. Allowed until the wish is redeemed.",
"fields": {
"wish_id": {
"name": "Wish ID",
"description": "The ID of the wish"
},
"pledge_id": {
"name": "Pledge ID",
"description": "The ID of the pledge to remove"
}
}
},
"remove_wish": {
"name": "Remove Wish",
"description": "Delete a wish. Saved points go back to the child and pledges are voided.",
"fields": {
"wish_id": {
"name": "Wish ID",
"description": "The ID of the wish"
}
}
},
"start_inspection": {
"name": "Start Inspection",
"description": "Flag a chore approved in the last 24 hours for a surprise inspection. Pass or fail it before the window closes; an undecided inspection closes quietly.",
"fields": {
"completion_id": {
"name": "Completion ID",
"description": "The ID of the approved chore completion to inspect"
},
"bonus": {
"name": "Bonus",
"description": "Points paid if it passes. Defaults to the Settings value."
},
"window": {
"name": "Window",
"description": "How long you have to check it: 1h, 2h, 4h or bed (until bedtime). Defaults to the Settings value."
},
"tell_child": {
"name": "Tell the child",
"description": "Show \"Inspection coming\" on the child's card now. Turn off for a secret check."
}
}
},
"pass_inspection": {
"name": "Pass Inspection",
"description": "The inspected chore passed. Pays the bonus as a normal points entry and celebrates on the child's card.",
"fields": {
"inspection_id": {
"name": "Inspection ID",
"description": "The ID of the open inspection"
},
"bonus": {
"name": "Bonus",
"description": "Points to pay. Defaults to the bonus set when the inspection started."
},
"note": {
"name": "Note",
"description": "Optional note shown to the child"
}
}
},
"fail_inspection": {
"name": "Fail Inspection",
"description": "The inspected chore didn't pass. No points are taken. It can be sent back to redo; the redo earns nothing extra.",
"fields": {
"inspection_id": {
"name": "Inspection ID",
"description": "The ID of the open inspection"
},
"redo": {
"name": "Send back to redo",
"description": "Put the chore back on the child's list as not done for today. Defaults to the Settings value."
},
"note": {
"name": "Note",
"description": "Optional note shown to the child (what needs fixing)"
}
}
},
"cancel_inspection": {
"name": "Cancel Inspection",
"description": "Call off an open inspection, as if it never started.",
"fields": {
"inspection_id": {
"name": "Inspection ID",
"description": "The ID of the open inspection"
}
}
}
},
"entity": {
@@ -874,6 +1329,13 @@
},
"child_badges": {
"unit_of_measurement": "badges"
},
"child_watch": {
"state": {
"left": "# left",
"all_done": "All done",
"no_chores": "No chores"
}
}
},
"number": {
@@ -892,5 +1354,34 @@
"name": "Card design"
}
}
},
"exceptions": {
"calendar_unknown_event": {
"message": "This event isn't a TaskMate chore on this child's calendar."
},
"calendar_series_not_supported": {
"message": "A repeating chore can only be moved or removed one day at a time from the calendar. To change the whole series, edit the chore in TaskMate."
},
"calendar_repeating_create": {
"message": "Repeating events can't be added from the calendar. Create a repeating chore in TaskMate instead."
},
"calendar_rename_series": {
"message": "Rename a repeating chore in TaskMate. From the calendar you can only move or remove a single day."
},
"calendar_time_follows_chore": {
"message": "A repeating chore keeps its time of day. Only the date of a single occurrence can be changed from the calendar."
},
"calendar_past_date": {
"message": "Chores can't be added to, moved to or moved from a day in the past."
},
"calendar_already_scheduled": {
"message": "{chore} is already on {date}."
},
"calendar_empty_title": {
"message": "Give the chore a title."
},
"calendar_own_event": {
"message": "This event was published by TaskMate itself, so it isn't added as a new chore."
}
}
}
@@ -15,6 +15,9 @@
},
"abort": {
"already_configured": "TaskMate est déjà configuré"
},
"create_entry": {
"default": "TaskMate est prêt. [Ouvrez le panneau TaskMate](/taskmate-admin) pour ajouter vos enfants avec l'assistant de configuration, qui suggère des tâches adaptées à leur âge."
}
},
"selector": {
@@ -179,6 +182,10 @@
"points": {
"name": "Points",
"description": "Attribuer ce nombre de points au lieu de la valeur de la tâche. Laisser vide pour utiliser les points de la tâche."
},
"rating": {
"name": "Note de qualité",
"description": "Note facultative de 1 à 3 étoiles sur la qualité du travail. Quand les notes de qualité sont activées, les points sont multipliés par le coefficient de cette note ; laissez vide pour verser 100 %."
}
}
},
@@ -189,6 +196,10 @@
"completion_ids": {
"name": "Identifiants de complétion",
"description": "Identifiants de complétion à approuver ; laisser vide pour tout approuver"
},
"rating": {
"name": "Note de qualité",
"description": "Note facultative de 1 à 3 étoiles appliquée à chaque tâche approuvée par cet appel (les notes de qualité doivent être activées)."
}
}
},
@@ -199,6 +210,20 @@
"completion_id": {
"name": "ID de complétion",
"description": "L'identifiant de l'exécution à rejeter"
},
"reason": {
"name": "Raison",
"description": "Raison facultative affichée à l'enfant"
}
}
},
"undo_chore": {
"name": "Annuler une tâche",
"description": "Permet à un enfant de reprendre sa propre tâche — une soumission en attente tant qu'un parent ne l'a pas examinée, ou une tâche approuvée automatiquement pendant la fenêtre d'annulation. Désactivé tant que la fenêtre n'est pas réglée au-dessus de 0 dans les paramètres TaskMate ; tout ce qu'un parent a examiné doit être annulé par un parent.",
"fields": {
"completion_id": {
"name": "ID de réalisation",
"description": "L'ID de la tâche réalisée par l'enfant à reprendre"
}
}
},
@@ -307,6 +332,10 @@
"claim_id": {
"name": "ID de la demande",
"description": "L'identifiant de la demande à rejeter"
},
"reason": {
"name": "Raison",
"description": "Raison facultative affichée à l'enfant"
}
}
},
@@ -525,6 +554,18 @@
"requires_approval": {
"name": "Nécessite approbation",
"description": "Si vrai, un parent doit approuver l'exécution avant que les points soient attribués"
},
"team_size": {
"name": "Taille de l'équipe",
"description": "Fait de cette corvée une tâche d'équipe qui ne compte qu'une fois que ce nombre d'enfants différents l'ont rejointe. Quand l'équipe est complète, elle est soumise pour tous. 0 = corvée normale."
},
"team_points_mode": {
"name": "Points d'équipe",
"description": "Comment une tâche d'équipe rapporte : « each » donne tous les points à chaque participant, « split » les partage à parts égales (arrondi à l'inférieur)."
},
"team_bonus": {
"name": "Bonus d'équipe",
"description": "Points supplémentaires gagnés par chaque participant quand une tâche d'équipe est terminée."
}
}
},
@@ -616,6 +657,174 @@
}
}
},
"leave_team_chore": {
"name": "Quitter une tâche d'équipe",
"description": "Retirer un enfant d'une tâche d'équipe qu'il a rejointe aujourd'hui, avant que l'équipe soit complète",
"fields": {
"chore_id": {
"name": "ID de la corvée",
"description": "L'identifiant de la tâche d'équipe"
},
"child_id": {
"name": "ID de l'enfant",
"description": "L'identifiant de l'enfant qui quitte l'équipe"
}
}
},
"complete_next_chore": {
"name": "Terminer la tâche suivante",
"description": "Termine la prochaine tâche en attente de l'enfant, dans l'ordre affiché par la carte enfant. Les tâches qui demandent une photo, une note, un minuteur ou une équipe sont ignorées. Conçu pour les boutons et raccourcis de montre ; les règles d'approbation habituelles s'appliquent.",
"fields": {
"child_id": {
"name": "ID de l'enfant",
"description": "L'ID de l'enfant dont la prochaine tâche doit être terminée"
}
}
},
"post_bounty": {
"name": "Publier une prime",
"description": "Publier une tâche ponctuelle sur le tableau des primes pour un nombre de points fixe. Tout enfant éligible peut la réserver ; une fois faite, elle passe par la validation habituelle.",
"fields": {
"title": {
"name": "Titre",
"description": "La tâche, p. ex. « Laver la voiture »"
},
"points": {
"name": "Points",
"description": "Points fixes versés quand un parent valide la tâche terminée"
},
"description": {
"name": "Description",
"description": "Détails facultatifs pour les enfants"
},
"icon": {
"name": "Icône",
"description": "Icône MDI affichée sur le tableau"
},
"expires_at": {
"name": "Expire le",
"description": "Date et heure facultatives auxquelles la prime quitte le tableau si personne ne l'a réservée. Laisser vide pour ne pas expirer."
},
"eligible_child_ids": {
"name": "Enfants éligibles",
"description": "ID des enfants qui peuvent la réserver. Vide = tous les enfants."
},
"claim_hours": {
"name": "Réservation (heures)",
"description": "Durée d'une réservation avant que la prime ne revienne sur le tableau"
},
"require_photo": {
"name": "Photo obligatoire",
"description": "L'enfant doit joindre une photo en la marquant comme faite"
},
"notify_children": {
"name": "Prévenir les enfants",
"description": "Envoyer la notification « Nouvelle prime » aux enfants éligibles"
}
}
},
"update_bounty": {
"name": "Modifier une prime",
"description": "Modifier une prime. Une fois réservée, seuls ses points et son expiration peuvent changer.",
"fields": {
"bounty_id": {
"name": "ID de la prime",
"description": "L'ID de la prime à modifier"
},
"title": {
"name": "Titre",
"description": "La tâche, p. ex. « Laver la voiture »"
},
"points": {
"name": "Points",
"description": "Points fixes versés quand un parent valide la tâche terminée"
},
"description": {
"name": "Description",
"description": "Détails facultatifs pour les enfants"
},
"icon": {
"name": "Icône",
"description": "Icône MDI affichée sur le tableau"
},
"expires_at": {
"name": "Expire le",
"description": "Date et heure facultatives auxquelles la prime quitte le tableau si personne ne l'a réservée. Laisser vide pour ne pas expirer."
},
"eligible_child_ids": {
"name": "Enfants éligibles",
"description": "ID des enfants qui peuvent la réserver. Vide = tous les enfants."
},
"claim_hours": {
"name": "Réservation (heures)",
"description": "Durée d'une réservation avant que la prime ne revienne sur le tableau"
},
"require_photo": {
"name": "Photo obligatoire",
"description": "L'enfant doit joindre une photo en la marquant comme faite"
},
"notify_children": {
"name": "Prévenir les enfants",
"description": "Envoyer la notification « Nouvelle prime » aux enfants éligibles"
}
}
},
"remove_bounty": {
"name": "Retirer une prime",
"description": "Retirer une prime du tableau. Impossible tant qu'une tâche terminée attend la validation.",
"fields": {
"bounty_id": {
"name": "ID de la prime",
"description": "L'ID de la prime à retirer"
}
}
},
"claim_bounty": {
"name": "Réserver une prime",
"description": "Réserver une prime pour un enfant, qui la garde pendant la durée de réservation. Un enfant ne peut en réserver qu'une à la fois.",
"fields": {
"bounty_id": {
"name": "ID de la prime",
"description": "L'ID de la prime à réserver"
},
"child_id": {
"name": "ID de l'enfant",
"description": "L'ID de l'enfant qui la réserve"
}
}
},
"give_back_bounty": {
"name": "Rendre une prime",
"description": "Remettre une prime réservée sur le tableau pour qu'un autre enfant puisse la prendre",
"fields": {
"bounty_id": {
"name": "ID de la prime",
"description": "L'ID de la prime réservée"
},
"child_id": {
"name": "ID de l'enfant",
"description": "L'ID de l'enfant qui l'a réservée"
}
}
},
"complete_bounty": {
"name": "Terminer une prime",
"description": "Marquer une prime réservée comme faite. Elle part en validation comme une tâche.",
"fields": {
"bounty_id": {
"name": "ID de la prime",
"description": "L'ID de la prime réservée"
},
"child_id": {
"name": "ID de l'enfant",
"description": "L'ID de l'enfant qui l'a réservée"
},
"photo_url": {
"name": "URL de la photo",
"description": "Photo de la tâche terminée (obligatoire si la prime en demande une)"
}
}
},
"pause_timed_task": {
"name": "Mettre en pause une tâche chronométrée",
"description": "Mettre en pause le minuteur d'une tâche chronométrée en cours",
@@ -818,6 +1027,20 @@
}
}
},
"adjust_streak_freezes": {
"name": "Ajuster les gels de série",
"description": "Donne des gels de série à un enfant (nombre positif) ou en retire (négatif). Un ajout ne dépasse jamais la limite streak_freeze_max.",
"fields": {
"child_id": {
"name": "ID de l'enfant",
"description": "L'ID de l'enfant dont les gels changent"
},
"amount": {
"name": "Nombre",
"description": "Gels à ajouter (positif) ou à retirer (négatif)"
}
}
},
"postpone_mandatory_chore": {
"name": "Reporter la tâche obligatoire",
"description": "Accorde à une tâche obligatoire manquée une autre période (ou demain) sans pénalité et supprime l'élément à vérifier.",
@@ -865,6 +1088,238 @@
"description": "Points à convertir en argent et à déduire"
}
}
},
"add_wish": {
"name": "Ajouter un souhait",
"description": "Un enfant ajoute quelque chose pour lequel il veut économiser. Le souhait attend l'approbation d'un parent.",
"fields": {
"child_id": {
"name": "ID de l'enfant",
"description": "L'ID de l'enfant qui fait le souhait"
},
"name": {
"name": "Nom",
"description": "Ce que l'enfant souhaite"
},
"target": {
"name": "Objectif",
"description": "Nombre de points proposé. Le parent peut le modifier lors de l'approbation."
},
"link": {
"name": "Lien",
"description": "Adresse web facultative (http ou https) de l'article"
},
"photo_url": {
"name": "Image",
"description": "Image facultative tout juste envoyée via le point d'accès photo de TaskMate"
}
}
},
"withdraw_wish": {
"name": "Retirer un souhait",
"description": "Un enfant retire un souhait encore en attente d'approbation, ou efface un souhait refusé.",
"fields": {
"wish_id": {
"name": "ID du souhait",
"description": "L'ID du souhait"
},
"child_id": {
"name": "ID de l'enfant",
"description": "L'ID de l'enfant à qui appartient le souhait"
}
}
},
"move_points_to_wish": {
"name": "Placer des points dans un souhait",
"description": "Transfère des points du solde disponible d'un enfant vers l'un de ses souhaits approuvés.",
"fields": {
"wish_id": {
"name": "ID du souhait",
"description": "L'ID du souhait"
},
"child_id": {
"name": "ID de l'enfant",
"description": "L'ID de l'enfant à qui appartient le souhait"
},
"points": {
"name": "Points",
"description": "Nombre de points à transférer (plafonné au solde et à ce qu'il manque au souhait)"
}
}
},
"take_points_from_wish": {
"name": "Reprendre des points d'un souhait",
"description": "Rend à l'enfant une partie de ses propres points économisés. Les points promis ne sortent jamais.",
"fields": {
"wish_id": {
"name": "ID du souhait",
"description": "L'ID du souhait"
},
"child_id": {
"name": "ID de l'enfant",
"description": "L'ID de l'enfant à qui appartient le souhait"
},
"points": {
"name": "Points",
"description": "Nombre de points économisés à reprendre (plafonné à ce que l'enfant a économisé)"
}
}
},
"request_wish_redeem": {
"name": "Demander à obtenir le souhait",
"description": "Un souhait entièrement financé devient une demande de récompense en attente d'approbation.",
"fields": {
"wish_id": {
"name": "ID du souhait",
"description": "L'ID du souhait"
},
"child_id": {
"name": "ID de l'enfant",
"description": "L'ID de l'enfant à qui appartient le souhait"
}
}
},
"approve_wish": {
"name": "Approuver un souhait",
"description": "Transforme un souhait en attente en objectif d'épargne, éventuellement avec un autre objectif.",
"fields": {
"wish_id": {
"name": "ID du souhait",
"description": "L'ID du souhait"
},
"target": {
"name": "Objectif",
"description": "Points que coûte le souhait. Laisser vide pour garder la proposition de l'enfant."
}
}
},
"decline_wish": {
"name": "Refuser un souhait",
"description": "Refuse un souhait. Les points économisés reviennent à l'enfant et les promesses sont annulées.",
"fields": {
"wish_id": {
"name": "ID du souhait",
"description": "L'ID du souhait"
},
"reason": {
"name": "Raison",
"description": "Raison facultative affichée à l'enfant"
}
}
},
"pledge_to_wish": {
"name": "Promettre pour un souhait",
"description": "Enregistre des points offerts par un proche pour le souhait d'un enfant. Ils ne touchent aucun solde.",
"fields": {
"wish_id": {
"name": "ID du souhait",
"description": "L'ID du souhait"
},
"name": {
"name": "De la part de",
"description": "De qui vient la promesse, p. ex. Mamie"
},
"points": {
"name": "Points",
"description": "Nombre de points promis (plafonné à ce qu'il manque au souhait)"
},
"message": {
"name": "Message",
"description": "Message facultatif pour l'enfant"
}
}
},
"remove_wish_pledge": {
"name": "Retirer une promesse",
"description": "Annule une promesse. Possible tant que le souhait n'est pas obtenu.",
"fields": {
"wish_id": {
"name": "ID du souhait",
"description": "L'ID du souhait"
},
"pledge_id": {
"name": "ID de la promesse",
"description": "L'ID de la promesse à retirer"
}
}
},
"remove_wish": {
"name": "Supprimer un souhait",
"description": "Supprime un souhait. Les points économisés reviennent à l'enfant et les promesses sont annulées.",
"fields": {
"wish_id": {
"name": "ID du souhait",
"description": "L'ID du souhait"
}
}
},
"start_inspection": {
"name": "Lancer une inspection",
"description": "Marque une tâche approuvée au cours des dernières 24 heures pour une inspection surprise. Valide-la ou refuse-la avant la fin du délai ; une inspection non tranchée se ferme discrètement.",
"fields": {
"completion_id": {
"name": "ID de réalisation",
"description": "L'ID de la réalisation approuvée à inspecter"
},
"bonus": {
"name": "Bonus",
"description": "Points versés si elle est réussie. Par défaut, la valeur des Paramètres."
},
"window": {
"name": "Délai",
"description": "Combien de temps tu as pour vérifier : 1h, 2h, 4h ou bed (jusqu'au coucher). Par défaut, la valeur des Paramètres."
},
"tell_child": {
"name": "Prévenir l'enfant",
"description": "Affiche « Inspection en approche » sur la carte de l'enfant dès maintenant. Désactive pour une vérification secrète."
}
}
},
"pass_inspection": {
"name": "Valider l'inspection",
"description": "La tâche inspectée est réussie. Verse le bonus comme une entrée de points normale et fête ça sur la carte de l'enfant.",
"fields": {
"inspection_id": {
"name": "ID d'inspection",
"description": "L'ID de l'inspection en cours"
},
"bonus": {
"name": "Bonus",
"description": "Points à verser. Par défaut, le bonus fixé au lancement."
},
"note": {
"name": "Note",
"description": "Note facultative affichée à l'enfant"
}
}
},
"fail_inspection": {
"name": "Refuser l'inspection",
"description": "La tâche inspectée n'est pas réussie. Aucun point n'est retiré. Elle peut être renvoyée à refaire ; la refaire ne rapporte rien de plus.",
"fields": {
"inspection_id": {
"name": "ID d'inspection",
"description": "L'ID de l'inspection en cours"
},
"redo": {
"name": "Renvoyer à refaire",
"description": "Remet la tâche sur la liste de l'enfant comme non faite pour aujourd'hui. Par défaut, la valeur des Paramètres."
},
"note": {
"name": "Note",
"description": "Note facultative affichée à l'enfant (ce qu'il faut corriger)"
}
}
},
"cancel_inspection": {
"name": "Annuler l'inspection",
"description": "Annule une inspection en cours, comme si elle n'avait jamais commencé.",
"fields": {
"inspection_id": {
"name": "ID d'inspection",
"description": "L'ID de l'inspection en cours"
}
}
}
},
"entity": {
@@ -874,6 +1329,13 @@
},
"child_badges": {
"unit_of_measurement": "badges"
},
"child_watch": {
"state": {
"left": "Encore #",
"all_done": "Tout est fait",
"no_chores": "Aucune tâche"
}
}
},
"number": {
@@ -892,5 +1354,34 @@
"name": "Design de carte"
}
}
},
"exceptions": {
"calendar_unknown_event": {
"message": "Cet événement n'est pas une tâche TaskMate du calendrier de cet enfant."
},
"calendar_series_not_supported": {
"message": "Une tâche récurrente ne peut être déplacée ou supprimée depuis le calendrier qu'un jour à la fois. Pour modifier toute la série, modifiez la tâche dans TaskMate."
},
"calendar_repeating_create": {
"message": "Les événements récurrents ne peuvent pas être ajoutés depuis le calendrier. Créez plutôt une tâche récurrente dans TaskMate."
},
"calendar_rename_series": {
"message": "Renommez une tâche récurrente dans TaskMate. Depuis le calendrier, vous ne pouvez déplacer ou supprimer qu'un seul jour."
},
"calendar_time_follows_chore": {
"message": "Une tâche récurrente garde son moment de la journée. Seule la date d'une occurrence peut être modifiée depuis le calendrier."
},
"calendar_past_date": {
"message": "Une tâche ne peut pas être ajoutée à un jour passé, ni déplacée vers ou depuis un jour passé."
},
"calendar_already_scheduled": {
"message": "{chore} est déjà prévue le {date}."
},
"calendar_empty_title": {
"message": "Donnez un titre à la tâche."
},
"calendar_own_event": {
"message": "Cet événement a été publié par TaskMate lui-même : il n'est donc pas ajouté comme nouvelle tâche."
}
}
}
@@ -15,6 +15,9 @@
},
"abort": {
"already_configured": "TaskMate er allerede konfigurert"
},
"create_entry": {
"default": "TaskMate er klar. [Åpne TaskMate-panelet](/taskmate-admin) for å legge til barna dine med oppsettveiviseren, som foreslår oppgaver etter alderen deres."
}
},
"selector": {
@@ -179,6 +182,10 @@
"points": {
"name": "Poeng",
"description": "Gi dette antallet poeng i stedet for oppgavens egen verdi. La stå tomt for å bruke oppgavens poeng."
},
"rating": {
"name": "Kvalitetsvurdering",
"description": "Valgfri vurdering på 1-3 stjerner av hvor godt oppgaven ble gjort. Når kvalitetsvurdering er slått på, skaleres poengene med stjernens multiplikator; la feltet stå tomt for å gi 100 %."
}
}
},
@@ -189,6 +196,10 @@
"completion_ids": {
"name": "Fullførings-ID-er",
"description": "Bestemte fullførings-ID-er som skal godkjennes; utelat for å godkjenne alle ventende"
},
"rating": {
"name": "Kvalitetsvurdering",
"description": "Valgfri vurdering på 1-3 stjerner som gjelder alle oppgaver godkjent med dette kallet (kvalitetsvurdering må være slått på)."
}
}
},
@@ -199,6 +210,20 @@
"completion_id": {
"name": "Fullførings-ID",
"description": "ID-en til oppgavefullføringen som skal avvises"
},
"reason": {
"name": "Begrunnelse",
"description": "Valgfri begrunnelse som vises for barnet"
}
}
},
"undo_chore": {
"name": "Angre gjøremål",
"description": "La et barn ta tilbake sitt eget gjøremål — en innsending som venter, helt til en forelder har gått gjennom den, eller et automatisk godkjent gjøremål innenfor angrevinduet. Av med mindre vinduet er satt over 0 i TaskMate-innstillingene; alt en forelder har gått gjennom, må en forelder angre.",
"fields": {
"completion_id": {
"name": "Fullførings-ID",
"description": "ID-en til barnets fullførte gjøremål som skal tas tilbake"
}
}
},
@@ -307,6 +332,10 @@
"claim_id": {
"name": "Krav-ID",
"description": "ID-en til belønningskravet som skal avvises"
},
"reason": {
"name": "Begrunnelse",
"description": "Valgfri begrunnelse som vises for barnet"
}
}
},
@@ -525,6 +554,18 @@
"requires_approval": {
"name": "Krever godkjenning",
"description": "Hvis sann må en forelder godkjenne fullføringen før poeng tildeles"
},
"team_size": {
"name": "Lagstørrelse",
"description": "Gjør dette til en lagoppgave som først teller når så mange ulike barn er med. Når laget er fullt, sendes den inn for alle. 0 = en vanlig oppgave."
},
"team_points_mode": {
"name": "Lagpoeng",
"description": "Hvordan en lagoppgave betaler ut: «each» gir alle deltakerne fulle poeng, «split» deler dem likt (rundet ned)."
},
"team_bonus": {
"name": "Lagbonus",
"description": "Ekstra poeng hver deltaker får når en lagoppgave er fullført."
}
}
},
@@ -616,6 +657,174 @@
}
}
},
"leave_team_chore": {
"name": "Forlat lagoppgave",
"description": "Ta et barn ut av en lagoppgave det ble med på i dag, før laget er fullt",
"fields": {
"chore_id": {
"name": "Oppgave-ID",
"description": "ID-en til lagoppgaven"
},
"child_id": {
"name": "Barn-ID",
"description": "ID-en til barnet som forlater laget"
}
}
},
"complete_next_chore": {
"name": "Fullfør neste oppgave",
"description": "Fullfører barnets neste utestående oppgave, i rekkefølgen barnekortet viser dem. Oppgaver som krever bilde, notat, tidtaker eller lag hoppes over. Laget for klokkeknapper og snarveier; vanlige godkjenningsregler gjelder.",
"fields": {
"child_id": {
"name": "Barn-ID",
"description": "ID-en til barnet som skal få neste oppgave fullført"
}
}
},
"post_bounty": {
"name": "Legg ut dusør",
"description": "Legg ut en engangsjobb på dusørtavlen for et fast antall poeng. Alle barn som kan, får ta den; når den er gjort, går den gjennom vanlig godkjenning.",
"fields": {
"title": {
"name": "Tittel",
"description": "Hva jobben er, f.eks. \"Vask bilen\""
},
"points": {
"name": "Poeng",
"description": "Faste poeng som betales når en forelder godkjenner den ferdige jobben"
},
"description": {
"name": "Beskrivelse",
"description": "Valgfrie detaljer til barna"
},
"icon": {
"name": "Ikon",
"description": "MDI-ikon som vises på tavlen"
},
"expires_at": {
"name": "Utløper",
"description": "Valgfri dato og tid da dusøren forsvinner fra tavlen hvis ingen har tatt den. La stå tom for ingen utløp."
},
"eligible_child_ids": {
"name": "Barn som kan ta den",
"description": "ID-ene til barna som kan ta den. Tom = alle barn."
},
"claim_hours": {
"name": "Reservasjon (timer)",
"description": "Hvor lenge en reservasjon varer før dusøren går tilbake på tavlen"
},
"require_photo": {
"name": "Krev bilde",
"description": "Barnet må legge ved et bilde når det melder jobben ferdig"
},
"notify_children": {
"name": "Varsle barna",
"description": "Send varselet \"Ny dusør\" til barna som kan ta den"
}
}
},
"update_bounty": {
"name": "Oppdater dusør",
"description": "Rediger en dusør. Når den er tatt, kan bare poeng og utløp endres.",
"fields": {
"bounty_id": {
"name": "Dusør-ID",
"description": "ID-en til dusøren som skal redigeres"
},
"title": {
"name": "Tittel",
"description": "Hva jobben er, f.eks. \"Vask bilen\""
},
"points": {
"name": "Poeng",
"description": "Faste poeng som betales når en forelder godkjenner den ferdige jobben"
},
"description": {
"name": "Beskrivelse",
"description": "Valgfrie detaljer til barna"
},
"icon": {
"name": "Ikon",
"description": "MDI-ikon som vises på tavlen"
},
"expires_at": {
"name": "Utløper",
"description": "Valgfri dato og tid da dusøren forsvinner fra tavlen hvis ingen har tatt den. La stå tom for ingen utløp."
},
"eligible_child_ids": {
"name": "Barn som kan ta den",
"description": "ID-ene til barna som kan ta den. Tom = alle barn."
},
"claim_hours": {
"name": "Reservasjon (timer)",
"description": "Hvor lenge en reservasjon varer før dusøren går tilbake på tavlen"
},
"require_photo": {
"name": "Krev bilde",
"description": "Barnet må legge ved et bilde når det melder jobben ferdig"
},
"notify_children": {
"name": "Varsle barna",
"description": "Send varselet \"Ny dusør\" til barna som kan ta den"
}
}
},
"remove_bounty": {
"name": "Fjern dusør",
"description": "Ta en dusør av tavlen. Ikke mulig mens en ferdig jobb venter på godkjenning.",
"fields": {
"bounty_id": {
"name": "Dusør-ID",
"description": "ID-en til dusøren som skal fjernes"
}
}
},
"claim_bounty": {
"name": "Ta dusør",
"description": "Ta en dusør for et barn og lås den til dem i reservasjonstiden. Et barn kan ha én om gangen.",
"fields": {
"bounty_id": {
"name": "Dusør-ID",
"description": "ID-en til dusøren som skal tas"
},
"child_id": {
"name": "Barne-ID",
"description": "ID-en til barnet som tar den"
}
}
},
"give_back_bounty": {
"name": "Gi tilbake dusør",
"description": "Gi en tatt dusør tilbake til tavlen så et annet barn kan ta den",
"fields": {
"bounty_id": {
"name": "Dusør-ID",
"description": "ID-en til den tatte dusøren"
},
"child_id": {
"name": "Barne-ID",
"description": "ID-en til barnet som tok den"
}
}
},
"complete_bounty": {
"name": "Fullfør dusør",
"description": "Meld en tatt dusør som ferdig. Den går til godkjenning som en oppgave.",
"fields": {
"bounty_id": {
"name": "Dusør-ID",
"description": "ID-en til den tatte dusøren"
},
"child_id": {
"name": "Barne-ID",
"description": "ID-en til barnet som tok den"
},
"photo_url": {
"name": "Bilde-URL",
"description": "Bilde av den ferdige jobben (påkrevd når dusøren ber om det)"
}
}
},
"pause_timed_task": {
"name": "Sett tidsbasert oppgave på pause",
"description": "Sett timeren på en tidsbasert oppgave på pause",
@@ -818,6 +1027,20 @@
}
}
},
"adjust_streak_freezes": {
"name": "Juster rekkefrys",
"description": "Gi et barn rekkefrys (positivt antall) eller ta noen bort (negativt). En tildeling går aldri over grensen streak_freeze_max.",
"fields": {
"child_id": {
"name": "Barne-ID",
"description": "ID-en til barnet som får endret frys"
},
"amount": {
"name": "Antall",
"description": "Frys som skal legges til (positivt) eller fjernes (negativt)"
}
}
},
"postpone_mandatory_chore": {
"name": "Utsett obligatorisk oppgave",
"description": "Gi en glemt obligatorisk oppgave en ny periode (eller i morgen) uten straff, og fjern gjennomgangselementet.",
@@ -865,6 +1088,238 @@
"description": "Poeng som skal regnes om til penger og trekkes fra"
}
}
},
"add_wish": {
"name": "Legg til ønske",
"description": "Et barn legger til noe det vil spare til. Det venter på at en forelder godkjenner det.",
"fields": {
"child_id": {
"name": "Barne-ID",
"description": "ID-en til barnet som ønsker seg noe"
},
"name": {
"name": "Navn",
"description": "Det barnet ønsker seg"
},
"target": {
"name": "Mål",
"description": "Foreslått antall poeng det koster. Forelderen kan endre det ved godkjenning."
},
"link": {
"name": "Lenke",
"description": "Valgfri nettadresse (http eller https) til tingen"
},
"photo_url": {
"name": "Bilde",
"description": "Valgfritt bilde som nettopp er lastet opp via TaskMates fotoendepunkt"
}
}
},
"withdraw_wish": {
"name": "Trekk tilbake ønske",
"description": "Et barn trekker tilbake et ønske som fortsatt venter på godkjenning, eller fjerner et avslått ønske.",
"fields": {
"wish_id": {
"name": "Ønske-ID",
"description": "ID-en til ønsket"
},
"child_id": {
"name": "Barne-ID",
"description": "ID-en til barnet som ønsket tilhører"
}
}
},
"move_points_to_wish": {
"name": "Flytt poeng til ønske",
"description": "Flytt poeng fra et barns disponible saldo til ett av barnets godkjente ønsker.",
"fields": {
"wish_id": {
"name": "Ønske-ID",
"description": "ID-en til ønsket"
},
"child_id": {
"name": "Barne-ID",
"description": "ID-en til barnet som ønsket tilhører"
},
"points": {
"name": "Poeng",
"description": "Hvor mange poeng som flyttes (høyst saldoen og det ønsket fortsatt mangler)"
}
}
},
"take_points_from_wish": {
"name": "Ta poeng fra ønske",
"description": "Gi noen av barnets egne sparte poeng tilbake fra et ønske. Poeng fra tilsagn kan aldri tas ut.",
"fields": {
"wish_id": {
"name": "Ønske-ID",
"description": "ID-en til ønsket"
},
"child_id": {
"name": "Barne-ID",
"description": "ID-en til barnet som ønsket tilhører"
},
"points": {
"name": "Poeng",
"description": "Hvor mange sparte poeng som tas tilbake (høyst det barnet har spart)"
}
}
},
"request_wish_redeem": {
"name": "Be om å løse inn ønske",
"description": "Et fullfinansiert ønske blir et belønningskrav som venter på forelderens godkjenning.",
"fields": {
"wish_id": {
"name": "Ønske-ID",
"description": "ID-en til ønsket"
},
"child_id": {
"name": "Barne-ID",
"description": "ID-en til barnet som ønsket tilhører"
}
}
},
"approve_wish": {
"name": "Godkjenn ønske",
"description": "Gjør et ventende ønske om til et sparemål, eventuelt med et annet mål.",
"fields": {
"wish_id": {
"name": "Ønske-ID",
"description": "ID-en til ønsket"
},
"target": {
"name": "Mål",
"description": "Poeng ønsket koster. La feltet stå tomt for å beholde barnets forslag."
}
}
},
"decline_wish": {
"name": "Avslå ønske",
"description": "Si nei til et ønske. Sparte poeng går tilbake til barnet, og tilsagn annulleres.",
"fields": {
"wish_id": {
"name": "Ønske-ID",
"description": "ID-en til ønsket"
},
"reason": {
"name": "Begrunnelse",
"description": "Valgfri begrunnelse som vises for barnet"
}
}
},
"pledge_to_wish": {
"name": "Gi tilsagn til ønske",
"description": "Registrer poeng et familiemedlem bidrar med til et barns ønske. De rører aldri noen saldo.",
"fields": {
"wish_id": {
"name": "Ønske-ID",
"description": "ID-en til ønsket"
},
"name": {
"name": "Fra",
"description": "Hvem tilsagnet er fra, f.eks. Bestemor"
},
"points": {
"name": "Poeng",
"description": "Hvor mange poeng det gis tilsagn om (høyst det ønsket fortsatt mangler)"
},
"message": {
"name": "Melding",
"description": "Valgfri melding til barnet"
}
}
},
"remove_wish_pledge": {
"name": "Fjern tilsagn",
"description": "Angre et tilsagn. Mulig til ønsket er løst inn.",
"fields": {
"wish_id": {
"name": "Ønske-ID",
"description": "ID-en til ønsket"
},
"pledge_id": {
"name": "Tilsagn-ID",
"description": "ID-en til tilsagnet som skal fjernes"
}
}
},
"remove_wish": {
"name": "Fjern ønske",
"description": "Slett et ønske. Sparte poeng går tilbake til barnet, og tilsagn annulleres.",
"fields": {
"wish_id": {
"name": "Ønske-ID",
"description": "ID-en til ønsket"
}
}
},
"start_inspection": {
"name": "Start inspeksjon",
"description": "Merk en oppgave som er godkjent de siste 24 timene for en overraskelsesinspeksjon. Godkjenn eller underkjenn den før vinduet lukkes; en uavgjort inspeksjon lukkes stille.",
"fields": {
"completion_id": {
"name": "Fullførings-ID",
"description": "ID-en til den godkjente fullføringen som skal inspiseres"
},
"bonus": {
"name": "Bonus",
"description": "Poeng som betales hvis den består. Standard er verdien fra Innstillinger."
},
"window": {
"name": "Vindu",
"description": "Hvor lenge du har på å sjekke: 1h, 2h, 4h eller bed (til leggetid). Standard er verdien fra Innstillinger."
},
"tell_child": {
"name": "Si fra til barnet",
"description": "Vis «Inspeksjon på vei» på barnets kort nå. Slå av for en hemmelig sjekk."
}
}
},
"pass_inspection": {
"name": "Godkjenn inspeksjon",
"description": "Den inspiserte oppgaven besto. Betaler bonusen som en vanlig poengføring og feirer på barnets kort.",
"fields": {
"inspection_id": {
"name": "Inspeksjons-ID",
"description": "ID-en til den åpne inspeksjonen"
},
"bonus": {
"name": "Bonus",
"description": "Poeng som skal betales. Standard er bonusen som ble satt da inspeksjonen startet."
},
"note": {
"name": "Notat",
"description": "Valgfritt notat som vises til barnet"
}
}
},
"fail_inspection": {
"name": "Underkjenn inspeksjon",
"description": "Den inspiserte oppgaven besto ikke. Ingen poeng trekkes. Den kan sendes tilbake for å gjøres på nytt; det gir ingen ekstra poeng.",
"fields": {
"inspection_id": {
"name": "Inspeksjons-ID",
"description": "ID-en til den åpne inspeksjonen"
},
"redo": {
"name": "Send tilbake for å gjøres på nytt",
"description": "Setter oppgaven tilbake på barnets liste som ikke gjort i dag. Standard er verdien fra Innstillinger."
},
"note": {
"name": "Notat",
"description": "Valgfritt notat som vises til barnet (hva som må fikses)"
}
}
},
"cancel_inspection": {
"name": "Avbryt inspeksjon",
"description": "Avlys en åpen inspeksjon, som om den aldri startet.",
"fields": {
"inspection_id": {
"name": "Inspeksjons-ID",
"description": "ID-en til den åpne inspeksjonen"
}
}
}
},
"entity": {
@@ -874,6 +1329,13 @@
},
"child_badges": {
"unit_of_measurement": "merker"
},
"child_watch": {
"state": {
"left": "# igjen",
"all_done": "Alt ferdig",
"no_chores": "Ingen oppgaver"
}
}
},
"number": {
@@ -892,5 +1354,34 @@
"name": "Kortdesign"
}
}
},
"exceptions": {
"calendar_unknown_event": {
"message": "Denne hendelsen er ikke en TaskMate-oppgave i dette barnets kalender."
},
"calendar_series_not_supported": {
"message": "En gjentakende oppgave kan bare flyttes eller fjernes én dag om gangen fra kalenderen. Rediger oppgaven i TaskMate for å endre hele serien."
},
"calendar_repeating_create": {
"message": "Gjentakende hendelser kan ikke legges til fra kalenderen. Opprett en gjentakende oppgave i TaskMate i stedet."
},
"calendar_rename_series": {
"message": "Gi en gjentakende oppgave nytt navn i TaskMate. Fra kalenderen kan du bare flytte eller fjerne én enkelt dag."
},
"calendar_time_follows_chore": {
"message": "En gjentakende oppgave beholder tidspunktet på dagen. Bare datoen for én enkelt forekomst kan endres fra kalenderen."
},
"calendar_past_date": {
"message": "Oppgaver kan ikke legges til, flyttes til eller flyttes fra en dag som har vært."
},
"calendar_already_scheduled": {
"message": "{chore} ligger allerede på {date}."
},
"calendar_empty_title": {
"message": "Gi oppgaven en tittel."
},
"calendar_own_event": {
"message": "Denne hendelsen er publisert av TaskMate selv, så den legges ikke til som en ny oppgave."
}
}
}
@@ -15,6 +15,9 @@
},
"abort": {
"already_configured": "TaskMate er allereie konfigurert"
},
"create_entry": {
"default": "TaskMate er klar. [Opne TaskMate-panelet](/taskmate-admin) for å leggje til borna dine med oppsettsvegvisaren, som føreslår oppgåver etter alderen deira."
}
},
"selector": {
@@ -179,6 +182,10 @@
"points": {
"name": "Poeng",
"description": "Gje dette talet poeng i staden for oppgåva sin eigen verdi. Lat stå tomt for å bruke poenga til oppgåva."
},
"rating": {
"name": "Kvalitetsvurdering",
"description": "Valfri vurdering på 1-3 stjerner av kor godt oppgåva vart gjord. Når kvalitetsvurdering er slått på, vert poenga skalerte med multiplikatoren til stjerna; la feltet stå tomt for å gje 100 %."
}
}
},
@@ -189,6 +196,10 @@
"completion_ids": {
"name": "Fullførings-ID-ar",
"description": "Bestemte fullførings-ID-ar som skal godkjennast; utelat for å godkjenne alle ventande"
},
"rating": {
"name": "Kvalitetsvurdering",
"description": "Valfri vurdering på 1-3 stjerner som gjeld alle oppgåver godkjende med dette kallet (kvalitetsvurdering må vere slått på)."
}
}
},
@@ -199,6 +210,20 @@
"completion_id": {
"name": "Fullførings-ID",
"description": "ID-en til oppgåvefullføringa som skal avvisast"
},
"reason": {
"name": "Grunngjeving",
"description": "Valfri grunngjeving som blir vist til barnet"
}
}
},
"undo_chore": {
"name": "Angre gjeremål",
"description": "La eit barn ta tilbake sitt eige gjeremål — ei innsending som ventar, heilt til ein forelder har gått gjennom ho, eller eit automatisk godkjent gjeremål innanfor angrevindauget. Av med mindre vindauget er sett over 0 i TaskMate-innstillingane; alt ein forelder har gått gjennom, må ein forelder angre.",
"fields": {
"completion_id": {
"name": "Fullførings-ID",
"description": "ID-en til det fullførte gjeremålet til barnet som skal takast tilbake"
}
}
},
@@ -307,6 +332,10 @@
"claim_id": {
"name": "Krav-ID",
"description": "ID-en til løningskravet som skal avvisast"
},
"reason": {
"name": "Grunngjeving",
"description": "Valfri grunngjeving som blir vist til barnet"
}
}
},
@@ -525,6 +554,18 @@
"requires_approval": {
"name": "Krev godkjenning",
"description": "Om sann må ein forelder godkjenne fullføringa før poeng vert tildelte"
},
"team_size": {
"name": "Lagstorleik",
"description": "Gjer dette til ei lagoppgåve som først tel når så mange ulike born er med. Når laget er fullt, vert ho send inn for alle. 0 = ei vanleg oppgåve."
},
"team_points_mode": {
"name": "Lagpoeng",
"description": "Korleis ei lagoppgåve betaler ut: «each» gjev alle deltakarane fulle poeng, «split» deler dei likt (runda ned)."
},
"team_bonus": {
"name": "Lagbonus",
"description": "Ekstra poeng kvar deltakar får når ei lagoppgåve er fullført."
}
}
},
@@ -616,6 +657,174 @@
}
}
},
"leave_team_chore": {
"name": "Forlat lagoppgåve",
"description": "Ta eit born ut av ei lagoppgåve det vart med på i dag, før laget er fullt",
"fields": {
"chore_id": {
"name": "Oppgåve-ID",
"description": "ID-en til lagoppgåva"
},
"child_id": {
"name": "Born-ID",
"description": "ID-en til bornet som forlèt laget"
}
}
},
"complete_next_chore": {
"name": "Fullfør neste oppgåve",
"description": "Fullfører den neste uteståande oppgåva til barnet, i rekkjefølgja barnekortet viser dei. Oppgåver som krev bilete, notat, tidtakar eller lag vert hoppa over. Laga for klokkeknappar og snarvegar; vanlege godkjenningsreglar gjeld.",
"fields": {
"child_id": {
"name": "Born-ID",
"description": "ID-en til barnet som skal få neste oppgåve fullført"
}
}
},
"post_bounty": {
"name": "Legg ut dusør",
"description": "Legg ut ein eingongsjobb på dusørtavla for eit fast tal poeng. Alle born som kan, får ta han; når han er gjord, går han gjennom vanleg godkjenning.",
"fields": {
"title": {
"name": "Tittel",
"description": "Kva jobben er, t.d. \"Vask bilen\""
},
"points": {
"name": "Poeng",
"description": "Faste poeng som vert betalte når ein forelder godkjenner den ferdige jobben"
},
"description": {
"name": "Skildring",
"description": "Valfrie detaljar til borna"
},
"icon": {
"name": "Ikon",
"description": "MDI-ikon som vert vist på tavla"
},
"expires_at": {
"name": "Går ut",
"description": "Valfri dato og tid då dusøren forsvinn frå tavla viss ingen har teke han. La stå tom for inga utløp."
},
"eligible_child_ids": {
"name": "Born som kan ta han",
"description": "ID-ane til borna som kan ta han. Tom = alle born."
},
"claim_hours": {
"name": "Reservasjon (timar)",
"description": "Kor lenge ein reservasjon varer før dusøren går tilbake på tavla"
},
"require_photo": {
"name": "Krev bilete",
"description": "Barnet må leggje ved eit bilete når det melder jobben ferdig"
},
"notify_children": {
"name": "Varsle borna",
"description": "Send varselet \"Ny dusør\" til borna som kan ta han"
}
}
},
"update_bounty": {
"name": "Oppdater dusør",
"description": "Rediger ein dusør. Når han er teken, kan berre poeng og utløp endrast.",
"fields": {
"bounty_id": {
"name": "Dusør-ID",
"description": "ID-en til dusøren som skal redigerast"
},
"title": {
"name": "Tittel",
"description": "Kva jobben er, t.d. \"Vask bilen\""
},
"points": {
"name": "Poeng",
"description": "Faste poeng som vert betalte når ein forelder godkjenner den ferdige jobben"
},
"description": {
"name": "Skildring",
"description": "Valfrie detaljar til borna"
},
"icon": {
"name": "Ikon",
"description": "MDI-ikon som vert vist på tavla"
},
"expires_at": {
"name": "Går ut",
"description": "Valfri dato og tid då dusøren forsvinn frå tavla viss ingen har teke han. La stå tom for inga utløp."
},
"eligible_child_ids": {
"name": "Born som kan ta han",
"description": "ID-ane til borna som kan ta han. Tom = alle born."
},
"claim_hours": {
"name": "Reservasjon (timar)",
"description": "Kor lenge ein reservasjon varer før dusøren går tilbake på tavla"
},
"require_photo": {
"name": "Krev bilete",
"description": "Barnet må leggje ved eit bilete når det melder jobben ferdig"
},
"notify_children": {
"name": "Varsle borna",
"description": "Send varselet \"Ny dusør\" til borna som kan ta han"
}
}
},
"remove_bounty": {
"name": "Fjern dusør",
"description": "Ta ein dusør av tavla. Ikkje mogleg medan ein ferdig jobb ventar på godkjenning.",
"fields": {
"bounty_id": {
"name": "Dusør-ID",
"description": "ID-en til dusøren som skal fjernast"
}
}
},
"claim_bounty": {
"name": "Ta dusør",
"description": "Ta ein dusør for eit barn og lås han til dei i reservasjonstida. Eit barn kan ha éin om gongen.",
"fields": {
"bounty_id": {
"name": "Dusør-ID",
"description": "ID-en til dusøren som skal takast"
},
"child_id": {
"name": "Barne-ID",
"description": "ID-en til barnet som tek han"
}
}
},
"give_back_bounty": {
"name": "Gje tilbake dusør",
"description": "Gje ein teken dusør tilbake til tavla så eit anna barn kan ta han",
"fields": {
"bounty_id": {
"name": "Dusør-ID",
"description": "ID-en til den tekne dusøren"
},
"child_id": {
"name": "Barne-ID",
"description": "ID-en til barnet som tok han"
}
}
},
"complete_bounty": {
"name": "Fullfør dusør",
"description": "Meld ein teken dusør som ferdig. Han går til godkjenning som ei oppgåve.",
"fields": {
"bounty_id": {
"name": "Dusør-ID",
"description": "ID-en til den tekne dusøren"
},
"child_id": {
"name": "Barne-ID",
"description": "ID-en til barnet som tok han"
},
"photo_url": {
"name": "Bilete-URL",
"description": "Bilete av den ferdige jobben (påkravd når dusøren ber om det)"
}
}
},
"pause_timed_task": {
"name": "Set tidsstyrt oppgåve på pause",
"description": "Set timeren på ei tidsstyrt oppgåve på pause",
@@ -818,6 +1027,20 @@
}
}
},
"adjust_streak_freezes": {
"name": "Juster rekkjefrys",
"description": "Gje eit barn rekkjefrys (positivt tal) eller ta nokre bort (negativt). Ei tildeling går aldri over grensa streak_freeze_max.",
"fields": {
"child_id": {
"name": "Barne-ID",
"description": "ID-en til barnet som får endra frys"
},
"amount": {
"name": "Tal",
"description": "Frys som skal leggjast til (positivt) eller fjernast (negativt)"
}
}
},
"postpone_mandatory_chore": {
"name": "Utsett obligatorisk oppgåve",
"description": "Gi ei gløymd obligatorisk oppgåve ein ny periode (eller i morgon) utan straff, og fjern gjennomgangselementet.",
@@ -865,6 +1088,238 @@
"description": "Poeng som skal reknast om til pengar og trekkjast frå"
}
}
},
"add_wish": {
"name": "Legg til ønske",
"description": "Eit barn legg til noko det vil spare til. Det ventar på at ein forelder godkjenner det.",
"fields": {
"child_id": {
"name": "Barne-ID",
"description": "ID-en til barnet som ønskjer seg noko"
},
"name": {
"name": "Namn",
"description": "Det barnet ønskjer seg"
},
"target": {
"name": "Mål",
"description": "Føreslått tal på poeng det kostar. Forelderen kan endre det ved godkjenning."
},
"link": {
"name": "Lenkje",
"description": "Valfri nettadresse (http eller https) til tingen"
},
"photo_url": {
"name": "Bilete",
"description": "Valfritt bilete som nettopp er lasta opp via fotoendepunktet til TaskMate"
}
}
},
"withdraw_wish": {
"name": "Trekk tilbake ønske",
"description": "Eit barn trekkjer tilbake eit ønske som framleis ventar på godkjenning, eller fjernar eit avslått ønske.",
"fields": {
"wish_id": {
"name": "Ønske-ID",
"description": "ID-en til ønsket"
},
"child_id": {
"name": "Barne-ID",
"description": "ID-en til barnet som ønsket høyrer til"
}
}
},
"move_points_to_wish": {
"name": "Flytt poeng til ønske",
"description": "Flytt poeng frå den disponible saldoen til eit barn til eitt av dei godkjende ønska.",
"fields": {
"wish_id": {
"name": "Ønske-ID",
"description": "ID-en til ønsket"
},
"child_id": {
"name": "Barne-ID",
"description": "ID-en til barnet som ønsket høyrer til"
},
"points": {
"name": "Poeng",
"description": "Kor mange poeng som skal flyttast (høgst saldoen og det ønsket framleis manglar)"
}
}
},
"take_points_from_wish": {
"name": "Ta poeng frå ønske",
"description": "Gi nokre av dei sparte poenga til barnet tilbake frå eit ønske. Poeng frå tilsegn kan aldri takast ut.",
"fields": {
"wish_id": {
"name": "Ønske-ID",
"description": "ID-en til ønsket"
},
"child_id": {
"name": "Barne-ID",
"description": "ID-en til barnet som ønsket høyrer til"
},
"points": {
"name": "Poeng",
"description": "Kor mange sparte poeng som skal takast tilbake (høgst det barnet har spart)"
}
}
},
"request_wish_redeem": {
"name": "Be om å løyse inn ønske",
"description": "Eit fullfinansiert ønske blir eit belønningskrav som ventar på godkjenning frå forelderen.",
"fields": {
"wish_id": {
"name": "Ønske-ID",
"description": "ID-en til ønsket"
},
"child_id": {
"name": "Barne-ID",
"description": "ID-en til barnet som ønsket høyrer til"
}
}
},
"approve_wish": {
"name": "Godkjenn ønske",
"description": "Gjer eit ventande ønske om til eit sparemål, eventuelt med eit anna mål.",
"fields": {
"wish_id": {
"name": "Ønske-ID",
"description": "ID-en til ønsket"
},
"target": {
"name": "Mål",
"description": "Poeng ønsket kostar. La feltet stå tomt for å halde på forslaget frå barnet."
}
}
},
"decline_wish": {
"name": "Avslå ønske",
"description": "Sei nei til eit ønske. Sparte poeng går tilbake til barnet, og tilsegn blir annullerte.",
"fields": {
"wish_id": {
"name": "Ønske-ID",
"description": "ID-en til ønsket"
},
"reason": {
"name": "Grunngjeving",
"description": "Valfri grunngjeving som blir vist til barnet"
}
}
},
"pledge_to_wish": {
"name": "Gi tilsegn til ønske",
"description": "Registrer poeng som ein slektning bidreg med til ønsket til eit barn. Dei rører aldri nokon saldo.",
"fields": {
"wish_id": {
"name": "Ønske-ID",
"description": "ID-en til ønsket"
},
"name": {
"name": "Frå",
"description": "Kven tilsegnet er frå, t.d. Bestemor"
},
"points": {
"name": "Poeng",
"description": "Kor mange poeng det blir gitt tilsegn om (høgst det ønsket framleis manglar)"
},
"message": {
"name": "Melding",
"description": "Valfri melding til barnet"
}
}
},
"remove_wish_pledge": {
"name": "Fjern tilsegn",
"description": "Angre eit tilsegn. Mogleg til ønsket er løyst inn.",
"fields": {
"wish_id": {
"name": "Ønske-ID",
"description": "ID-en til ønsket"
},
"pledge_id": {
"name": "Tilsegn-ID",
"description": "ID-en til tilsegnet som skal fjernast"
}
}
},
"remove_wish": {
"name": "Fjern ønske",
"description": "Slett eit ønske. Sparte poeng går tilbake til barnet, og tilsegn blir annullerte.",
"fields": {
"wish_id": {
"name": "Ønske-ID",
"description": "ID-en til ønsket"
}
}
},
"start_inspection": {
"name": "Start inspeksjon",
"description": "Merk ei oppgåve som er godkjend dei siste 24 timane for ein overraskingsinspeksjon. Godkjenn eller underkjenn ho før vindauget lukkar seg; ein uavgjord inspeksjon lukkar seg stille.",
"fields": {
"completion_id": {
"name": "Fullførings-ID",
"description": "ID-en til den godkjende fullføringa som skal inspiserast"
},
"bonus": {
"name": "Bonus",
"description": "Poeng som vert betalte om ho står. Standard er verdien frå Innstillingar."
},
"window": {
"name": "Vindauge",
"description": "Kor lenge du har på å sjekke: 1h, 2h, 4h eller bed (til leggjetid). Standard er verdien frå Innstillingar."
},
"tell_child": {
"name": "Sei frå til barnet",
"description": "Vis «Inspeksjon på veg» på kortet til barnet no. Slå av for ein hemmeleg sjekk."
}
}
},
"pass_inspection": {
"name": "Godkjenn inspeksjon",
"description": "Den inspiserte oppgåva stod. Betaler bonusen som ei vanleg poengføring og feirar på kortet til barnet.",
"fields": {
"inspection_id": {
"name": "Inspeksjons-ID",
"description": "ID-en til den opne inspeksjonen"
},
"bonus": {
"name": "Bonus",
"description": "Poeng som skal betalast. Standard er bonusen som vart sett då inspeksjonen starta."
},
"note": {
"name": "Notat",
"description": "Valfritt notat som vert vist til barnet"
}
}
},
"fail_inspection": {
"name": "Underkjenn inspeksjon",
"description": "Den inspiserte oppgåva stod ikkje. Ingen poeng vert trekte. Ho kan sendast tilbake for å gjerast på nytt; det gjev ingen ekstra poeng.",
"fields": {
"inspection_id": {
"name": "Inspeksjons-ID",
"description": "ID-en til den opne inspeksjonen"
},
"redo": {
"name": "Send tilbake for å gjerast på nytt",
"description": "Set oppgåva tilbake på lista til barnet som ikkje gjord i dag. Standard er verdien frå Innstillingar."
},
"note": {
"name": "Notat",
"description": "Valfritt notat som vert vist til barnet (kva som må fiksast)"
}
}
},
"cancel_inspection": {
"name": "Avbryt inspeksjon",
"description": "Avlys ein open inspeksjon, som om han aldri starta.",
"fields": {
"inspection_id": {
"name": "Inspeksjons-ID",
"description": "ID-en til den opne inspeksjonen"
}
}
}
},
"entity": {
@@ -874,6 +1329,13 @@
},
"child_badges": {
"unit_of_measurement": "merke"
},
"child_watch": {
"state": {
"left": "# att",
"all_done": "Alt ferdig",
"no_chores": "Ingen oppgåver"
}
}
},
"number": {
@@ -892,5 +1354,34 @@
"name": "Kortdesign"
}
}
},
"exceptions": {
"calendar_unknown_event": {
"message": "Denne hendinga er ikkje ei TaskMate-oppgåve i kalenderen til dette barnet."
},
"calendar_series_not_supported": {
"message": "Ei gjentakande oppgåve kan berre flyttast eller fjernast éin dag om gongen frå kalenderen. Rediger oppgåva i TaskMate for å endre heile serien."
},
"calendar_repeating_create": {
"message": "Gjentakande hendingar kan ikkje leggjast til frå kalenderen. Opprett ei gjentakande oppgåve i TaskMate i staden."
},
"calendar_rename_series": {
"message": "Gi ei gjentakande oppgåve nytt namn i TaskMate. Frå kalenderen kan du berre flytte eller fjerne éin einskild dag."
},
"calendar_time_follows_chore": {
"message": "Ei gjentakande oppgåve held på tidspunktet på dagen. Berre datoen for éin einskild førekomst kan endrast frå kalenderen."
},
"calendar_past_date": {
"message": "Oppgåver kan ikkje leggjast til, flyttast til eller flyttast frå ein dag som har vore."
},
"calendar_already_scheduled": {
"message": "{chore} ligg allereie på {date}."
},
"calendar_empty_title": {
"message": "Gi oppgåva ein tittel."
},
"calendar_own_event": {
"message": "Denne hendinga er publisert av TaskMate sjølv, så ho blir ikkje lagd til som ei ny oppgåve."
}
}
}
@@ -15,6 +15,9 @@
},
"abort": {
"already_configured": "O TaskMate já está configurado"
},
"create_entry": {
"default": "O TaskMate está pronto. [Abra o painel do TaskMate](/taskmate-admin) para adicionar suas crianças com o assistente de configuração, que sugere tarefas para a idade delas."
}
},
"selector": {
@@ -179,6 +182,10 @@
"points": {
"name": "Pontos",
"description": "Atribuir esta quantidade de pontos em vez do valor da própria tarefa. Deixe vazio para usar os pontos da tarefa."
},
"rating": {
"name": "Avaliação de qualidade",
"description": "Avaliação opcional de 1 a 3 estrelas sobre a qualidade da tarefa. Com as avaliações de qualidade ativadas, os pontos são multiplicados pelo multiplicador dessa estrela; deixe vazio para pagar 100%."
}
}
},
@@ -189,6 +196,10 @@
"completion_ids": {
"name": "IDs de conclusão",
"description": "IDs de conclusão específicos para aprovar; omita para aprovar todas as pendentes"
},
"rating": {
"name": "Avaliação de qualidade",
"description": "Avaliação opcional de 1 a 3 estrelas aplicada a todas as tarefas aprovadas por esta chamada (as avaliações de qualidade precisam estar ativadas)."
}
}
},
@@ -199,6 +210,20 @@
"completion_id": {
"name": "ID de conclusão",
"description": "O ID da conclusão da tarefa a rejeitar"
},
"reason": {
"name": "Motivo",
"description": "Motivo opcional mostrado à criança"
}
}
},
"undo_chore": {
"name": "Desfazer tarefa",
"description": "Permite que uma criança desfaça a própria tarefa — um envio pendente até que um responsável o revise, ou uma tarefa aprovada automaticamente dentro da janela para desfazer. Desativado a menos que a janela esteja acima de 0 nas configurações do TaskMate; o que um responsável já revisou só pode ser desfeito por um responsável.",
"fields": {
"completion_id": {
"name": "ID da conclusão",
"description": "O ID da tarefa concluída pela criança a desfazer"
}
}
},
@@ -307,6 +332,10 @@
"claim_id": {
"name": "ID do pedido",
"description": "O ID do pedido de recompensa a rejeitar"
},
"reason": {
"name": "Motivo",
"description": "Motivo opcional mostrado à criança"
}
}
},
@@ -525,6 +554,18 @@
"requires_approval": {
"name": "Requer aprovação",
"description": "Se verdadeiro, um pai/mãe deve aprovar a conclusão antes de os pontos serem concedidos"
},
"team_size": {
"name": "Tamanho da equipe",
"description": "Transforma esta tarefa em uma tarefa em equipe que só conta quando esse número de crianças diferentes participar. Quando a equipe está completa, ela é enviada para todas. 0 = uma tarefa normal."
},
"team_points_mode": {
"name": "Pontos da equipe",
"description": "Como uma tarefa em equipe paga: \"each\" dá os pontos completos a cada participante, \"split\" divide igualmente (arredondado para baixo)."
},
"team_bonus": {
"name": "Bônus de equipe",
"description": "Pontos extras que cada participante ganha quando uma tarefa em equipe é concluída."
}
}
},
@@ -616,6 +657,174 @@
}
}
},
"leave_team_chore": {
"name": "Sair de tarefa em equipe",
"description": "Tirar uma criança de uma tarefa em equipe em que entrou hoje, antes de a equipe estar completa",
"fields": {
"chore_id": {
"name": "ID da tarefa",
"description": "O ID da tarefa em equipe"
},
"child_id": {
"name": "ID da criança",
"description": "O ID da criança que sai da equipe"
}
}
},
"complete_next_chore": {
"name": "Concluir a próxima tarefa",
"description": "Conclui a próxima tarefa pendente da criança, na ordem mostrada no cartão da criança. Tarefas que exigem uma foto, uma nota, um cronômetro ou uma equipe são puladas. Feito para botões e atalhos do relógio; as regras normais de aprovação se aplicam.",
"fields": {
"child_id": {
"name": "ID da criança",
"description": "O ID da criança cuja próxima tarefa deve ser concluída"
}
}
},
"post_bounty": {
"name": "Publicar tarefa extra",
"description": "Publique uma tarefa extra avulsa no quadro de tarefas extras por um número fixo de pontos. Qualquer criança elegível pode pegá-la; quando feita, ela passa pela aprovação normal.",
"fields": {
"title": {
"name": "Título",
"description": "Qual é a tarefa, por ex. \"Lavar o carro\""
},
"points": {
"name": "Pontos",
"description": "Pontos fixos pagos quando um responsável aprova a tarefa feita"
},
"description": {
"name": "Descrição",
"description": "Detalhes opcionais para as crianças"
},
"icon": {
"name": "Ícone",
"description": "Ícone MDI exibido no quadro"
},
"expires_at": {
"name": "Expira em",
"description": "Data e hora opcionais em que a tarefa extra sai do quadro se ninguém a pegou. Deixe vazio para não expirar."
},
"eligible_child_ids": {
"name": "Crianças elegíveis",
"description": "IDs das crianças que podem pegá-la. Vazio = todas as crianças."
},
"claim_hours": {
"name": "Reserva (horas)",
"description": "Quanto tempo dura uma reserva antes de a tarefa extra voltar ao quadro"
},
"require_photo": {
"name": "Exigir foto",
"description": "A criança precisa anexar uma foto ao marcá-la como feita"
},
"notify_children": {
"name": "Avisar as crianças",
"description": "Enviar a notificação \"Nova tarefa extra\" às crianças elegíveis"
}
}
},
"update_bounty": {
"name": "Atualizar tarefa extra",
"description": "Edite uma tarefa extra. Depois de pega, só os pontos e a expiração podem mudar.",
"fields": {
"bounty_id": {
"name": "ID da tarefa extra",
"description": "O ID da tarefa extra a editar"
},
"title": {
"name": "Título",
"description": "Qual é a tarefa, por ex. \"Lavar o carro\""
},
"points": {
"name": "Pontos",
"description": "Pontos fixos pagos quando um responsável aprova a tarefa feita"
},
"description": {
"name": "Descrição",
"description": "Detalhes opcionais para as crianças"
},
"icon": {
"name": "Ícone",
"description": "Ícone MDI exibido no quadro"
},
"expires_at": {
"name": "Expira em",
"description": "Data e hora opcionais em que a tarefa extra sai do quadro se ninguém a pegou. Deixe vazio para não expirar."
},
"eligible_child_ids": {
"name": "Crianças elegíveis",
"description": "IDs das crianças que podem pegá-la. Vazio = todas as crianças."
},
"claim_hours": {
"name": "Reserva (horas)",
"description": "Quanto tempo dura uma reserva antes de a tarefa extra voltar ao quadro"
},
"require_photo": {
"name": "Exigir foto",
"description": "A criança precisa anexar uma foto ao marcá-la como feita"
},
"notify_children": {
"name": "Avisar as crianças",
"description": "Enviar a notificação \"Nova tarefa extra\" às crianças elegíveis"
}
}
},
"remove_bounty": {
"name": "Remover tarefa extra",
"description": "Tire uma tarefa extra do quadro. Não é possível enquanto uma tarefa feita aguarda aprovação.",
"fields": {
"bounty_id": {
"name": "ID da tarefa extra",
"description": "O ID da tarefa extra a remover"
}
}
},
"claim_bounty": {
"name": "Pegar tarefa extra",
"description": "Pegue uma tarefa extra para uma criança, reservando-a durante o período de reserva. Cada criança só pode ter uma por vez.",
"fields": {
"bounty_id": {
"name": "ID da tarefa extra",
"description": "O ID da tarefa extra a pegar"
},
"child_id": {
"name": "ID da criança",
"description": "O ID da criança que está pegando"
}
}
},
"give_back_bounty": {
"name": "Devolver tarefa extra",
"description": "Devolva uma tarefa extra pega ao quadro para outra criança poder pegá-la",
"fields": {
"bounty_id": {
"name": "ID da tarefa extra",
"description": "O ID da tarefa extra pega"
},
"child_id": {
"name": "ID da criança",
"description": "O ID da criança que a pegou"
}
}
},
"complete_bounty": {
"name": "Concluir tarefa extra",
"description": "Marque uma tarefa extra pega como feita. Ela vai para aprovação como uma tarefa normal.",
"fields": {
"bounty_id": {
"name": "ID da tarefa extra",
"description": "O ID da tarefa extra pega"
},
"child_id": {
"name": "ID da criança",
"description": "O ID da criança que a pegou"
},
"photo_url": {
"name": "URL da foto",
"description": "Foto da tarefa feita (obrigatória quando a tarefa extra pede)"
}
}
},
"pause_timed_task": {
"name": "Pausar tarefa cronometrada",
"description": "Pausar o cronômetro de uma tarefa cronometrada em andamento",
@@ -818,6 +1027,20 @@
}
}
},
"adjust_streak_freezes": {
"name": "Ajustar congelamentos de sequência",
"description": "Dá congelamentos de sequência a uma criança (quantidade positiva) ou remove alguns (negativa). Uma concessão nunca ultrapassa o limite streak_freeze_max.",
"fields": {
"child_id": {
"name": "ID da criança",
"description": "O ID da criança cujos congelamentos mudam"
},
"amount": {
"name": "Quantidade",
"description": "Congelamentos a adicionar (positivo) ou remover (negativo)"
}
}
},
"postpone_mandatory_chore": {
"name": "Adiar tarefa obrigatória",
"description": "Dá a uma tarefa obrigatória perdida outro período (ou amanhã) sem penalidade e remove o item de revisão.",
@@ -865,6 +1088,238 @@
"description": "Pontos a converter em dinheiro e deduzir"
}
}
},
"add_wish": {
"name": "Adicionar desejo",
"description": "Uma criança adiciona algo para o qual quer economizar. Fica aguardando a aprovação de um responsável.",
"fields": {
"child_id": {
"name": "ID da criança",
"description": "O ID da criança que faz o desejo"
},
"name": {
"name": "Nome",
"description": "O que a criança quer"
},
"target": {
"name": "Meta",
"description": "Número de pontos sugerido. O responsável pode alterá-lo ao aprovar."
},
"link": {
"name": "Link",
"description": "Endereço web opcional (http ou https) do item"
},
"photo_url": {
"name": "Imagem",
"description": "Imagem opcional recém-enviada pelo endpoint de fotos do TaskMate"
}
}
},
"withdraw_wish": {
"name": "Retirar desejo",
"description": "Uma criança retira um desejo que ainda aguarda aprovação, ou remove um que foi recusado.",
"fields": {
"wish_id": {
"name": "ID do desejo",
"description": "O ID do desejo"
},
"child_id": {
"name": "ID da criança",
"description": "O ID da criança dona do desejo"
}
}
},
"move_points_to_wish": {
"name": "Mover pontos para desejo",
"description": "Move pontos do saldo disponível de uma criança para um de seus desejos aprovados.",
"fields": {
"wish_id": {
"name": "ID do desejo",
"description": "O ID do desejo"
},
"child_id": {
"name": "ID da criança",
"description": "O ID da criança dona do desejo"
},
"points": {
"name": "Pontos",
"description": "Quantos pontos mover (limitado ao saldo e ao que ainda falta para o desejo)"
}
}
},
"take_points_from_wish": {
"name": "Retirar pontos do desejo",
"description": "Devolve à criança parte dos próprios pontos guardados em um desejo. Os pontos prometidos nunca saem.",
"fields": {
"wish_id": {
"name": "ID do desejo",
"description": "O ID do desejo"
},
"child_id": {
"name": "ID da criança",
"description": "O ID da criança dona do desejo"
},
"points": {
"name": "Pontos",
"description": "Quantos pontos guardados retirar (limitado ao que a criança guardou)"
}
}
},
"request_wish_redeem": {
"name": "Pedir para resgatar desejo",
"description": "Um desejo totalmente financiado vira um pedido de recompensa aguardando a aprovação do responsável.",
"fields": {
"wish_id": {
"name": "ID do desejo",
"description": "O ID do desejo"
},
"child_id": {
"name": "ID da criança",
"description": "O ID da criança dona do desejo"
}
}
},
"approve_wish": {
"name": "Aprovar desejo",
"description": "Transforma um desejo pendente em uma meta de economia, opcionalmente com outro valor.",
"fields": {
"wish_id": {
"name": "ID do desejo",
"description": "O ID do desejo"
},
"target": {
"name": "Meta",
"description": "Pontos que o desejo custa. Deixe vazio para manter a sugestão da criança."
}
}
},
"decline_wish": {
"name": "Recusar desejo",
"description": "Recusa um desejo. Os pontos guardados voltam para a criança e as promessas são anuladas.",
"fields": {
"wish_id": {
"name": "ID do desejo",
"description": "O ID do desejo"
},
"reason": {
"name": "Motivo",
"description": "Motivo opcional mostrado à criança"
}
}
},
"pledge_to_wish": {
"name": "Prometer para desejo",
"description": "Registra pontos que um familiar oferece para o desejo de uma criança. Nunca mexem em nenhum saldo.",
"fields": {
"wish_id": {
"name": "ID do desejo",
"description": "O ID do desejo"
},
"name": {
"name": "De",
"description": "De quem é a promessa, ex.: Vovó"
},
"points": {
"name": "Pontos",
"description": "Quantos pontos prometer (limitado ao que ainda falta para o desejo)"
},
"message": {
"name": "Mensagem",
"description": "Mensagem opcional para a criança"
}
}
},
"remove_wish_pledge": {
"name": "Remover promessa",
"description": "Desfaz uma promessa. Possível até o desejo ser resgatado.",
"fields": {
"wish_id": {
"name": "ID do desejo",
"description": "O ID do desejo"
},
"pledge_id": {
"name": "ID da promessa",
"description": "O ID da promessa a remover"
}
}
},
"remove_wish": {
"name": "Remover desejo",
"description": "Exclui um desejo. Os pontos guardados voltam para a criança e as promessas são anuladas.",
"fields": {
"wish_id": {
"name": "ID do desejo",
"description": "O ID do desejo"
}
}
},
"start_inspection": {
"name": "Iniciar inspeção",
"description": "Marca uma tarefa aprovada nas últimas 24 horas para uma inspeção surpresa. Aprove ou reprove antes de o prazo acabar; uma inspeção sem decisão fecha discretamente.",
"fields": {
"completion_id": {
"name": "ID da conclusão",
"description": "O ID da conclusão aprovada a ser inspecionada"
},
"bonus": {
"name": "Bônus",
"description": "Pontos pagos se passar. O padrão é o valor das Configurações."
},
"window": {
"name": "Prazo",
"description": "Quanto tempo você tem para conferir: 1h, 2h, 4h ou bed (até a hora de dormir). O padrão é o valor das Configurações."
},
"tell_child": {
"name": "Avisar a criança",
"description": "Mostra \"Vem aí uma inspeção\" no cartão da criança agora. Desligue para uma checagem secreta."
}
}
},
"pass_inspection": {
"name": "Aprovar inspeção",
"description": "A tarefa inspecionada passou. Paga o bônus como uma entrada de pontos normal e comemora no cartão da criança.",
"fields": {
"inspection_id": {
"name": "ID da inspeção",
"description": "O ID da inspeção em andamento"
},
"bonus": {
"name": "Bônus",
"description": "Pontos a pagar. O padrão é o bônus definido ao iniciar."
},
"note": {
"name": "Nota",
"description": "Nota opcional mostrada à criança"
}
}
},
"fail_inspection": {
"name": "Reprovar inspeção",
"description": "A tarefa inspecionada não passou. Nenhum ponto é retirado. Ela pode ser devolvida para refazer; refazer não dá pontos extras.",
"fields": {
"inspection_id": {
"name": "ID da inspeção",
"description": "O ID da inspeção em andamento"
},
"redo": {
"name": "Devolver para refazer",
"description": "Coloca a tarefa de volta na lista da criança como não feita hoje. O padrão é o valor das Configurações."
},
"note": {
"name": "Nota",
"description": "Nota opcional mostrada à criança (o que precisa ser corrigido)"
}
}
},
"cancel_inspection": {
"name": "Cancelar inspeção",
"description": "Cancela uma inspeção em andamento, como se nunca tivesse começado.",
"fields": {
"inspection_id": {
"name": "ID da inspeção",
"description": "O ID da inspeção em andamento"
}
}
}
},
"entity": {
@@ -874,6 +1329,13 @@
},
"child_badges": {
"unit_of_measurement": "medalhas"
},
"child_watch": {
"state": {
"left": "Faltam #",
"all_done": "Tudo feito",
"no_chores": "Sem tarefas"
}
}
},
"number": {
@@ -892,5 +1354,34 @@
"name": "Design do cartão"
}
}
},
"exceptions": {
"calendar_unknown_event": {
"message": "Este evento não é uma tarefa do TaskMate no calendário desta criança."
},
"calendar_series_not_supported": {
"message": "Uma tarefa repetida só pode ser movida ou removida no calendário um dia por vez. Para alterar a série inteira, edite a tarefa no TaskMate."
},
"calendar_repeating_create": {
"message": "Não é possível adicionar eventos repetidos pelo calendário. Crie uma tarefa repetida no TaskMate em vez disso."
},
"calendar_rename_series": {
"message": "Renomeie uma tarefa repetida no TaskMate. Pelo calendário você só pode mover ou remover um único dia."
},
"calendar_time_follows_chore": {
"message": "Uma tarefa repetida mantém seu horário do dia. Pelo calendário só é possível alterar a data de uma única ocorrência."
},
"calendar_past_date": {
"message": "Não é possível adicionar tarefas a um dia passado, nem movê-las para ou de um dia passado."
},
"calendar_already_scheduled": {
"message": "{chore} já está agendada para {date}."
},
"calendar_empty_title": {
"message": "Dê um título para a tarefa."
},
"calendar_own_event": {
"message": "Este evento foi publicado pelo próprio TaskMate, então não é adicionado como uma nova tarefa."
}
}
}
@@ -15,6 +15,9 @@
},
"abort": {
"already_configured": "O TaskMate já está configurado"
},
"create_entry": {
"default": "O TaskMate está pronto. [Abra o painel do TaskMate](/taskmate-admin) para adicionar as suas crianças com o assistente de configuração, que sugere tarefas para a idade delas."
}
},
"selector": {
@@ -179,6 +182,10 @@
"points": {
"name": "Pontos",
"description": "Atribuir este número de pontos em vez do valor da própria tarefa. Deixe vazio para usar os pontos da tarefa."
},
"rating": {
"name": "Avaliação de qualidade",
"description": "Avaliação opcional de 1 a 3 estrelas sobre a qualidade da tarefa. Com as avaliações de qualidade ativadas, os pontos são multiplicados pelo multiplicador dessa estrela; deixe vazio para pagar 100%."
}
}
},
@@ -189,6 +196,10 @@
"completion_ids": {
"name": "IDs de conclusão",
"description": "IDs de conclusão específicos para aprovar; omita para aprovar todas as pendentes"
},
"rating": {
"name": "Avaliação de qualidade",
"description": "Avaliação opcional de 1 a 3 estrelas aplicada a todas as tarefas aprovadas por esta chamada (as avaliações de qualidade têm de estar ativadas)."
}
}
},
@@ -199,6 +210,20 @@
"completion_id": {
"name": "ID de conclusão",
"description": "O ID da conclusão da tarefa a rejeitar"
},
"reason": {
"name": "Motivo",
"description": "Motivo opcional mostrado à criança"
}
}
},
"undo_chore": {
"name": "Anular tarefa",
"description": "Permite a uma criança retirar a sua própria tarefa — uma submissão pendente até um pai a rever, ou uma tarefa aprovada automaticamente dentro da janela de anulação. Desligado a menos que a janela esteja acima de 0 nas definições do TaskMate; o que um pai já reviu só pode ser anulado por um pai.",
"fields": {
"completion_id": {
"name": "ID de conclusão",
"description": "O ID da tarefa concluída pela criança a retirar"
}
}
},
@@ -307,6 +332,10 @@
"claim_id": {
"name": "ID do pedido",
"description": "O ID do pedido de recompensa a rejeitar"
},
"reason": {
"name": "Motivo",
"description": "Motivo opcional mostrado à criança"
}
}
},
@@ -525,6 +554,18 @@
"requires_approval": {
"name": "Requer aprovação",
"description": "Se verdadeiro, um pai/mãe deve aprovar a conclusão antes de os pontos serem atribuídos"
},
"team_size": {
"name": "Tamanho da equipa",
"description": "Torna esta tarefa numa tarefa de equipa que só conta quando este número de crianças diferentes se juntar. Quando a equipa está completa, é submetida para todas. 0 = uma tarefa normal."
},
"team_points_mode": {
"name": "Pontos da equipa",
"description": "Como uma tarefa de equipa paga: \"each\" dá os pontos completos a cada participante, \"split\" divide-os igualmente (arredondado para baixo)."
},
"team_bonus": {
"name": "Bónus de equipa",
"description": "Pontos extra que cada participante ganha quando uma tarefa de equipa é concluída."
}
}
},
@@ -616,6 +657,174 @@
}
}
},
"leave_team_chore": {
"name": "Sair de tarefa de equipa",
"description": "Retirar uma criança de uma tarefa de equipa a que se juntou hoje, antes de a equipa estar completa",
"fields": {
"chore_id": {
"name": "ID da tarefa",
"description": "O ID da tarefa de equipa"
},
"child_id": {
"name": "ID da criança",
"description": "O ID da criança que sai da equipa"
}
}
},
"complete_next_chore": {
"name": "Concluir a próxima tarefa",
"description": "Conclui a próxima tarefa pendente da criança, pela ordem mostrada no cartão da criança. As tarefas que exigem uma fotografia, uma nota, um temporizador ou uma equipa são ignoradas. Pensado para botões e atalhos do relógio; aplicam-se as regras de aprovação habituais.",
"fields": {
"child_id": {
"name": "ID da criança",
"description": "O ID da criança cuja próxima tarefa deve ser concluída"
}
}
},
"post_bounty": {
"name": "Publicar tarefa extra",
"description": "Publicar uma tarefa extra pontual no quadro de tarefas extra por um número fixo de pontos. Qualquer criança elegível pode ficar com ela; quando feita, passa pela aprovação normal.",
"fields": {
"title": {
"name": "Título",
"description": "Qual é a tarefa, p. ex. \"Lavar o carro\""
},
"points": {
"name": "Pontos",
"description": "Pontos fixos pagos quando um pai ou mãe aprova a tarefa feita"
},
"description": {
"name": "Descrição",
"description": "Detalhes opcionais para as crianças"
},
"icon": {
"name": "Ícone",
"description": "Ícone MDI mostrado no quadro"
},
"expires_at": {
"name": "Expira em",
"description": "Data e hora opcionais em que a tarefa extra sai do quadro se ninguém tiver ficado com ela. Deixe vazio para não expirar."
},
"eligible_child_ids": {
"name": "Crianças elegíveis",
"description": "IDs das crianças que podem ficar com ela. Vazio = todas as crianças."
},
"claim_hours": {
"name": "Reserva (horas)",
"description": "Quanto tempo dura uma reserva antes de a tarefa extra voltar ao quadro"
},
"require_photo": {
"name": "Exigir fotografia",
"description": "A criança tem de anexar uma fotografia ao marcá-la como feita"
},
"notify_children": {
"name": "Avisar as crianças",
"description": "Enviar a notificação \"Nova tarefa extra\" às crianças elegíveis"
}
}
},
"update_bounty": {
"name": "Atualizar tarefa extra",
"description": "Editar uma tarefa extra. Depois de reservada, só os pontos e a expiração podem mudar.",
"fields": {
"bounty_id": {
"name": "ID da tarefa extra",
"description": "O ID da tarefa extra a editar"
},
"title": {
"name": "Título",
"description": "Qual é a tarefa, p. ex. \"Lavar o carro\""
},
"points": {
"name": "Pontos",
"description": "Pontos fixos pagos quando um pai ou mãe aprova a tarefa feita"
},
"description": {
"name": "Descrição",
"description": "Detalhes opcionais para as crianças"
},
"icon": {
"name": "Ícone",
"description": "Ícone MDI mostrado no quadro"
},
"expires_at": {
"name": "Expira em",
"description": "Data e hora opcionais em que a tarefa extra sai do quadro se ninguém tiver ficado com ela. Deixe vazio para não expirar."
},
"eligible_child_ids": {
"name": "Crianças elegíveis",
"description": "IDs das crianças que podem ficar com ela. Vazio = todas as crianças."
},
"claim_hours": {
"name": "Reserva (horas)",
"description": "Quanto tempo dura uma reserva antes de a tarefa extra voltar ao quadro"
},
"require_photo": {
"name": "Exigir fotografia",
"description": "A criança tem de anexar uma fotografia ao marcá-la como feita"
},
"notify_children": {
"name": "Avisar as crianças",
"description": "Enviar a notificação \"Nova tarefa extra\" às crianças elegíveis"
}
}
},
"remove_bounty": {
"name": "Remover tarefa extra",
"description": "Tirar uma tarefa extra do quadro. Não é possível enquanto uma tarefa feita aguarda aprovação.",
"fields": {
"bounty_id": {
"name": "ID da tarefa extra",
"description": "O ID da tarefa extra a remover"
}
}
},
"claim_bounty": {
"name": "Reservar tarefa extra",
"description": "Reservar uma tarefa extra para uma criança, que fica com ela durante o período de reserva. Cada criança só pode ter uma de cada vez.",
"fields": {
"bounty_id": {
"name": "ID da tarefa extra",
"description": "O ID da tarefa extra a reservar"
},
"child_id": {
"name": "ID da criança",
"description": "O ID da criança que a reserva"
}
}
},
"give_back_bounty": {
"name": "Devolver tarefa extra",
"description": "Devolver uma tarefa extra reservada ao quadro para outra criança a poder levar",
"fields": {
"bounty_id": {
"name": "ID da tarefa extra",
"description": "O ID da tarefa extra reservada"
},
"child_id": {
"name": "ID da criança",
"description": "O ID da criança que a reservou"
}
}
},
"complete_bounty": {
"name": "Concluir tarefa extra",
"description": "Marcar uma tarefa extra reservada como feita. Vai para aprovação como uma tarefa normal.",
"fields": {
"bounty_id": {
"name": "ID da tarefa extra",
"description": "O ID da tarefa extra reservada"
},
"child_id": {
"name": "ID da criança",
"description": "O ID da criança que a reservou"
},
"photo_url": {
"name": "URL da fotografia",
"description": "Fotografia da tarefa feita (obrigatória quando a tarefa extra a pede)"
}
}
},
"pause_timed_task": {
"name": "Pausar tarefa temporizada",
"description": "Pausar o temporizador de uma tarefa temporizada em curso",
@@ -818,6 +1027,20 @@
}
}
},
"adjust_streak_freezes": {
"name": "Ajustar congelamentos de sequência",
"description": "Dá congelamentos de sequência a uma criança (quantidade positiva) ou retira alguns (negativa). Uma atribuição nunca ultrapassa o limite streak_freeze_max.",
"fields": {
"child_id": {
"name": "ID da criança",
"description": "O ID da criança cujos congelamentos mudam"
},
"amount": {
"name": "Quantidade",
"description": "Congelamentos a adicionar (positivo) ou remover (negativo)"
}
}
},
"postpone_mandatory_chore": {
"name": "Adiar tarefa obrigatória",
"description": "Dá a uma tarefa obrigatória falhada outro período (ou amanhã) sem penalização e remove o item de revisão.",
@@ -865,6 +1088,238 @@
"description": "Pontos a converter em dinheiro e a deduzir"
}
}
},
"add_wish": {
"name": "Adicionar desejo",
"description": "Uma criança adiciona algo para o qual quer poupar. Fica à espera da aprovação de um adulto.",
"fields": {
"child_id": {
"name": "ID da criança",
"description": "O ID da criança que faz o desejo"
},
"name": {
"name": "Nome",
"description": "O que a criança quer"
},
"target": {
"name": "Objetivo",
"description": "Número de pontos sugerido. O adulto pode alterá-lo ao aprovar."
},
"link": {
"name": "Ligação",
"description": "Endereço web opcional (http ou https) do artigo"
},
"photo_url": {
"name": "Imagem",
"description": "Imagem opcional acabada de enviar pelo endpoint de fotos do TaskMate"
}
}
},
"withdraw_wish": {
"name": "Retirar desejo",
"description": "Uma criança retira um desejo que ainda aguarda aprovação, ou limpa um que foi recusado.",
"fields": {
"wish_id": {
"name": "ID do desejo",
"description": "O ID do desejo"
},
"child_id": {
"name": "ID da criança",
"description": "O ID da criança a quem pertence o desejo"
}
}
},
"move_points_to_wish": {
"name": "Mover pontos para desejo",
"description": "Move pontos do saldo disponível de uma criança para um dos seus desejos aprovados.",
"fields": {
"wish_id": {
"name": "ID do desejo",
"description": "O ID do desejo"
},
"child_id": {
"name": "ID da criança",
"description": "O ID da criança a quem pertence o desejo"
},
"points": {
"name": "Pontos",
"description": "Quantos pontos mover (limitado ao saldo e ao que ainda falta ao desejo)"
}
}
},
"take_points_from_wish": {
"name": "Retirar pontos do desejo",
"description": "Devolve à criança parte dos seus próprios pontos poupados num desejo. Os pontos prometidos nunca saem.",
"fields": {
"wish_id": {
"name": "ID do desejo",
"description": "O ID do desejo"
},
"child_id": {
"name": "ID da criança",
"description": "O ID da criança a quem pertence o desejo"
},
"points": {
"name": "Pontos",
"description": "Quantos pontos poupados retirar (limitado ao que a criança poupou)"
}
}
},
"request_wish_redeem": {
"name": "Pedir para resgatar desejo",
"description": "Um desejo totalmente financiado passa a um pedido de recompensa à espera da aprovação do adulto.",
"fields": {
"wish_id": {
"name": "ID do desejo",
"description": "O ID do desejo"
},
"child_id": {
"name": "ID da criança",
"description": "O ID da criança a quem pertence o desejo"
}
}
},
"approve_wish": {
"name": "Aprovar desejo",
"description": "Transforma um desejo pendente num objetivo de poupança, opcionalmente com outro valor.",
"fields": {
"wish_id": {
"name": "ID do desejo",
"description": "O ID do desejo"
},
"target": {
"name": "Objetivo",
"description": "Pontos que o desejo custa. Deixe vazio para manter a sugestão da criança."
}
}
},
"decline_wish": {
"name": "Recusar desejo",
"description": "Recusa um desejo. Os pontos poupados voltam para a criança e as promessas são anuladas.",
"fields": {
"wish_id": {
"name": "ID do desejo",
"description": "O ID do desejo"
},
"reason": {
"name": "Motivo",
"description": "Motivo opcional mostrado à criança"
}
}
},
"pledge_to_wish": {
"name": "Prometer para desejo",
"description": "Regista pontos que um familiar oferece para o desejo de uma criança. Nunca mexem em nenhum saldo.",
"fields": {
"wish_id": {
"name": "ID do desejo",
"description": "O ID do desejo"
},
"name": {
"name": "De",
"description": "De quem é a promessa, p. ex. Avó"
},
"points": {
"name": "Pontos",
"description": "Quantos pontos prometer (limitado ao que ainda falta ao desejo)"
},
"message": {
"name": "Mensagem",
"description": "Mensagem opcional para a criança"
}
}
},
"remove_wish_pledge": {
"name": "Remover promessa",
"description": "Anula uma promessa. Possível até o desejo ser resgatado.",
"fields": {
"wish_id": {
"name": "ID do desejo",
"description": "O ID do desejo"
},
"pledge_id": {
"name": "ID da promessa",
"description": "O ID da promessa a remover"
}
}
},
"remove_wish": {
"name": "Remover desejo",
"description": "Apaga um desejo. Os pontos poupados voltam para a criança e as promessas são anuladas.",
"fields": {
"wish_id": {
"name": "ID do desejo",
"description": "O ID do desejo"
}
}
},
"start_inspection": {
"name": "Iniciar inspeção",
"description": "Marca uma tarefa aprovada nas últimas 24 horas para uma inspeção surpresa. Aprova-a ou reprova-a antes de o prazo terminar; uma inspeção por decidir fecha-se discretamente.",
"fields": {
"completion_id": {
"name": "ID da conclusão",
"description": "O ID da conclusão aprovada a inspecionar"
},
"bonus": {
"name": "Bónus",
"description": "Pontos pagos se passar. Por omissão, o valor das Definições."
},
"window": {
"name": "Prazo",
"description": "Quanto tempo tens para verificar: 1h, 2h, 4h ou bed (até à hora de dormir). Por omissão, o valor das Definições."
},
"tell_child": {
"name": "Avisar a criança",
"description": "Mostra «Vem aí uma inspeção» no cartão da criança já. Desliga para uma verificação secreta."
}
}
},
"pass_inspection": {
"name": "Aprovar inspeção",
"description": "A tarefa inspecionada passou. Paga o bónus como uma entrada de pontos normal e celebra no cartão da criança.",
"fields": {
"inspection_id": {
"name": "ID da inspeção",
"description": "O ID da inspeção em curso"
},
"bonus": {
"name": "Bónus",
"description": "Pontos a pagar. Por omissão, o bónus definido ao iniciar."
},
"note": {
"name": "Nota",
"description": "Nota opcional mostrada à criança"
}
}
},
"fail_inspection": {
"name": "Reprovar inspeção",
"description": "A tarefa inspecionada não passou. Não são retirados pontos. Pode ser devolvida para refazer; refazê-la não dá pontos extra.",
"fields": {
"inspection_id": {
"name": "ID da inspeção",
"description": "O ID da inspeção em curso"
},
"redo": {
"name": "Devolver para refazer",
"description": "Volta a pôr a tarefa na lista da criança como não feita hoje. Por omissão, o valor das Definições."
},
"note": {
"name": "Nota",
"description": "Nota opcional mostrada à criança (o que é preciso corrigir)"
}
}
},
"cancel_inspection": {
"name": "Cancelar inspeção",
"description": "Cancela uma inspeção em curso, como se nunca tivesse começado.",
"fields": {
"inspection_id": {
"name": "ID da inspeção",
"description": "O ID da inspeção em curso"
}
}
}
},
"entity": {
@@ -874,6 +1329,13 @@
},
"child_badges": {
"unit_of_measurement": "medalhas"
},
"child_watch": {
"state": {
"left": "Faltam #",
"all_done": "Tudo feito",
"no_chores": "Sem tarefas"
}
}
},
"number": {
@@ -892,5 +1354,34 @@
"name": "Design do cartão"
}
}
},
"exceptions": {
"calendar_unknown_event": {
"message": "Este evento não é uma tarefa TaskMate no calendário desta criança."
},
"calendar_series_not_supported": {
"message": "Uma tarefa repetida só pode ser movida ou removida no calendário um dia de cada vez. Para alterar toda a série, edite a tarefa no TaskMate."
},
"calendar_repeating_create": {
"message": "Não é possível adicionar eventos repetidos a partir do calendário. Crie antes uma tarefa repetida no TaskMate."
},
"calendar_rename_series": {
"message": "Mude o nome de uma tarefa repetida no TaskMate. No calendário só pode mover ou remover um único dia."
},
"calendar_time_follows_chore": {
"message": "Uma tarefa repetida mantém a sua hora do dia. No calendário só é possível alterar a data de uma única ocorrência."
},
"calendar_past_date": {
"message": "Não é possível adicionar tarefas a um dia passado, nem movê-las para ou de um dia passado."
},
"calendar_already_scheduled": {
"message": "{chore} já está marcada para {date}."
},
"calendar_empty_title": {
"message": "Dê um título à tarefa."
},
"calendar_own_event": {
"message": "Este evento foi publicado pelo próprio TaskMate, por isso não é adicionado como nova tarefa."
}
}
}
+367 -9
View File
@@ -43,6 +43,9 @@ Settings:
All commands require admin. Mutations write through coordinator methods so
TaskMate's existing business logic (refunds, cleanup, recompute) runs.
The kiosk card's commands (taskmate/kiosk/*) live in kiosk.py and are
registered alongside these; status and verify_pin serve any signed-in user.
"""
from __future__ import annotations
@@ -59,19 +62,32 @@ from homeassistant.core import HomeAssistant
from . import images, photos
from .const import (
AGE_GROUPS,
ASSIGNMENT_MODES,
BOUNTY_CLAIM_HOURS_DEFAULT,
BOUNTY_CLAIM_HOURS_MAX,
BOUNTY_DESCRIPTION_MAX_LENGTH,
BOUNTY_POINTS_MAX,
BOUNTY_TITLE_MAX_LENGTH,
DEFAULT_NOTIFICATION_GROUP,
DEFAULT_NOTIFICATION_NAV_URL,
DEFAULT_TIME_PERIODS,
DIFFICULTY_TIERS,
DOMAIN,
MAX_CHORE_TAGS,
MAX_TIME_PERIODS,
SCHEDULE_MODES,
TAG_ID_MAX_LENGTH,
TEAM_POINTS_MODES,
TEAM_SIZE_MAX,
TIME_CATEGORY_ICONS,
is_valid_completion_sound,
)
from .coord_birthdays import normalize_birthday
from .coord_inspections import INSPECTION_BONUS_MAX, INSPECTION_FAIL_MODES, INSPECTION_WINDOWS
from .coord_teamwork import teamwork_config_error
from .coordinator import TaskMateCoordinator
from .models import BonusSubTask, Reward
from .models import REJECT_REASON_MAX, BonusSubTask, Reward, normalize_tag_ids
from .sounds import MAX_NAME_LEN as MAX_SOUND_NAME_LEN
_LOGGER = logging.getLogger(__name__)
@@ -94,10 +110,15 @@ WS_REPORT_FAIRNESS: Final = "taskmate/reports/fairness"
WS_REPORT_FRICTION: Final = "taskmate/reports/friction"
WS_REPORT_PROJECTION: Final = "taskmate/reports/projection"
WS_REPORT_HEALTH: Final = "taskmate/reports/health"
WS_REPORT_QUALITY: Final = "taskmate/reports/quality"
WS_SCHEDULED_LIST: Final = "taskmate/scheduled/list"
WS_SCHEDULED_ADD: Final = "taskmate/scheduled/add"
WS_SCHEDULED_REMOVE: Final = "taskmate/scheduled/remove"
# Setup wizard (#980); the handlers live in websocket_setup.py.
WS_SETUP_CATALOGUE: Final = "taskmate/setup_wizard/catalogue"
WS_SETUP_APPLY: Final = "taskmate/setup_wizard/apply"
WS_ADD_REWARD: Final = "taskmate/add_reward"
WS_UPDATE_REWARD: Final = "taskmate/update_reward"
WS_REMOVE_REWARD: Final = "taskmate/remove_reward"
@@ -123,6 +144,11 @@ WS_CREATE_CHALLENGE: Final = "taskmate/create_challenge"
WS_UPDATE_CHALLENGE: Final = "taskmate/update_challenge"
WS_DELETE_CHALLENGE: Final = "taskmate/delete_challenge"
WS_BOUNTY_POST: Final = "taskmate/bounty_post"
WS_BOUNTY_UPDATE: Final = "taskmate/bounty_update"
WS_BOUNTY_REMOVE: Final = "taskmate/bounty_remove"
WS_BOUNTY_RELEASE: Final = "taskmate/bounty_release"
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"
@@ -170,6 +196,7 @@ WS_NOTIF_DELETE_CUSTOM: Final = "taskmate/notifications/delete_custom"
WS_NOTIF_LIST_NOTIFY: Final = "taskmate/notifications/list_notify_services"
WS_NOTIF_SET_STREAK_CUTOFF: Final = "taskmate/notifications/set_streak_cutoff"
WS_NOTIF_SET_ESCALATION: Final = "taskmate/notifications/set_escalation"
WS_NOTIF_SET_PRESENCE_ARRIVAL: Final = "taskmate/notifications/set_presence_arrival"
WS_NOTIF_SEND_TEST: Final = "taskmate/notifications/send_test"
WS_NOTIF_SET_NAV_URL: Final = "taskmate/notifications/set_nav_url"
WS_NOTIF_SET_GROUP: Final = "taskmate/notifications/set_group"
@@ -207,6 +234,8 @@ WS_CONFIG_IMPORT: Final = "taskmate/config/import"
# Everything else routed through @_admin_only mutates state and is logged.
_AUDIT_EXCLUDE: Final = {
WS_GET_STATE,
# Chore auctions (#982): the start sheet's read-only occurrence lookup.
"taskmate/auctions/occurrences",
WS_NOTIF_GET_STATE,
WS_NOTIF_LIST_NOTIFY,
WS_TEMPLATES_LIST,
@@ -221,12 +250,14 @@ _AUDIT_EXCLUDE: Final = {
WS_REPORT_FRICTION,
WS_REPORT_PROJECTION,
WS_REPORT_HEALTH,
WS_REPORT_QUALITY,
WS_SETUP_CATALOGUE,
}
def _audit_target(coordinator, msg: dict) -> str:
"""Best-effort human-readable target for an admin action from its payload."""
name = msg.get("name")
name = msg.get("name") or msg.get("title")
if isinstance(name, str) and name.strip():
return name.strip()
for key, getter in (
@@ -251,6 +282,9 @@ def _audit_target(coordinator, msg: dict) -> str:
"awarded_badge_id",
"type_id",
"transaction_id",
"bounty_id",
"auction_id",
"inspection_id",
):
if msg.get(key):
return str(msg[key])
@@ -315,6 +349,14 @@ def _opt_str(v: Any) -> str:
return str(v).strip()
def _validate_presence_entity(value):
"""voluptuous validator: '' (off) or a person.* / device_tracker.* entity id (#926)."""
value = _opt_str(value)
if value and not value.startswith(("person.", "device_tracker.")):
raise vol.Invalid("presence_entity must be a person.* or device_tracker.* entity")
return value
# ---------------------------------------------------------------------------
# State snapshot
# ---------------------------------------------------------------------------
@@ -362,7 +404,14 @@ def _build_state_snapshot(coordinator: TaskMateCoordinator) -> dict[str, Any]:
"quest_progress": dict(data.get("quest_progress", {}) or {}),
"avatar_catalog": coordinator.avatar_catalog(),
"challenges": list(data.get("challenges", [])),
# Bounty board (#931): every bounty, history included — the panel
# splits them into Active / Waiting approval / History.
"bounties": list(data.get("bounties", [])),
# Chore auctions (#982): every auction with its bids — parents see the
# amounts, and a won occurrence carries how the chore itself is going.
"auctions": coordinator.auctions_state(),
"pool_allocations": list(data.get("pool_allocations", [])),
"wishes": coordinator.wishlist_state(), # wishlist (#932), images signed
"timed_sessions": list(data.get("timed_sessions", [])),
"templates": coordinator.get_all_templates(),
# Operational state — used by the panel's Activity tab + approval banner
@@ -382,16 +431,40 @@ def _build_state_snapshot(coordinator: TaskMateCoordinator) -> dict[str, Any]:
"points_name": data.get("points_name", "Stars"),
"points_icon": data.get("points_icon", "mdi:star"),
"card_design": "classic",
# Child undo window (#918): off unless a parent opts in.
"chore_undo_seconds": 0,
# Difficulty multiplier defaults; overridden by stored values below.
"difficulty_multiplier_easy": 0.5,
"difficulty_multiplier_medium": 1.0,
"difficulty_multiplier_hard": 2.0,
# Quality-rating multipliers (#927); overridden by stored values.
"quality_rating_multiplier_1": 0.75,
"quality_rating_multiplier_2": 1.0,
"quality_rating_multiplier_3": 1.25,
**(data.get("settings", {}) or {}),
},
"parent_completable": parent_completable,
# Kiosk (#930): which children have a PIN — never the hash itself.
"kiosk_pin_children": coordinator.storage.get_kiosk_pin_child_ids(),
# Today page (#966): each child's chores today + the last 7 days.
"today": _today_state(coordinator),
# Surprise inspections (#981): the kept log, and which approved
# completions can still be flagged (the magnifier).
"inspections": coordinator.inspections_state(),
"inspectable_completions": coordinator.inspectable_completion_ids(),
"inspection_bed_rule": coordinator.inspection_bed_rule(),
}
def _today_state(coordinator) -> dict | None:
"""Today page data; a failure here must not break the rest of the panel."""
try:
return coordinator.daily_progress_state()
except Exception: # noqa: BLE001
_LOGGER.exception("TaskMate: could not build the Today board")
return None
@websocket_api.websocket_command({vol.Required("type"): WS_GET_STATE})
@websocket_api.async_response
@_admin_only
@@ -414,11 +487,19 @@ async def _ws_get_state(hass, connection, msg, coordinator):
vol.Optional("unavailability_entity", default=""): str,
vol.Optional("pause_streak_when_unavailable", default=False): bool,
vol.Optional("linked_user_id", default=""): str,
vol.Optional("birthday", default=""): vol.All(str, vol.Length(max=10)),
vol.Optional("presence_entity", default=""): _validate_presence_entity,
vol.Optional("age_group", default=""): vol.In(("", *AGE_GROUPS)),
}
)
@websocket_api.async_response
@_admin_only
async def _ws_add_child(hass, connection, msg, coordinator):
try:
birthday = normalize_birthday(msg.get("birthday", ""))
except ValueError as err:
connection.send_error(msg["id"], "invalid_format", str(err))
return
child = await coordinator.async_add_child(
name=msg["name"].strip(),
avatar=msg.get("avatar") or "mdi:account-circle",
@@ -427,6 +508,9 @@ async def _ws_add_child(hass, connection, msg, coordinator):
unavailability_entity=_opt_str(msg.get("unavailability_entity")),
pause_streak_when_unavailable=bool(msg.get("pause_streak_when_unavailable", False)),
linked_user_id=_opt_str(msg.get("linked_user_id")),
birthday=birthday,
presence_entity=msg.get("presence_entity", ""),
age_group=msg.get("age_group", ""),
)
connection.send_result(msg["id"], {"id": child.id})
@@ -442,8 +526,11 @@ async def _ws_add_child(hass, connection, msg, coordinator):
vol.Optional("unavailability_entity"): str,
vol.Optional("pause_streak_when_unavailable"): bool,
vol.Optional("linked_user_id"): str,
vol.Optional("presence_entity"): _validate_presence_entity,
vol.Optional("is_guest"): bool,
vol.Optional("guest_expires_on"): str,
vol.Optional("birthday"): vol.All(str, vol.Length(max=10)),
vol.Optional("age_group"): vol.In(("", *AGE_GROUPS)),
}
)
@websocket_api.async_response
@@ -467,6 +554,16 @@ async def _ws_update_child(hass, connection, msg, coordinator):
existing.pause_streak_when_unavailable = bool(msg["pause_streak_when_unavailable"])
if "linked_user_id" in msg:
existing.linked_user_id = _opt_str(msg["linked_user_id"])
if "birthday" in msg:
try:
existing.birthday = normalize_birthday(msg["birthday"])
except ValueError as err:
connection.send_error(msg["id"], "invalid_format", str(err))
return
if "presence_entity" in msg:
existing.presence_entity = msg["presence_entity"]
if "age_group" in msg:
existing.age_group = msg["age_group"]
if "is_guest" in msg or "guest_expires_on" in msg:
# Routed through the coordinator so the expiry is validated and an
# archived guest is un-archived when promoted to a family member.
@@ -603,6 +700,9 @@ _CHORE_EDITABLE_FIELDS = {
"late_penalty",
"require_photo",
"open_ended",
"team_size",
"team_points_mode",
"team_bonus",
"mandatory",
"mandatory_penalty_points",
"assignment_mode",
@@ -614,9 +714,25 @@ _CHORE_EDITABLE_FIELDS = {
"timed_rate_points",
"timed_rate_minutes",
"timed_max_daily_minutes",
"tag_ids",
}
def _tag_id_list(value):
"""Validate the NFC / QR tag ids linked to a chore (#923).
Raises rather than returning a falsy value — voluptuous treats a callable
as a coercer (see ``_image_url_or_blank``).
"""
if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
raise vol.Invalid("tag_ids must be a list of strings")
if any(len(v.strip()) > TAG_ID_MAX_LENGTH for v in value):
raise vol.Invalid(f"Tag ids are limited to {TAG_ID_MAX_LENGTH} characters")
if len({v.strip() for v in value if v.strip()}) > MAX_CHORE_TAGS:
raise vol.Invalid(f"A chore can be linked to at most {MAX_CHORE_TAGS} tags")
return normalize_tag_ids(value)
def _chore_payload_schema(*, require_name: bool):
"""Build a vol.Schema for add/update chore. require_name=True for add."""
name_field = vol.Required("name") if require_name else vol.Optional("name")
@@ -663,6 +779,12 @@ def _chore_payload_schema(*, require_name: bool):
vol.Optional("mandatory_penalty_points"): vol.All(int, vol.Range(min=0)),
vol.Optional("require_photo"): bool,
vol.Optional("open_ended"): bool,
# Teamwork chores (#928): 0 = an ordinary chore, otherwise 2..max.
# The cross-field rules (not first-come, not open-ended) are checked
# by teamwork_config_error once the whole chore is known.
vol.Optional("team_size"): vol.All(int, vol.Range(min=0, max=TEAM_SIZE_MAX)),
vol.Optional("team_points_mode"): vol.In(TEAM_POINTS_MODES),
vol.Optional("team_bonus"): vol.All(int, vol.Range(min=0)),
vol.Optional("assignment_mode"): vol.In(ASSIGNMENT_MODES),
vol.Optional("assignment_rotation_anchor"): str,
vol.Optional("require_availability"): bool,
@@ -679,6 +801,7 @@ def _chore_payload_schema(*, require_name: bool):
vol.Optional("timed_rate_points"): vol.All(int, vol.Range(min=1)),
vol.Optional("timed_rate_minutes"): vol.All(int, vol.Range(min=1)),
vol.Optional("timed_max_daily_minutes"): vol.All(int, vol.Range(min=0)),
vol.Optional("tag_ids"): _tag_id_list,
}
@@ -707,6 +830,18 @@ async def _maybe_apply_manual_start(coordinator, chore_id: str, child_id: str |
@websocket_api.async_response
@_admin_only
async def _ws_add_chore(hass, connection, msg, coordinator):
# The chore is created and then updated with the remaining fields, so a bad
# teamwork combination (#928) has to be refused before the create — the
# update would reject it only after an ordinary chore already existed.
team_error = teamwork_config_error(
msg.get("team_size", 0),
assignment_mode=msg.get("assignment_mode", "everyone"),
open_ended=bool(msg.get("open_ended", False)),
task_type=msg.get("task_type", "standard"),
team_points_mode=msg.get("team_points_mode", "each"),
)
if team_error:
raise ValueError(team_error)
chore = await coordinator.async_add_chore(
name=msg["name"].strip(),
points=msg.get("points", 10),
@@ -894,6 +1029,18 @@ async def _ws_print_chart(hass, connection, msg, coordinator):
connection.send_result(msg["id"], {"html": html})
@websocket_api.websocket_command(
{
vol.Required("type"): WS_REPORT_QUALITY,
vol.Optional("days"): vol.All(int, vol.Range(min=1, max=90)),
}
)
@websocket_api.async_response
@_admin_only
async def _ws_report_quality(hass, connection, msg, coordinator):
connection.send_result(msg["id"], coordinator.quality_report(msg.get("days")))
@websocket_api.websocket_command({vol.Required("type"): WS_REPORT_HEALTH})
@websocket_api.async_response
@_admin_only
@@ -1000,6 +1147,7 @@ _REWARD_FIELDS = {
"available_days",
"available_from",
"available_until",
"streak_freeze",
}
@@ -1025,6 +1173,7 @@ def _reward_payload_schema(*, require_name: 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,
vol.Optional("streak_freeze"): bool,
}
@@ -1069,6 +1218,7 @@ async def _ws_add_reward(hass, connection, msg, coordinator):
available_days=sorted(set(msg.get("available_days", []) or [])),
available_from=msg.get("available_from", "") or "",
available_until=msg.get("available_until", "") or "",
streak_freeze=msg.get("streak_freeze", False),
)
coordinator.storage.add_reward(reward)
await coordinator.storage.async_save()
@@ -1255,6 +1405,83 @@ async def _ws_set_child_avatar(hass, connection, msg, coordinator):
connection.send_result(msg["id"], {"id": msg["child_id"]})
# ---------------------------------------------------------------------------
# Bounty board (#931)
# ---------------------------------------------------------------------------
_BOUNTY_EDITABLE = {
vol.Optional("description"): vol.All(str, vol.Length(max=BOUNTY_DESCRIPTION_MAX_LENGTH)),
vol.Optional("icon"): str,
# ISO date-time, or null / "" for no expiry.
vol.Optional("expires_at"): vol.Any(None, str),
vol.Optional("eligible_child_ids"): [str],
vol.Optional("claim_hours"): vol.All(int, vol.Range(min=1, max=BOUNTY_CLAIM_HOURS_MAX)),
vol.Optional("require_photo"): bool,
vol.Optional("notify_children"): bool,
}
_BOUNTY_FIELDS = (
"title",
"points",
"description",
"icon",
"expires_at",
"eligible_child_ids",
"claim_hours",
"require_photo",
"notify_children",
)
@websocket_api.websocket_command(
{
vol.Required("type"): WS_BOUNTY_POST,
vol.Required("title"): vol.All(str, vol.Length(min=1, max=BOUNTY_TITLE_MAX_LENGTH)),
vol.Required("points"): vol.All(int, vol.Range(min=1, max=BOUNTY_POINTS_MAX)),
**_BOUNTY_EDITABLE,
}
)
@websocket_api.async_response
@_admin_only
async def _ws_bounty_post(hass, connection, msg, coordinator):
extra = {k: msg[k] for k in _BOUNTY_FIELDS if k in msg and k not in ("title", "points")}
extra.setdefault("claim_hours", BOUNTY_CLAIM_HOURS_DEFAULT)
bounty = await coordinator.async_post_bounty(msg["title"], msg["points"], **extra)
connection.send_result(msg["id"], {"id": bounty.id})
@websocket_api.websocket_command(
{
vol.Required("type"): WS_BOUNTY_UPDATE,
vol.Required("bounty_id"): str,
vol.Optional("title"): vol.All(str, vol.Length(min=1, max=BOUNTY_TITLE_MAX_LENGTH)),
vol.Optional("points"): vol.All(int, vol.Range(min=1, max=BOUNTY_POINTS_MAX)),
**_BOUNTY_EDITABLE,
}
)
@websocket_api.async_response
@_admin_only
async def _ws_bounty_update(hass, connection, msg, coordinator):
changes = {k: msg[k] for k in _BOUNTY_FIELDS if k in msg}
bounty = await coordinator.async_update_bounty(msg["bounty_id"], **changes)
connection.send_result(msg["id"], {"id": bounty.id})
@websocket_api.websocket_command({vol.Required("type"): WS_BOUNTY_REMOVE, vol.Required("bounty_id"): str})
@websocket_api.async_response
@_admin_only
async def _ws_bounty_remove(hass, connection, msg, coordinator):
await coordinator.async_remove_bounty(msg["bounty_id"])
connection.send_result(msg["id"], {"id": msg["bounty_id"]})
@websocket_api.websocket_command({vol.Required("type"): WS_BOUNTY_RELEASE, vol.Required("bounty_id"): str})
@websocket_api.async_response
@_admin_only
async def _ws_bounty_release(hass, connection, msg, coordinator):
await coordinator.async_release_bounty(msg["bounty_id"])
connection.send_result(msg["id"], {"id": msg["bounty_id"]})
# ---------------------------------------------------------------------------
# Challenges (daily / weekly)
# ---------------------------------------------------------------------------
@@ -1648,10 +1875,21 @@ _ALLOWED_CARD_DESIGNS = {"classic", "playroom", "console", "cleanpro", "accessib
# Settings stored under storage._data["settings"][key]
_SUBKEY_SETTINGS = {
"require_linked_child",
"chore_undo_seconds",
"history_days",
# Recaps (#929)
"recap_frequencies",
"recap_child_frequencies",
"recap_notify_children",
"recap_notify_parents",
"recap_send_time",
"recap_retention",
"recap_compare",
"streak_reset_mode",
"card_design",
"weekend_multiplier",
"birthday_points_multiplier",
"birthday_chores_off",
"streak_milestones_enabled",
"perfect_week_enabled",
"perfect_week_bonus",
@@ -1659,9 +1897,15 @@ _SUBKEY_SETTINGS = {
"quick_point_amounts",
"streak_requires_all_chores",
"perfect_week_requires_all_chores",
"streak_freeze_max",
"streak_freeze_earn_every",
"difficulty_multiplier_easy",
"difficulty_multiplier_medium",
"difficulty_multiplier_hard",
"quality_rating_enabled",
"quality_rating_multiplier_1",
"quality_rating_multiplier_2",
"quality_rating_multiplier_3",
"unlock_allowlist",
"parent_routing",
"read_aloud_media_player",
@@ -1677,6 +1921,17 @@ _SUBKEY_SETTINGS = {
"surprise_bonus_chance",
"surprise_bonus_min",
"surprise_bonus_max",
# Surprise inspections (#981)
"inspections_enabled",
"inspection_bonus",
"inspection_window",
"inspection_bedtime",
"inspection_fail_mode",
"inspection_tell_child",
"inspection_pick_enabled",
"inspection_pick_time",
"inspection_pick_chance",
"inspection_pick_children",
"points_decay_enabled",
"points_decay_period",
"points_decay_percent",
@@ -1835,6 +2090,42 @@ def _validate_vacation_periods(raw: list) -> tuple[list[dict] | None, str | None
return sorted(periods, key=lambda p: p["start"]), None
def _validate_recap_child_frequencies(value: Any) -> dict[str, list[str]]:
"""Per-child recap overrides (#929): {child_id: [frequency, ...]}.
A child present here is on "Custom" (an empty list = recaps off); a child
absent inherits the family default.
"""
from .coord_recaps import RECAP_FREQUENCIES
if not isinstance(value, dict) or len(value) > 100:
raise vol.Invalid("recap_child_frequencies must be an object of child id -> frequencies")
out: dict[str, list[str]] = {}
for child_id, freqs in value.items():
if not isinstance(child_id, str) or not isinstance(freqs, list):
raise vol.Invalid("recap_child_frequencies must map child ids to lists")
bad = [f for f in freqs if f not in RECAP_FREQUENCIES]
if bad:
raise vol.Invalid(f"unknown recap frequency: {bad[0]}")
out[child_id] = [f for f in RECAP_FREQUENCIES if f in freqs]
return out
def _validate_recap_frequencies(value: Any) -> list[str]:
from .coord_recaps import RECAP_FREQUENCIES
if not isinstance(value, list) or any(f not in RECAP_FREQUENCIES for f in value):
raise vol.Invalid("recap_frequencies must be a list of recap frequencies")
return [f for f in RECAP_FREQUENCIES if f in value]
def _validate_chore_undo_seconds(value: Any) -> int:
"""A whole number of seconds from 0 to 3600 (#918). Booleans are refused."""
if isinstance(value, bool) or not isinstance(value, int) or not 0 <= value <= 3600:
raise vol.Invalid("chore_undo_seconds must be a whole number from 0 to 3600")
return value
# Extracted to a module constant so the accepted settings keys can be unit-tested
# (the websocket_command decorator does not expose the compiled schema). Every key
# accepted here must also be routed in _ws_update_settings below.
@@ -1846,16 +2137,32 @@ _UPDATE_SETTINGS_SCHEMA = {
vol.Optional("history_days"): vol.All(int, vol.Range(min=30, max=365)),
vol.Optional("streak_reset_mode"): vol.In(["reset", "pause"]),
vol.Optional("weekend_multiplier"): vol.All(vol.Coerce(float), vol.Range(min=1.0, max=5.0)),
vol.Optional("birthday_points_multiplier"): vol.All(vol.Coerce(float), vol.Range(min=1.0, max=5.0)),
vol.Optional("birthday_chores_off"): bool,
vol.Optional("streak_milestones_enabled"): bool,
vol.Optional("perfect_week_enabled"): bool,
vol.Optional("perfect_week_bonus"): vol.All(int, vol.Range(min=0)),
vol.Optional("streak_requires_all_chores"): bool,
vol.Optional("perfect_week_requires_all_chores"): bool,
vol.Optional("streak_freeze_max"): vol.All(vol.Coerce(int), vol.Range(min=0, max=10)),
vol.Optional("streak_freeze_earn_every"): vol.All(vol.Coerce(int), vol.Range(min=0, max=365)),
vol.Optional("difficulty_multiplier_easy"): vol.All(vol.Coerce(float), vol.Range(min=0.0, max=10.0)),
vol.Optional("difficulty_multiplier_medium"): vol.All(vol.Coerce(float), vol.Range(min=0.0, max=10.0)),
vol.Optional("difficulty_multiplier_hard"): vol.All(vol.Coerce(float), vol.Range(min=0.0, max=10.0)),
vol.Optional("quality_rating_enabled"): bool,
vol.Optional("quality_rating_multiplier_1"): vol.All(vol.Coerce(float), vol.Range(min=0.0, max=5.0)),
vol.Optional("quality_rating_multiplier_2"): vol.All(vol.Coerce(float), vol.Range(min=0.0, max=5.0)),
vol.Optional("quality_rating_multiplier_3"): vol.All(vol.Coerce(float), vol.Range(min=0.0, max=5.0)),
vol.Optional("unlock_allowlist"): [str],
vol.Optional("require_linked_child"): bool,
vol.Optional("chore_undo_seconds"): _validate_chore_undo_seconds,
vol.Optional("recap_frequencies"): _validate_recap_frequencies,
vol.Optional("recap_child_frequencies"): _validate_recap_child_frequencies,
vol.Optional("recap_notify_children"): bool,
vol.Optional("recap_notify_parents"): bool,
vol.Optional("recap_send_time"): vol.Match(r"^([01]\d|2[0-3]):[0-5]\d$"),
vol.Optional("recap_retention"): vol.In(["1y", "2y", "forever"]),
vol.Optional("recap_compare"): 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,
@@ -1870,6 +2177,16 @@ _UPDATE_SETTINGS_SCHEMA = {
vol.Optional("surprise_bonus_chance"): vol.All(vol.Coerce(float), vol.Range(min=0.0, max=100.0)),
vol.Optional("surprise_bonus_min"): vol.All(int, vol.Range(min=0, max=10000)),
vol.Optional("surprise_bonus_max"): vol.All(int, vol.Range(min=0, max=10000)),
vol.Optional("inspections_enabled"): bool,
vol.Optional("inspection_bonus"): vol.All(vol.Coerce(int), vol.Range(min=0, max=INSPECTION_BONUS_MAX)),
vol.Optional("inspection_window"): vol.In(INSPECTION_WINDOWS),
vol.Optional("inspection_bedtime"): vol.Match(r"^([01]\d|2[0-3]):[0-5]\d$"),
vol.Optional("inspection_fail_mode"): vol.In(INSPECTION_FAIL_MODES),
vol.Optional("inspection_tell_child"): bool,
vol.Optional("inspection_pick_enabled"): bool,
vol.Optional("inspection_pick_time"): vol.Match(r"^([01]\d|2[0-3]):[0-5]\d$"),
vol.Optional("inspection_pick_chance"): vol.All(vol.Coerce(int), vol.Range(min=0, max=100)),
vol.Optional("inspection_pick_children"): vol.All([str], vol.Length(max=100)),
vol.Optional("points_decay_enabled"): bool,
vol.Optional("points_decay_period"): vol.In(["weekly", "monthly"]),
vol.Optional("points_decay_percent"): vol.All(vol.Coerce(float), vol.Range(min=0.0, max=100.0)),
@@ -1960,6 +2277,12 @@ async def _ws_update_settings(hass, connection, msg, coordinator):
# Period boundaries moved → re-arm the mandatory-chore end-of-period checks (#532)
if "time_periods" in changed:
await coordinator.async_rearm_mandatory_schedules()
# Recaps (#929): note when a frequency was switched on, re-arm the send time.
if any(k.startswith("recap_") for k in changed):
await coordinator.async_recap_settings_changed()
# Surprise inspections (#981): move the daily pick to its new time.
if "inspection_pick_time" in changed:
await coordinator.async_inspection_settings_changed()
await coordinator.async_refresh()
connection.send_result(msg["id"], {"updated": changed})
@@ -1980,8 +2303,9 @@ async def _ws_update_settings(hass, connection, msg, coordinator):
@websocket_api.async_response
@_admin_only
async def _ws_complete_bonus_subtask(hass, connection, msg, coordinator):
# Ticked from the admin panel, so not the child's to undo (#918).
completion = await coordinator.async_complete_bonus_subtask(
msg["chore_id"], msg["bonus_subtask_id"], msg["child_id"]
msg["chore_id"], msg["bonus_subtask_id"], msg["child_id"], by_child=False
)
connection.send_result(msg["id"], {"id": completion.id})
@@ -1992,12 +2316,14 @@ async def _ws_complete_bonus_subtask(hass, connection, msg, coordinator):
vol.Required("completion_id"): str,
# Optional per-approval award (#832) — replaces the chore's own points.
vol.Optional("points"): vol.All(vol.Coerce(int), vol.Range(min=0)),
# Optional 1-3 star quality rating (#927).
vol.Optional("rating"): vol.All(vol.Coerce(int), vol.Range(min=1, max=3)),
}
)
@websocket_api.async_response
@_admin_only
async def _ws_approve_chore(hass, connection, msg, coordinator):
await coordinator.async_approve_chore(msg["completion_id"], points=msg.get("points"))
await coordinator.async_approve_chore(msg["completion_id"], points=msg.get("points"), rating=msg.get("rating"))
connection.send_result(msg["id"], {"completion_id": msg["completion_id"]})
@@ -2005,12 +2331,13 @@ async def _ws_approve_chore(hass, connection, msg, coordinator):
{
vol.Required("type"): WS_APPROVE_ALL_CHORES,
vol.Optional("completion_ids"): [str],
vol.Optional("rating"): vol.All(vol.Coerce(int), vol.Range(min=1, max=3)),
}
)
@websocket_api.async_response
@_admin_only
async def _ws_approve_all_chores(hass, connection, msg, coordinator):
count = await coordinator.async_approve_chores_bulk(msg.get("completion_ids"))
count = await coordinator.async_approve_chores_bulk(msg.get("completion_ids"), rating=msg.get("rating"))
connection.send_result(msg["id"], {"count": count})
@@ -2018,12 +2345,13 @@ async def _ws_approve_all_chores(hass, connection, msg, coordinator):
{
vol.Required("type"): WS_REJECT_CHORE,
vol.Required("completion_id"): str,
vol.Optional("reason", default=""): vol.All(str, vol.Length(max=REJECT_REASON_MAX)),
}
)
@websocket_api.async_response
@_admin_only
async def _ws_reject_chore(hass, connection, msg, coordinator):
await coordinator.async_reject_chore(msg["completion_id"])
await coordinator.async_reject_chore(msg["completion_id"], reason=msg.get("reason", ""))
connection.send_result(msg["id"], {"completion_id": msg["completion_id"]})
@@ -2044,12 +2372,13 @@ async def _ws_approve_reward(hass, connection, msg, coordinator):
{
vol.Required("type"): WS_REJECT_REWARD,
vol.Required("claim_id"): str,
vol.Optional("reason", default=""): vol.All(str, vol.Length(max=REJECT_REASON_MAX)),
}
)
@websocket_api.async_response
@_admin_only
async def _ws_reject_reward(hass, connection, msg, coordinator):
await coordinator.async_reject_reward(msg["claim_id"])
await coordinator.async_reject_reward(msg["claim_id"], reason=msg.get("reason", ""))
connection.send_result(msg["id"], {"claim_id": msg["claim_id"]})
@@ -2310,6 +2639,7 @@ async def ws_notif_get_state(hass, connection, msg, coordinator):
"streak_at_risk_cutoff_time": c.storage.get_streak_at_risk_cutoff(),
"mandatory_escalation_reminder_minutes": c.storage.get_escalation_reminder_minutes(),
"mandatory_escalation_parent_minutes": c.storage.get_escalation_parent_minutes(),
"presence_arrival_min_away": c.storage.get_presence_arrival_min_away(),
"notification_nav_url": c.storage.get_setting("notification_nav_url", DEFAULT_NOTIFICATION_NAV_URL),
"notification_group": c.storage.get_setting("notification_group", DEFAULT_NOTIFICATION_GROUP),
},
@@ -2530,6 +2860,19 @@ async def ws_notif_set_escalation(hass, connection, msg, coordinator):
connection.send_result(msg["id"], {"ok": True})
@websocket_api.websocket_command(
{
vol.Required("type"): WS_NOTIF_SET_PRESENCE_ARRIVAL,
vol.Required("min_away_minutes"): vol.All(vol.Coerce(int), vol.Range(min=0, max=1440)),
}
)
@websocket_api.async_response
@_admin_only
async def ws_notif_set_presence_arrival(hass, connection, msg, coordinator):
await coordinator.notifications.set_presence_arrival_min_away(msg["min_away_minutes"])
connection.send_result(msg["id"], {"ok": True})
@websocket_api.websocket_command(
{
vol.Required("type"): WS_NOTIF_SEND_TEST,
@@ -2777,6 +3120,7 @@ _COMMANDS = (
_ws_report_friction,
_ws_report_projection,
_ws_report_health,
_ws_report_quality,
_ws_templates_export,
_ws_templates_import,
_ws_print_chart,
@@ -2796,6 +3140,10 @@ _COMMANDS = (
_ws_update_avatar_catalog,
_ws_set_child_avatar,
_ws_create_challenge,
_ws_bounty_post,
_ws_bounty_update,
_ws_bounty_remove,
_ws_bounty_release,
_ws_update_challenge,
_ws_delete_challenge,
_ws_add_penalty,
@@ -2843,6 +3191,7 @@ _COMMANDS = (
ws_notif_set_streak_cutoff,
ws_notif_send_test,
ws_notif_set_escalation,
ws_notif_set_presence_arrival,
ws_notif_set_nav_url,
ws_notif_set_group,
ws_cal_get_url,
@@ -2855,7 +3204,16 @@ def async_register_websocket_commands(hass: HomeAssistant) -> None:
if hass.data.get(DOMAIN, {}).get(WS_REGISTERED):
_LOGGER.debug("TaskMate WS commands already registered, skipping")
return
for cmd in _COMMANDS:
# Imported here: kiosk.py, websocket_recaps.py and websocket_auctions.py
# build on this module's _admin_only / _get_coordinator helpers.
from .kiosk import KIOSK_COMMANDS
from .websocket_auctions import AUCTION_COMMANDS
from .websocket_inspections import INSPECTION_COMMANDS
from .websocket_recaps import RECAP_COMMANDS
from .websocket_setup import SETUP_COMMANDS
extra = (*KIOSK_COMMANDS, *RECAP_COMMANDS, *INSPECTION_COMMANDS, *SETUP_COMMANDS, *AUCTION_COMMANDS)
for cmd in (*_COMMANDS, *extra):
websocket_api.async_register_command(hass, cmd)
hass.data.setdefault(DOMAIN, {})[WS_REGISTERED] = True
_LOGGER.info("Registered %d TaskMate WebSocket commands", len(_COMMANDS))
_LOGGER.info("Registered %d TaskMate WebSocket commands", len(_COMMANDS) + len(extra))
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -40,7 +40,8 @@ class TaskMateActivityCard extends LitElement {
return [
"Weekend bonus", "Streak milestone bonus", "Perfect week bonus",
"Allocated to pool:", "Pool refund", "Points decay",
"Savings interest", "Badge",
"Savings interest", "Badge", "Streak freeze",
"Wish savings", "Wish refund", "Wish pledge", "Wish redeemed",
];
}
@@ -49,6 +50,20 @@ class TaskMateActivityCard extends LitElement {
return !TaskMateActivityCard._UNDO_DENY_PREFIXES.some(p => r.startsWith(p));
}
// Streak-freeze token movements (#925) are logged as zero-point rows. Worded
// as points they would read "lost 0", so both render paths give them their
// own line: the child, the translated reason, a snowflake — no points.
// Wishlist pledges and redemptions (#932) are zero-point rows too — the
// pledged points never touch the child's balance — and share the same line.
_isFreezeEntry(item) {
const reason = item.reason || "";
return !item.points && (reason.startsWith("Streak freeze") || reason.startsWith("Wish "));
}
_isWishEntry(item) {
return (item.reason || "").startsWith("Wish ");
}
shouldUpdate(changedProps) {
if (changedProps.has("hass")) {
return window.__taskmate_hasChanged
@@ -77,6 +92,8 @@ class TaskMateActivityCard extends LitElement {
['Pool refund (reward assignment changed):', 'activity.reason_pool_refund_assignment_changed'],
['Penalty:', 'activity.reason_penalty'],
['Bonus:', 'activity.reason_bonus'],
// Surprise inspections (#981): the pass bonus.
['Inspection passed:', 'activity.reason_inspection_passed'],
];
for (const [prefix, key] of prefixMap) {
if (reason.startsWith(prefix)) {
@@ -100,6 +117,42 @@ class TaskMateActivityCard extends LitElement {
if (streakMatch) {
return this._t('activity.reason_streak_milestone', { days: streakMatch[1] });
}
// Streak freeze tokens (#925). Zero-point rows: the reason is the news.
const freezeUsed = reason.match(/^Streak freeze used \((\d{4}-\d{2}-\d{2})\)$/);
if (freezeUsed) {
return this._t('activity.reason_streak_freeze_used', { date: freezeUsed[1] });
}
const freezeEarned = reason.match(/^Streak freeze earned \((\d+) day streak!\)$/);
if (freezeEarned) {
return this._t('activity.reason_streak_freeze_earned', { days: freezeEarned[1] });
}
const freezeAdjusted = reason.match(/^Streak freezes adjusted \(([+-]\d+)\)$/);
if (freezeAdjusted) {
return this._t('activity.reason_streak_freeze_adjusted', { delta: freezeAdjusted[1] });
}
if (reason === 'Streak freeze reversed') {
return this._t('activity.reason_streak_freeze_reversed');
}
// Wishlist (#932). Pledges carry who and how much, so they need a match
// rather than a prefix.
const wishPrefixes = [
['Wish savings taken back:', 'activity.reason_wish_taken_back'],
['Wish savings:', 'activity.reason_wish_savings'],
['Wish refund (declined):', 'activity.reason_wish_refund_declined'],
['Wish refund (removed):', 'activity.reason_wish_refund_removed'],
['Wish redeemed:', 'activity.reason_wish_redeemed'],
];
for (const [prefix, key] of wishPrefixes) {
if (reason.startsWith(prefix)) return this._t(key, { name: reason.slice(prefix.length).trim() });
}
const pledgeAdded = reason.match(/^Wish pledge from (.+) \(\+(\d+)\): (.*)$/);
if (pledgeAdded) {
return this._t('activity.reason_wish_pledge', { who: pledgeAdded[1], points: pledgeAdded[2], name: pledgeAdded[3] });
}
const pledgeGone = reason.match(/^Wish pledge (removed|voided) \((.+), (\d+)\): (.*)$/);
if (pledgeGone) {
return this._t(`activity.reason_wish_pledge_${pledgeGone[1]}`, { who: pledgeGone[2], points: pledgeGone[3], name: pledgeGone[4] });
}
return reason;
}
@@ -158,7 +211,7 @@ class TaskMateActivityCard extends LitElement {
.header-meta {
font-size: 0.78rem;
color: rgba(255, 255, 255, 0.85);
margin-left: 2px;
margin-inline-start: 2px;
white-space: nowrap;
}
@@ -230,7 +283,7 @@ class TaskMateActivityCard extends LitElement {
letter-spacing: 0.2px;
font-weight: 500;
opacity: 0.75;
margin-left: 4px;
margin-inline-start: 4px;
}
/* ── Activity item ─────────────────────────────────── */
@@ -255,6 +308,9 @@ class TaskMateActivityCard extends LitElement {
.activity-item.t-penalty .event-stripe { background: var(--tm-stripe-penalty); }
.activity-item.t-reward .event-stripe { background: var(--tm-stripe-reward); }
.activity-item.t-bonus .event-stripe { background: var(--tm-stripe-bonus); }
.activity-item.t-inspection .event-stripe { background: #f39c12; }
.activity-icon.t-inspection { background: rgba(243,156,18,0.16); color: #c2740a; }
.activity-insp-note { font-size: 0.8rem; font-style: italic; color: var(--secondary-text-color); margin-top: 2px; overflow-wrap: anywhere; }
.activity-row {
flex: 1;
@@ -317,6 +373,13 @@ class TaskMateActivityCard extends LitElement {
color: var(--secondary-text-color);
font-style: normal;
}
.activity-reject-reason {
font-size: 0.8rem;
font-weight: 600;
color: var(--tm-stripe-rejected, #ef4444);
margin-top: 2px;
overflow-wrap: anywhere;
}
.activity-meta {
display: flex;
@@ -330,7 +393,7 @@ class TaskMateActivityCard extends LitElement {
.activity-time { color: var(--secondary-text-color); }
.activity-ago::before {
content: "·";
margin-right: 4px;
margin-inline-end: 4px;
opacity: 0.6;
}
@@ -409,7 +472,7 @@ class TaskMateActivityCard extends LitElement {
justify-content: center;
width: 28px;
height: 28px;
margin-left: 2px;
margin-inline-start: 2px;
border-radius: 50%;
border: 1px solid var(--divider-color, #e0e0e0);
background: transparent;
@@ -433,6 +496,11 @@ class TaskMateActivityCard extends LitElement {
.tmd-undo { padding: 4px 11px; }
.act-line { font-weight: 700; font-size: 13.5px; line-height: 1.35; }
.act-line .reason { font-weight: 400; color: var(--tmd-dim); }
.act-insp-note { font-size: 12px; font-style: italic; font-weight: 400; color: var(--tmd-dim); margin-top: 2px; overflow-wrap: anywhere; }
.act-reject-reason { font-size: 12px; font-weight: 600; color: var(--tmd-bad); margin-top: 2px; overflow-wrap: anywhere; }
/* Quality rating the parent gave at approval (#927), both render paths. */
.act-rating { color: var(--tmd-gold, #f5b301); font-weight: 700; letter-spacing: 1px; white-space: nowrap; }
.act-rating .off { color: var(--tmd-dim, var(--secondary-text-color, #999)); font-weight: 400; }
.act-ago { font-size: 11.5px; }
.act-rel { opacity: 0.8; }
@@ -448,14 +516,14 @@ class TaskMateActivityCard extends LitElement {
.act-cn-row { gap: 10px; padding: 9px 2px; border-bottom: 1px solid var(--tmd-border); }
.act-cn-row:last-child { border-bottom: none; }
.act-cn-mid { flex: 1; min-width: 0; font-size: 12.5px; font-weight: 600; }
.act-cn-time { font-size: 11px; flex-shrink: 0; text-align: right; line-height: 1.3; white-space: nowrap; }
.act-cn-time { font-size: 11px; flex-shrink: 0; text-align: end; line-height: 1.3; white-space: nowrap; }
/* Clean Pro — connecting rail */
.act-cp-rail { position: relative; padding-left: 26px; }
.act-cp-line { position: absolute; left: 11px; top: 6px; bottom: 6px; width: 2px; background: var(--tmd-border); }
.act-cp-rail { position: relative; padding-inline-start: 26px; }
.act-cp-line { position: absolute; inset-inline-start: 11px; top: 6px; bottom: 6px; width: 2px; background: var(--tmd-border); }
.act-cp { gap: 14px; }
.act-cp-node { position: relative; }
.act-cp-dot { position: absolute; left: -26px; top: 0; width: 22px; height: 22px; border-radius: 50%;
.act-cp-dot { position: absolute; inset-inline-start: -26px; top: 0; width: 22px; height: 22px; border-radius: 50%;
background: color-mix(in srgb, var(--ac, var(--tmd-accent)) 18%, transparent); color: var(--ac, var(--tmd-accent));
display: grid; place-items: center; font-size: 11px; line-height: 1; box-shadow: 0 0 0 3px var(--tmd-surface); }
.act-cp-head { justify-content: space-between; align-items: flex-start; gap: 10px; }
@@ -465,7 +533,7 @@ class TaskMateActivityCard extends LitElement {
/* Vertical scroll (parity with classic) — extend to the card edge so the
scrollbar sits at the edge, not floating inset by the body padding. */
.act-pl, .act-cn, .act-cp-rail { max-height: 360px; overflow-y: auto;
margin-right: -15px; padding-right: 8px; }
margin-inline-end: -15px; padding-inline-end: 8px; }
/* Undo confirm overlay (works in classic + designed; no mwc dependency) */
.undo-ov { position: fixed; inset: 0; z-index: 99; display: grid; place-items: center;
@@ -515,15 +583,17 @@ class TaskMateActivityCard extends LitElement {
_eventBucket(item) {
const t = item.type || "chore";
if (t === "points_added" || t === "points_removed") return "adjustments";
if (t === "reward" || t === "reward_claimed" || t === "reward_approved") return "rewards";
if (t === "reward" || t === "reward_claimed" || t === "reward_approved" || t === "reward_rejected") return "rewards";
return "chores";
}
_classifyItem(item) {
const t = item.type || "chore";
if (t === "chore_rejected" || t === "reward_rejected") return "rejected";
if (t.startsWith("inspection_")) return "inspection";
if (t === "points_added") {
const reason = item.reason || "";
if (reason.startsWith("Bonus:") || reason.startsWith("Perfect week bonus") ||
if (reason.startsWith("Bonus:") || reason.startsWith("Inspection passed:") || reason.startsWith("Perfect week bonus") ||
reason.startsWith("Weekend bonus") || reason.startsWith("Streak milestone bonus")) {
return "bonus";
}
@@ -691,19 +761,45 @@ class TaskMateActivityCard extends LitElement {
const ago = this._formatAgo(new Date(item.completed_at));
const klass = this._classifyItem(item);
// ── Streak-freeze tokens (#925) ───────────────────────
if ((type === "points_added" || type === "points_removed") && this._isFreezeEntry(item)) {
return html`
<div class="activity-item t-bonus">
${this.config.accent_stripes !== false ? html`<div class="event-stripe"></div>` : ''}
<div class="activity-row">
<div class="activity-icon t-bonus">
<ha-icon icon="${this._isWishEntry(item) ? "mdi:heart" : "mdi:snowflake"}"></ha-icon>
</div>
<div class="activity-body">
<div class="activity-title">
<strong>${childName}</strong>
<span class="reason">— ${this._translateReason(item.reason)}</span>
</div>
<div class="activity-meta">
<span class="activity-time">${time}</span>
${this.config.show_relative_time !== false ? html`<span class="activity-ago">${ago}</span>` : ''}
</div>
</div>
</div>
</div>
`;
}
// ── Manual points transactions ────────────────────────
if (type === "points_added" || type === "points_removed") {
const isAdd = type === "points_added";
const pts = Math.abs(item.points || 0);
const reason = item.reason || '';
const isPenalty = reason.startsWith('Penalty:');
const isPoolAllocation = reason.startsWith('Allocated to pool:');
// Saving into a wish (#932) is the same kind of move as a savings jar.
const isPoolAllocation = reason.startsWith('Allocated to pool:') || reason.startsWith('Wish savings:');
const isSpend = !isAdd && !isPenalty && isPoolAllocation;
const verb = isAdd ? this._t('activity.received')
: isSpend ? this._t('activity.spent')
: this._t('activity.lost');
const pillClass = isAdd ? 'gain' : (isSpend ? 'spend' : 'loss');
const icon = isAdd ? 'mdi:star-plus' : (isPenalty ? 'mdi:minus-circle' : 'mdi:star-minus');
const icon = reason.startsWith('Inspection passed:') ? 'mdi:magnify-scan'
: isAdd ? 'mdi:star-plus' : (isPenalty ? 'mdi:minus-circle' : 'mdi:star-minus');
const displayReason = this._translateReason(item.reason);
return html`
@@ -736,6 +832,60 @@ class TaskMateActivityCard extends LitElement {
`;
}
// ── Surprise inspections (#981) ───────────────────────
if (type.startsWith("inspection_")) {
const d = this._inspectionText(item);
return html`
<div class="activity-item t-inspection">
${this.config.accent_stripes !== false ? html`<div class="event-stripe"></div>` : ''}
<div class="activity-row">
<div class="activity-icon t-inspection">
<ha-icon icon="${type === "inspection_redo" ? "mdi:restore" : "mdi:magnify-scan"}"></ha-icon>
</div>
<div class="activity-body">
<div class="activity-title">
<strong>${childName}</strong> · ${d.label}
<span class="reason">— ${d.name}</span>
</div>
${item.note ? html`<div class="activity-insp-note">“${item.note}”</div>` : ''}
<div class="activity-meta">
<span class="activity-time">${time}</span>
${this.config.show_relative_time !== false ? html`<span class="activity-ago">${ago}</span>` : ''}
</div>
</div>
</div>
</div>
`;
}
// ── Rejected with a reason (#976) ─────────────────────
if (type === "chore_rejected" || type === "reward_rejected") {
const isReward = type === "reward_rejected";
const name = (isReward ? item.reward_name : item.chore_name)
|| this._t(isReward ? 'activity.a_reward' : 'activity.a_chore');
return html`
<div class="activity-item t-rejected">
${this.config.accent_stripes !== false ? html`<div class="event-stripe"></div>` : ''}
<div class="activity-row">
<div class="activity-icon t-rejected">
<ha-icon icon="${isReward ? 'mdi:gift-off-outline' : 'mdi:close-circle'}"></ha-icon>
</div>
<div class="activity-body">
<div class="activity-title">
<strong>${childName}</strong> · ${name}
<span class="activity-status rejected">${this._t('common.rejected')}</span>
</div>
${item.reason ? html`<div class="activity-reject-reason">${this._t('activity.rejected_reason', { reason: item.reason })}</div>` : ''}
<div class="activity-meta">
<span class="activity-time">${time}</span>
${this.config.show_relative_time !== false ? html`<span class="activity-ago">${ago}</span>` : ''}
</div>
</div>
</div>
</div>
`;
}
// ── Reward claim events ───────────────────────────────
if (type === "reward_claimed" || type === "reward_approved") {
const pts = Math.abs(item.points || 0);
@@ -794,6 +944,7 @@ class TaskMateActivityCard extends LitElement {
<div class="activity-body">
<div class="activity-title">
<strong>${childName}</strong> · ${choreName}
${this._ratingStars(item)}
${status !== 'approved' ? html`
<span class="activity-status ${status}">${this._t('common.' + status)}</span>
` : ''}
@@ -817,6 +968,14 @@ class TaskMateActivityCard extends LitElement {
`;
}
// Stars a parent gave the approval (#927); nothing for unrated completions.
_ratingStars(item) {
const n = Number(item && item.rating) || 0;
if (n < 1 || n > 3) return '';
const label = this._t('activity.rated', { count: n });
return html`<span class="act-rating" title="${label}" aria-label="${label}" role="img">${'★'.repeat(n)}<span class="off">${'☆'.repeat(3 - n)}</span></span>`;
}
// ── Undo affordance ──────────────────────────────────────
// kind: 'chore' (undo an approval → pending) or 'txn' (reverse a points txn).
_renderUndoButton(kind, id, detail, child, points) {
@@ -834,7 +993,7 @@ class TaskMateActivityCard extends LitElement {
?disabled=${loading}
@click=${() => { this._confirm = { kind, id, message }; }}
>
<ha-icon icon="mdi:undo-variant"></ha-icon>
<ha-icon class="tm-rtl-flip" icon="mdi:undo-variant"></ha-icon>
</button>
`;
}
@@ -853,7 +1012,7 @@ class TaskMateActivityCard extends LitElement {
${this._t('common.cancel')}
</button>
<button class="undo-modal-btn go" @click=${() => this._doUndo()}>
↩ ${this._t('activity.undo')}
${this.getAttribute?.("dir") === "rtl" ? "↪" : "↩"} ${this._t('activity.undo')}
</button>
</div>
</div>
@@ -894,6 +1053,20 @@ class TaskMateActivityCard extends LitElement {
Ported from docs/design/redesigns/frag/17-activity.html
══════════════════════════════════════════════════════════════════════ */
// Surprise inspections (#981): label + chore for an inspection_* event.
_inspectionText(item) {
const keys = {
inspection_started: 'activity.inspection_started',
inspection_passed: 'activity.inspection_passed',
inspection_failed: 'activity.inspection_failed',
inspection_redo: 'activity.inspection_redo',
};
return {
label: this._t(keys[item.type] || 'activity.inspection_started'),
name: item.chore_name || this._t('activity.a_chore'),
};
}
// Normalise a raw event into a descriptor the designed renderers consume.
// Preserves the SAME undo affordance/handler used by the classic path.
_describeEvent(item, childNames, pointsIcon, chorePointsMap) {
@@ -902,18 +1075,31 @@ class TaskMateActivityCard extends LitElement {
const ago = this._formatAgo(new Date(item.completed_at)) || this._formatTime(new Date(item.completed_at));
const time = this._formatTime(new Date(item.completed_at));
if ((type === "points_added" || type === "points_removed") && this._isFreezeEntry(item)) {
const displayReason = this._translateReason(item.reason);
return {
childName, tone: 'accent', emoji: this._isWishEntry(item) ? '💝' : '❄️', sign: '', pts: '',
text: html`<strong>${childName}</strong> <span class="reason">— ${displayReason}</span>`,
plain: `${childName} · ${displayReason}`,
ago, time,
ptsClass: 'accent',
undo: null,
};
}
if (type === "points_added" || type === "points_removed") {
const isAdd = type === "points_added";
const pts = Math.abs(item.points || 0);
const reason = item.reason || '';
const isPenalty = reason.startsWith('Penalty:');
const isPoolAllocation = reason.startsWith('Allocated to pool:');
// Saving into a wish (#932) is the same kind of move as a savings jar.
const isPoolAllocation = reason.startsWith('Allocated to pool:') || reason.startsWith('Wish savings:');
const isSpend = !isAdd && !isPenalty && isPoolAllocation;
const displayReason = this._translateReason(item.reason);
const verb = isAdd ? this._t('activity.received') : isSpend ? this._t('activity.spent') : this._t('activity.lost');
const sign = isAdd ? '+' : '−';
const tone = isAdd ? 'good' : (isSpend ? 'accent' : 'bad');
const emoji = isAdd ? '⭐' : (isPenalty ? '⚠️' : '➖');
const emoji = reason.startsWith('Inspection passed:') ? '🔍' : isAdd ? '⭐' : (isPenalty ? '⚠️' : '➖');
return {
childName, tone, emoji, sign, pts,
text: html`<strong>${childName}</strong> ${verb} <strong>${pts}</strong>${item.reason ? html` <span class="reason">— ${displayReason}</span>` : ''}`,
@@ -926,6 +1112,34 @@ class TaskMateActivityCard extends LitElement {
};
}
if (type.startsWith("inspection_")) {
const d = this._inspectionText(item);
return {
childName, tone: type === "inspection_failed" ? 'accent' : 'good',
emoji: type === "inspection_redo" ? '🔁' : '🔍', sign: '', pts: '',
text: html`<strong>${childName}</strong> · ${d.label} <span class="reason">— ${d.name}</span>${item.note ? html`<div class="act-insp-note">“${item.note}”</div>` : ''}`,
plain: `${childName} · ${d.label} — ${d.name}`,
ago, time,
ptsClass: 'good',
undo: null,
};
}
if (type === "chore_rejected" || type === "reward_rejected") {
const isReward = type === "reward_rejected";
const name = (isReward ? item.reward_name : item.chore_name)
|| this._t(isReward ? 'activity.a_reward' : 'activity.a_chore');
const why = item.reason ? this._t('activity.rejected_reason', { reason: item.reason }) : '';
return {
childName, tone: 'bad', emoji: '↩️', sign: '', pts: '',
text: html`<strong>${childName}</strong> · ${name} <span class="reason">— ${this._t('common.rejected')}</span>${why ? html`<div class="act-reject-reason">${why}</div>` : ''}`,
plain: `${childName} · ${name} · ${this._t('common.rejected')}${why ? ` — ${why}` : ''}`,
ago, time,
ptsClass: 'bad',
undo: null,
};
}
if (type === "reward_claimed" || type === "reward_approved") {
const pts = Math.abs(item.points || 0);
const isPending = type === "reward_claimed" && !item.approved;
@@ -948,8 +1162,10 @@ class TaskMateActivityCard extends LitElement {
const emoji = status === 'approved' ? '✅' : status === 'rejected' ? '❌' : '⏳';
return {
childName, tone, emoji, sign: '+', pts,
text: html`<strong>${childName}</strong> ${this._t('activity.completed')} ${choreName}`,
text: html`<strong>${childName}</strong> ${this._t('activity.completed')} ${choreName} ${this._ratingStars(item)}`,
plain: `${childName} · ${choreName}`,
// Console renders `plain`, so it carries the stars separately.
stars: this._ratingStars(item),
ago, time,
ptsClass: status === 'approved' ? 'good' : status === 'rejected' ? 'bad' : 'muted',
undo: status === 'approved'
@@ -965,7 +1181,7 @@ class TaskMateActivityCard extends LitElement {
? this._t('activity.undo_confirm_chore', { detail: undo.detail, child: undo.child, points: undo.points })
: this._t('activity.undo_confirm_txn', { detail: undo.detail, child: undo.child, points: undo.points });
return html`<button class="btn ghost sm tmd-undo" ?disabled=${loading}
@click=${() => { this._confirm = { kind: undo.kind, id: undo.id, message }; }}>↩ ${label}</button>`;
@click=${() => { this._confirm = { kind: undo.kind, id: undo.id, message }; }}>${this.getAttribute?.("dir") === "rtl" ? "↪" : "↩"} ${label}</button>`;
}
_designFilterChips() {
@@ -1067,7 +1283,7 @@ class TaskMateActivityCard extends LitElement {
<div class="row act-cn-row" style="--ac:var(--tmd-${r.tone})">
${this._stripe(r)}
<div class="av" style="--av:26px;--ac:var(--tmd-${r.tone})">${r.emoji}</div>
<div class="act-cn-mid">${r.plain} <span class="num" style="color:var(--tmd-${r.ptsClass})">${r.sign}${r.pts}</span></div>
<div class="act-cn-mid">${r.plain} ${r.stars || ''} <span class="num" style="color:var(--tmd-${r.ptsClass})">${r.sign}${r.pts}</span></div>
${this._designUndoBtn(r.undo, this._t('activity.undo'))}
<div class="num muted act-cn-time">${this._timeMeta(r)}</div>
</div>`)}
@@ -1177,7 +1393,7 @@ class TaskMateActivityCardEditor extends LitElement {
.preset-swatch { width: 22px; height: 22px; border-radius: 50%; cursor: pointer; border: 2px solid var(--divider-color, #e0e0e0); transition: transform 0.1s; padding: 0; }
.preset-swatch:hover { transform: scale(1.15); }
.preset-swatch.active { border-color: var(--primary-text-color); box-shadow: 0 0 0 2px var(--primary-color); }
.colour-reset { font-size: 0.78rem; color: var(--secondary-text-color); background: none; border: 1px solid var(--divider-color, #e0e0e0); border-radius: 4px; padding: 4px 10px; cursor: pointer; margin-left: auto; }
.colour-reset { font-size: 0.78rem; color: var(--secondary-text-color); background: none; border: 1px solid var(--divider-color, #e0e0e0); border-radius: 4px; padding: 4px 10px; cursor: pointer; margin-inline-start: auto; }
.colour-helper { color: var(--secondary-text-color); font-size: 0.82rem; line-height: 1.3; }
`;
}
@@ -34,6 +34,8 @@ class TaskMateApprovalsCard extends LitElement {
_loading: { type: Object },
_signed: { state: true },
_review: { state: true },
_ratings: { state: true },
_rejecting: { state: true },
};
}
@@ -49,6 +51,7 @@ class TaskMateApprovalsCard extends LitElement {
constructor() {
super();
this._loading = {};
this._ratings = {}; // completion_id -> picked 1-3 star rating (#927)
this._signed = {}; // safe photo path -> per-viewer signed path
this._inflight = new Set();
}
@@ -212,7 +215,7 @@ class TaskMateApprovalsCard extends LitElement {
}
.time-group {
margin-left: 8px;
margin-inline-start: 8px;
margin-bottom: 12px;
}
@@ -331,11 +334,11 @@ class TaskMateApprovalsCard extends LitElement {
}
.action-buttons.left {
margin-right: 12px;
margin-inline-end: 12px;
}
.action-buttons.right {
margin-left: 12px;
margin-inline-start: 12px;
}
.action-button {
@@ -411,8 +414,8 @@ class TaskMateApprovalsCard extends LitElement {
}
.tm-rv-quote {
border-left: 3px solid var(--accent-color, #ffc107);
padding: 6px 0 6px 10px;
border-inline-start: 3px solid var(--accent-color, #ffc107);
padding-block: 6px; padding-inline: 10px 0;
margin-bottom: 12px;
font-size: 0.92rem;
white-space: pre-wrap;
@@ -468,6 +471,29 @@ class TaskMateApprovalsCard extends LitElement {
color: var(--primary-text-color, #33373d);
}
.tm-rv-btn.primary { background: #4caf50; color: #fff; }
.tm-rv-btn.danger { background: #f44336; color: #fff; }
/* Reject-with-a-reason sheet (#976) */
.tm-rj-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
.tm-rj-chip {
border: 2px solid var(--divider-color, #e0e0e0);
background: var(--card-background-color, #fff);
color: var(--primary-text-color, #33373d);
border-radius: 999px;
padding: 5px 12px;
font: inherit;
font-size: 0.85rem;
font-weight: 700;
cursor: pointer;
}
.tm-rj-chip.on { border-color: #f44336; background: rgba(244, 67, 54, 0.12); }
.tm-rj-input { font-size: 0.95rem; font-weight: 600; }
.tm-rj-input:focus { border-color: #f44336; }
.tm-rj-hint {
font-size: 0.75rem;
color: var(--secondary-text-color, #6b7280);
margin-top: 6px;
}
/* Mandatory-miss review actions (#532) */
.action-button.penalty {
@@ -486,7 +512,7 @@ class TaskMateApprovalsCard extends LitElement {
}
.approval-item.mandatory {
border-left: 5px solid var(--fun-red, #e74c3c);
border-inline-start: 5px solid var(--fun-red, #e74c3c);
}
.approval-item.mandatory .penalty-note {
@@ -605,7 +631,7 @@ class TaskMateApprovalsCard extends LitElement {
/* Clean Pro — divided list */
.ap-cp-item { padding: 10px 0; flex-wrap: wrap; row-gap: 8px; }
.ap-cp-mid { flex: 1 1 130px; min-width: 130px; }
.ap-cp-actions { display: flex; gap: 8px; flex: 0 0 auto; margin-left: auto; }
.ap-cp-actions { display: flex; gap: 8px; flex: 0 0 auto; margin-inline-start: auto; }
.ap-cp-title { font-weight: 600; }
.ap-cp-sub { font-size: 12px; }
.ap-cp-photo { width: 32px; height: 32px; border-radius: 8px; overflow: hidden;
@@ -616,6 +642,22 @@ class TaskMateApprovalsCard extends LitElement {
.ap-d-photo-link { line-height: 0; text-decoration: none; }
/* Quality rating star picker (#927) — shared by every design. */
.ap-stars { display: inline-flex; align-items: center; gap: 1px; flex: none; }
.ap-star {
background: none; border: 0; margin: 0; padding: 2px 3px; cursor: pointer;
font: inherit; font-size: 20px; line-height: 1; border-radius: 6px;
color: var(--tmd-dim, var(--secondary-text-color, #9e9e9e));
}
.ap-star:hover, .ap-star.on { color: var(--tmd-gold, #f5b301); }
/* Console rows are one tight line: the picker sits under the subtitle. */
.ap-cn-stars { margin-top: 3px; }
.ap-cn-stars .ap-star { font-size: 17px; padding: 1px 2px; }
.ap-star:focus-visible { outline: 2px solid var(--tmd-accent, var(--primary-color)); outline-offset: 1px; }
.ap-star:disabled { cursor: default; opacity: .5; }
.tm-rv-stars { margin: 4px 0 10px; }
.tm-rv-stars .ap-star { font-size: 28px; }
/* Scrollable list (parity with classic content) */
.ap-d-scroll { max-height: 360px; overflow-y: auto; }
`;
@@ -633,6 +675,9 @@ class TaskMateApprovalsCard extends LitElement {
header_color: '#27ae60',
...config,
};
// Dashboards saved before #1010 stored the English default title; treat it
// as unset so the translated default is shown instead.
if (this.config.title === "Pending Approvals") this.config.title = "";
}
getCardSize() {
@@ -646,7 +691,6 @@ class TaskMateApprovalsCard extends LitElement {
static getStubConfig() {
return {
entity: "sensor.taskmate_overview",
title: "Pending Approvals",
};
}
@@ -732,6 +776,7 @@ class TaskMateApprovalsCard extends LitElement {
`}
</div>
${this._renderReview()}
${this._renderRejectSheet()}
</ha-card>
`;
}
@@ -894,6 +939,7 @@ class TaskMateApprovalsCard extends LitElement {
${this._designHeader(hd, items.length, completions)}
<div class="tmd-bd">${body}</div>
${this._renderReview()}
${this._renderRejectSheet()}
</ha-card>`;
}
@@ -924,7 +970,7 @@ class TaskMateApprovalsCard extends LitElement {
}
if (it.kind === "claim") {
const approve = () => this._handleApproveReward(it.id);
const reject = () => this._handleRejectReward(it.id);
const reject = () => this._handleRejectReward(it.id, it.title, it.childName);
return this._apActionPair(approve, reject, isLoading, shape);
}
// mandatory miss: apply penalty (if any) / postpone / dismiss
@@ -952,7 +998,7 @@ class TaskMateApprovalsCard extends LitElement {
_apActionPair(approve, reject, isLoading, shape, completion = null) {
const adjust = completion
? html`<button class="btn ghost sm ${shape === "round" ? "round" : ""}" ?disabled="${isLoading}"
? html`${shape === "round" ? "" : this._renderStars(completion)}<button class="btn ghost sm ${shape === "round" ? "round" : ""}" ?disabled="${isLoading}"
title="${this._t('approvals.award_label')}"
@click="${() => this._openReview(completion)}">${this._reviewGlyph(completion)}</button>`
: "";
@@ -1036,6 +1082,7 @@ class TaskMateApprovalsCard extends LitElement {
<div style="flex:1;min-width:0">
<div class="ap-cn-title">${it.title}</div>
<div class="muted num ap-cn-sub">${(it.childName || '').toUpperCase()} · +${it.points} XP</div>
${it.kind === "completion" ? this._renderStars(it.completion, "ap-cn-stars") : ""}
</div>
${this._apPhotoDesigned(it, "ap-cn-photo")}
${this._designActions(it, "round")}
@@ -1078,7 +1125,7 @@ class TaskMateApprovalsCard extends LitElement {
return html`
<div class="day-group">
<div class="day-header">
<ha-icon icon="mdi:gift-outline" style="--mdc-icon-size: 18px; vertical-align: -3px; margin-right: 6px;"></ha-icon>
<ha-icon icon="mdi:gift-outline" style="--mdc-icon-size: 18px; vertical-align: -3px; margin-inline-end: 6px;"></ha-icon>
${this._t('approvals.reward_claims_section')}
</div>
${claims.map((claim) => this._renderClaimItem(claim))}
@@ -1098,7 +1145,7 @@ class TaskMateApprovalsCard extends LitElement {
<div class="action-buttons left">
<button
class="action-button reject ${isLoading ? 'loading' : ''}"
@click="${() => this._handleRejectReward(claimId)}"
@click="${() => this._handleRejectReward(claimId, rewardName, childName)}"
title="${this._t('approvals.reject')}"
?disabled="${isLoading}"
>
@@ -1107,7 +1154,7 @@ class TaskMateApprovalsCard extends LitElement {
</div>
<div class="item-info">
<span class="chore-name">
<ha-icon icon="${claim.reward_icon || 'mdi:gift'}" style="--mdc-icon-size: 16px; vertical-align: -3px; margin-right: 4px;"></ha-icon>
<ha-icon icon="${claim.reward_icon || 'mdi:gift'}" style="--mdc-icon-size: 16px; vertical-align: -3px; margin-inline-end: 4px;"></ha-icon>
${rewardName}
</span>
<div class="item-details">
@@ -1139,16 +1186,16 @@ class TaskMateApprovalsCard extends LitElement {
await this._callClaimService('approve_reward', claimId);
}
async _handleRejectReward(claimId) {
await this._callClaimService('reject_reward', claimId);
_handleRejectReward(claimId, name = "", childName = "") {
this._openRejectSheet({ kind: "reward", id: claimId, name, childName });
}
async _callClaimService(service, claimId) {
async _callClaimService(service, claimId, extra = null) {
if (this._loading[claimId]) return;
this._loading = { ...this._loading, [claimId]: true };
this.requestUpdate();
try {
await this.hass.callService('taskmate', service, { claim_id: claimId });
await this.hass.callService('taskmate', service, { claim_id: claimId, ...(extra || {}) });
} catch (error) {
console.error(`Failed to call ${service}:`, error);
if (this.hass.callService) {
@@ -1177,7 +1224,7 @@ class TaskMateApprovalsCard extends LitElement {
return html`
<div class="day-group">
<div class="day-header">
<ha-icon icon="mdi:alert-octagon-outline" style="--mdc-icon-size: 18px; vertical-align: -3px; margin-right: 6px;"></ha-icon>
<ha-icon icon="mdi:alert-octagon-outline" style="--mdc-icon-size: 18px; vertical-align: -3px; margin-inline-end: 6px;"></ha-icon>
${this._t('approvals.mandatory_section')}
</div>
${misses.map((miss) => this._renderMissItem(miss))}
@@ -1196,7 +1243,7 @@ class TaskMateApprovalsCard extends LitElement {
<div class="approval-item mandatory ${isLoading ? 'loading' : ''}">
<div class="item-info">
<span class="chore-name">
<ha-icon icon="mdi:alert-circle-outline" style="--mdc-icon-size: 16px; vertical-align: -3px; margin-right: 4px;"></ha-icon>
<ha-icon icon="mdi:alert-circle-outline" style="--mdc-icon-size: 16px; vertical-align: -3px; margin-inline-end: 4px;"></ha-icon>
${choreName}
</span>
<div class="item-details">
@@ -1503,6 +1550,7 @@ class TaskMateApprovalsCard extends LitElement {
<ha-icon icon="mdi:star"></ha-icon>
${completion.points}
</span>
${this._renderStars(completion)}
</div>
${this._photoHref(completion.photo_url) ? html`
<a class="approval-photo" href="${this._photoHref(completion.photo_url)}" target="_blank" rel="noopener"
@@ -1533,11 +1581,157 @@ class TaskMateApprovalsCard extends LitElement {
}
async _handleApprove(completion) {
await this._callService("approve_chore", completion.completion_id);
const id = completion.completion_id;
const rating = this._ratingMultipliers() ? this._ratings[id] || 0 : 0;
await this._callService("approve_chore", id, rating ? { rating } : null);
if (rating) this._clearRating(id);
}
async _handleReject(completion) {
await this._callService("reject_chore", completion.completion_id);
/* ── Quality rating (#927) ────────────────────────────────────────────
Published by the pending-approvals sensor only while the feature is on,
so its absence is the "off" switch. Picking a star is a selection, not an
approval — Approve still does the approving, and tapping the picked star
again clears it back to "unrated" (100%). */
_ratingMultipliers() {
const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity))
|| this.hass?.states?.[this.config.entity]?.attributes || {};
const m = attrs.quality_rating && attrs.quality_rating.multipliers;
return Array.isArray(m) && m.length === 3 ? m : null;
}
_pickRating(completion, n) {
const id = completion.completion_id;
const next = this._ratings[id] === n ? 0 : n;
this._ratings = { ...this._ratings, [id]: next };
// In the review sheet the parent sees the number they will pay, so a star
// rescales it there rather than silently on the server.
if (this._review && this._review.completion.completion_id === id) {
const input = this.renderRoot && this.renderRoot.querySelector("#tm-review-points");
const mults = this._ratingMultipliers();
if (input && mults) {
const base = Number(this._review.base) || 0;
input.value = String(Math.max(0, Math.round(next ? base * mults[next - 1] : base)));
}
}
}
_clearRating(id) {
if (!(id in this._ratings)) return;
const next = { ...this._ratings };
delete next[id];
this._ratings = next;
}
_renderStars(completion, extraClass = "") {
const mults = this._ratingMultipliers();
if (!mults || !completion) return "";
const id = completion.completion_id;
const sel = this._ratings[id] || 0;
const busy = !!this._loading[id];
return html`<span class="ap-stars ${extraClass}" role="group" aria-label="${this._t('approvals.rating_label')}">
${[1, 2, 3].map((n) => {
const label = this._t('approvals.rating_star_title', { count: n, percent: Math.round(Number(mults[n - 1]) * 100) });
// Filled vs outlined, not just gold vs grey, so the pick reads without colour.
return html`<button type="button" class="ap-star ${n <= sel ? "on" : ""}" ?disabled="${busy}"
aria-pressed="${n === sel ? "true" : "false"}" title="${label}" aria-label="${label}"
@click="${(e) => { e.stopPropagation(); this._pickRating(completion, n); }}">${n <= sel ? "★" : "☆"}</button>`;
})}
</span>`;
}
_handleReject(completion) {
this._openRejectSheet({
kind: "chore",
id: completion.completion_id,
name: completion.chore_name || "",
childName: completion.child_name || "",
});
}
/* ── Reject with a reason (#976) ─────────────────────────────────────
Reject opens a small sheet: quick-pick chips or free text, both
optional — an empty box rejects exactly as before. Rendered from both
the classic and the designed paths. The input is uncontrolled so a
coordinator refresh can't wipe what the parent is typing. */
_rejectChips() {
return ["reject.chip_not_finished", "reject.chip_redo", "reject.chip_not_today"].map(k => this._t(k));
}
_rejectInput() {
return this.renderRoot && this.renderRoot.querySelector("#tm-reject-reason");
}
_openRejectSheet(target) {
this._rejecting = { ...target, chip: "" };
this.updateComplete.then(() => {
const input = this._rejectInput();
if (!input) return;
input.value = "";
input.focus();
});
}
_closeRejectSheet() {
this._rejecting = null;
}
_pickRejectChip(text) {
const input = this._rejectInput();
const chip = this._rejecting && this._rejecting.chip === text ? "" : text;
if (input) input.value = chip;
this._rejecting = { ...this._rejecting, chip };
}
async _confirmReject() {
const target = this._rejecting;
if (!target) return;
const input = this._rejectInput();
const reason = String((input && input.value) || "").trim().slice(0, 200);
this._closeRejectSheet();
const extra = reason ? { reason } : null;
if (target.kind === "reward") await this._callClaimService("reject_reward", target.id, extra);
else await this._callService("reject_chore", target.id, extra);
}
_renderRejectSheet() {
const target = this._rejecting;
if (!target) return "";
const stop = (e) => e.stopPropagation();
const onKey = (e) => { if (e.key === "Enter") this._confirmReject(); };
const onInput = (e) => {
const chip = this._rejectChips().includes(e.target.value) ? e.target.value : "";
if (chip !== target.chip) this._rejecting = { ...target, chip };
};
return html`
<div class="tm-rv-overlay" @click="${() => this._closeRejectSheet()}">
<div class="tm-rv-sheet tm-rj-sheet" role="dialog" aria-modal="true"
aria-label="${this._t('reject.title', { name: target.name })}" @click="${stop}">
<div class="tm-rv-title">${this._t('reject.title', { name: target.name })}</div>
${target.childName ? html`<div class="tm-rv-sub">${target.childName}</div>` : ""}
<label class="tm-rv-label" for="tm-reject-reason">${this._t('reject.reason_label')}</label>
<div class="tm-rj-chips">
${this._rejectChips().map(c => html`
<button type="button" class="tm-rj-chip ${target.chip === c ? "on" : ""}"
aria-pressed="${target.chip === c}"
@click="${() => this._pickRejectChip(c)}">${c}</button>`)}
</div>
<input id="tm-reject-reason" class="tm-rv-points tm-rj-input" type="text" maxlength="200"
placeholder="${this._t('reject.reason_placeholder')}"
@input="${onInput}" @keydown="${onKey}">
<div class="tm-rj-hint">${this._t('reject.reason_hint')}</div>
<div class="tm-rv-row">
<button class="tm-rv-btn ghost" @click="${() => this._closeRejectSheet()}">
${this._t('common.cancel')}
</button>
<button class="tm-rv-btn danger" @click="${() => this._confirmReject()}">
${this._t('reject.confirm')}
</button>
</div>
</div>
</div>
`;
}
async _handleApproveAll(completions) {
@@ -1575,12 +1769,15 @@ class TaskMateApprovalsCard extends LitElement {
}
_openReview(completion) {
this._review = { completion };
const base = completion.suggested_points || completion.points || 0;
this._review = { completion, base };
this.requestUpdate();
this.updateComplete.then(() => {
const input = this.renderRoot && this.renderRoot.querySelector("#tm-review-points");
if (!input) return;
input.value = String(completion.suggested_points || completion.points || 0);
const mults = this._ratingMultipliers();
const picked = this._ratings[completion.completion_id] || 0;
input.value = String(mults && picked ? Math.max(0, Math.round(base * mults[picked - 1])) : base);
input.focus();
input.select();
});
@@ -1597,8 +1794,12 @@ class TaskMateApprovalsCard extends LitElement {
const input = this.renderRoot && this.renderRoot.querySelector("#tm-review-points");
const points = Math.max(0, Math.round(Number(input && input.value) || 0));
const completionId = review.completion.completion_id;
const rating = this._ratingMultipliers() ? this._ratings[completionId] || 0 : 0;
this._closeReview();
await this._callService("approve_chore", completionId, { points });
// The typed award is paid as-is; a picked rating is recorded with it
// (the stars above already rescaled the number the parent confirmed).
await this._callService("approve_chore", completionId, rating ? { points, rating } : { points });
if (rating) this._clearRating(completionId);
}
_renderReview() {
@@ -1624,6 +1825,11 @@ class TaskMateApprovalsCard extends LitElement {
${photoUrl ? html`<div class="tm-rv-photo"><img src="${photoUrl}" alt="" loading="lazy"></div>` : ""}
${this._ratingMultipliers() ? html`
<div class="tm-rv-label">${this._t('approvals.rating_label')}</div>
<div class="tm-rv-stars">${this._renderStars(completion)}</div>
` : ""}
<label class="tm-rv-label" for="tm-review-points">${this._t('approvals.award_label')}</label>
<input id="tm-review-points" class="tm-rv-points" type="number"
min="0" max="9999" inputmode="numeric">
@@ -1697,7 +1903,7 @@ class TaskMateApprovalsCardEditor extends LitElement {
.preset-swatch { width: 22px; height: 22px; border-radius: 50%; cursor: pointer; border: 2px solid var(--divider-color, #e0e0e0); transition: transform 0.1s; padding: 0; }
.preset-swatch:hover { transform: scale(1.15); }
.preset-swatch.active { border-color: var(--primary-text-color); box-shadow: 0 0 0 2px var(--primary-color); }
.colour-reset { font-size: 0.78rem; color: var(--secondary-text-color); background: none; border: 1px solid var(--divider-color, #e0e0e0); border-radius: 4px; padding: 4px 10px; cursor: pointer; margin-left: auto; }
.colour-reset { font-size: 0.78rem; color: var(--secondary-text-color); background: none; border: 1px solid var(--divider-color, #e0e0e0); border-radius: 4px; padding: 4px 10px; cursor: pointer; margin-inline-start: auto; }
.colour-helper { color: var(--secondary-text-color); font-size: 0.82rem; line-height: 1.3; }
`;
}
@@ -35,6 +35,11 @@
// missing that prefix and so matched nothing at all, silently dropping
// chore_completions and mandatory_misses from every merge (#798).
"sensor.taskmate_pending_approvals",
// The bounty board (#931): `bounties` for the bounty card.
"sensor.taskmate_bounties",
// Chore auctions (#982): `auctions`, a digest with no bid amounts. The
// auction card re-fetches its own view over the WebSocket when it moves.
"sensor.taskmate_auctions",
];
// Attributes a companion must NOT contribute to the merge, because another
@@ -164,8 +169,19 @@
*/
const TASKMATE_BUTTON_PREFIX = "button.taskmate_";
// HA's RTL flag for the language, as taskmate-design.js direction() reads it.
function isRTL(hass) {
const meta = hass.translationMetadata && hass.translationMetadata.translations;
const entry = meta && hass.language && meta[hass.language];
return entry ? !!entry.isRTL : undefined;
}
function hasRelevantChange(oldHass, newHass, primaryEntityId) {
if (!oldHass || !oldHass.states || !newHass || !newHass.states) return true;
// A language change (#994) keeps the same states object, but every card
// renders translated strings and stamps dir from the language.
if (oldHass.language !== newHass.language) return true;
if (isRTL(oldHass) !== isRTL(newHass)) return true;
if (oldHass.states === newHass.states) return false;
if (primaryEntityId) {
@@ -0,0 +1,848 @@
/**
* TaskMate Chore Auction Card (#982)
*
* A reverse auction on one occurrence of an unpopular chore: the child this
* card belongs to bids the fewest points they would do it for, lowest bid
* wins, and the winner gets that occurrence at their price. Bids are sealed —
* the card shows how many bids are in, the child's own bid, and after closing
* the winner and the winning price. Never anyone else's amount.
*
* The auctions are fetched over the `taskmate/auctions/list` WebSocket
* command, which applies the linked-child rule and only ever returns this
* child's own bid. `sensor.taskmate_auctions` carries a public digest (bid
* counts, results); the card re-fetches whenever that digest moves.
*
* Single layout, so it consumes the design tokens directly (like the bounty
* card) rather than carrying a second _renderDesigned() template. Every
* --tmd-* reference has the card's original value as its fallback, so classic
* — which defines no tokens — keeps the wireframe look. Layout classes carry
* an ac- prefix: the shared design kit defines its own .btn / .bar / .chip.
*/
const LitElement = customElements.get("hui-masonry-view")
? Object.getPrototypeOf(customElements.get("hui-masonry-view"))
: Object.getPrototypeOf(customElements.get("hui-view"));
const html = LitElement.prototype.html;
const css = LitElement.prototype.css;
const _safeColor = (c, d) => (typeof c === "string" && /^#[0-9a-fA-F]{3,8}$/.test(c) ? c : d);
const DEFAULT_ACCENT = "#5e35b1";
const AUCTIONS_ENTITY = "sensor.taskmate_auctions";
// Child avatar colours by position; a design's own palette wins when it has one.
const CHILD_COLOURS = ["#ff6b9d", "#3498db", "#1abc9c", "#e67e22", "#9b59b6", "#2ecc71"];
class TaskMateAuctionCard extends LitElement {
static get properties() {
return {
hass: { type: Object },
config: { type: Object },
_auctions: { type: Array },
_error: { type: String },
_sheet: { type: Object },
_busy: { type: Boolean },
};
}
constructor() {
super();
this._auctions = null;
this._error = "";
this._sheet = null;
this._busy = false;
this._tick = null;
this._fetchedKey = null;
}
setConfig(config) {
if (!config || !config.entity) throw new Error("You need to define an entity");
if (!config.child_id) throw new Error("You need to define a child_id");
this.config = config;
this._fetchedKey = null;
}
static getStubConfig() {
return { entity: "sensor.taskmate_overview", child_id: "" };
}
static getConfigElement() {
return document.createElement("taskmate-auction-card-editor");
}
getCardSize() {
return 6;
}
connectedCallback() {
super.connectedCallback();
// Countdowns tick client-side from the server's closing times; re-render
// once a second only while an auction is actually open.
this._tick = setInterval(() => {
if ((this._auctions || []).some(a => a.status === "open")) this.requestUpdate();
}, 1000);
this._fetchedKey = null;
}
disconnectedCallback() {
super.disconnectedCallback();
clearInterval(this._tick);
this._tick = null;
}
shouldUpdate(changedProps) {
if (changedProps.has("hass") && changedProps.size === 1) {
return window.__taskmate_hasChanged
? window.__taskmate_hasChanged(changedProps.get("hass"), this.hass, this.config?.entity)
: true;
}
return true;
}
updated() {
// Re-fetch whenever the public digest moves (a bid came in, one closed).
const key = this._digestKey();
if (this.hass && key !== this._fetchedKey) {
this._fetchedKey = key;
this._fetch();
}
}
_t(key, params) {
const fn = window.__taskmate_localize;
return fn ? fn(this.hass, key, params) : key;
}
_attrs() {
return (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity))
|| this.hass?.states?.[this.config.entity]?.attributes || {};
}
_digestKey() {
const digest = this._attrs().auctions || this.hass?.states?.[AUCTIONS_ENTITY]?.attributes?.auctions || [];
return `${this.config?.child_id}|${JSON.stringify(digest)}`;
}
_children() {
return this._attrs().children || [];
}
_child() {
return this._children().find(c => String(c.id) === String(this.config.child_id));
}
_childName(id) {
const c = this._children().find(ch => String(ch.id) === String(id));
return c ? c.name : "?";
}
_lang() {
return this.hass?.locale?.language || this.hass?.language || undefined;
}
/** "Saturday 4 October" (long) or "Sat 4 Oct" for an ISO date. */
_date(iso, long = true) {
const d = new Date(`${iso}T00:00:00`);
if (Number.isNaN(d.getTime())) return iso || "";
return d.toLocaleDateString(this._lang(), long
? { weekday: "long", day: "numeric", month: "long" }
: { weekday: "short", day: "numeric", month: "short" });
}
_when(isoInstant) {
const d = new Date(isoInstant || "");
if (Number.isNaN(d.getTime())) return "";
return d.toLocaleString(this._lang(), { weekday: "short", hour: "2-digit", minute: "2-digit" });
}
_fmtDur(ms) {
const sec = Math.max(0, Math.round(ms / 1000));
const d = Math.floor(sec / 86400);
const h = Math.floor((sec % 86400) / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = sec % 60;
if (d) return this._t("bounty.dur_days", { d, h });
if (h) return this._t("bounty.dur_hours", { h, m: String(m).padStart(2, "0") });
return this._t("bounty.dur_minutes", { m, s: String(s).padStart(2, "0") });
}
_left(a) {
const t = Date.parse(a.closes_at || "");
return Number.isFinite(t) ? t - Date.now() : 0;
}
_avatar(childId, size = 30) {
const children = this._children();
const idx = Math.max(0, children.findIndex(c => String(c.id) === String(childId)));
const name = this._childName(childId);
const colour = `var(--tmd-c${(idx % 6) + 1}, ${CHILD_COLOURS[idx % CHILD_COLOURS.length]})`;
return html`<span class="ac-av" style="--ac-ac:${colour};--ac-s:${size}px" title="${name}">${name.charAt(0).toUpperCase()}</span>`;
}
/**
* Resolve the design, stamp data-tm-design on the host (so the :host-scoped
* token block applies inside this shadow root) and settle the header colour.
* The colour is an inline host property: a <style> in the template is
* ordered before adoptedStyleSheets and would lose to the :host default.
*/
_applyDesign() {
const design = window.__taskmate_design
? window.__taskmate_design.apply(this, this.hass, this.config, this.config.entity)
: "classic";
const configured = this.config.header_color;
if (typeof configured === "string" && /^#[0-9a-fA-F]{3,8}$/.test(configured)) {
this.style.setProperty("--ac-accent", _safeColor(configured, DEFAULT_ACCENT));
} else {
this.style.removeProperty("--ac-accent");
}
return design;
}
// ── data ────────────────────────────────────────────────────────────────
async _fetch() {
const conn = this.hass?.connection;
if (!conn || !this.config?.child_id) return;
try {
const res = await conn.sendMessagePromise({ type: "taskmate/auctions/list", child_id: this.config.child_id });
this._auctions = Array.isArray(res?.auctions) ? res.auctions : [];
this._error = "";
} catch (err) {
this._error = String(err?.message || err?.code || err);
}
}
async _send(msg) {
if (this._busy) return false;
this._busy = true;
try {
await this.hass.connection.sendMessagePromise(msg);
await this._fetch();
return true;
} catch (err) {
this.dispatchEvent(new CustomEvent("hass-notification", {
detail: { message: String(err?.message || err) }, bubbles: true, composed: true,
}));
return false;
} finally {
this._busy = false;
this.requestUpdate();
}
}
// ── bidding ─────────────────────────────────────────────────────────────
_openSheet(a) {
const start = a.my_bid ?? Math.round((a.min_points + a.max_points) / 2);
this._sheet = { id: a.id, value: Math.max(a.min_points, Math.min(a.max_points, start)), fresh: true };
}
_setBid(value, a) {
const v = Math.max(0, Math.min(a.max_points, Number(value) || 0));
this._sheet = { ...this._sheet, value: v, fresh: false };
}
_key(k, a) {
const cur = String(this._sheet.value || "");
if (k === "c") return this._setBid(0, a);
if (k === "bs") return this._setBid(Number(cur.slice(0, -1)) || 0, a);
return this._setBid(Number(this._sheet.fresh ? k : cur + k), a);
}
async _seal(a) {
const ok = await this._send({
type: "taskmate/auctions/bid", auction_id: a.id, child_id: this.config.child_id, points: this._sheet.value,
});
if (ok) this._sheet = null;
}
async _withdraw(a) {
await this._send({ type: "taskmate/auctions/withdraw", auction_id: a.id, child_id: this.config.child_id });
}
// ── render ──────────────────────────────────────────────────────────────
render() {
if (!this.hass || !this.config) return html``;
this._applyDesign();
const title = this.config.title || this._t("auction.title");
const child = this._child();
if (!child) {
return html`<ha-card>${this._header(title, null)}<div class="ac-empty">${this._t("auction.no_child")}</div></ha-card>`;
}
const all = this._auctions || [];
const open = all.filter(a => a.status === "open");
const done = all.filter(a => a.status !== "open");
const sheetAuction = this._sheet && open.find(a => a.id === this._sheet.id);
return html`
<ha-card class="${sheetAuction ? "ac-sheet-open" : ""}">
${this._header(title, this._auctions ? open.length : null)}
<div class="ac-content">
${this._error ? html`<div class="ac-empty ac-err">${this._t("auction.load_failed", { error: this._error })}</div>` : ""}
${open.length
? open.map(a => this._lot(a))
: (this._auctions && !this._error ? html`<div class="ac-empty">${this._t("auction.empty")}</div>` : "")}
${done.length ? html`
<div class="ac-sec">${this._t("auction.results")}</div>
${done.map(a => this._lot(a))}
` : ""}
</div>
${sheetAuction ? this._renderSheet(sheetAuction) : ""}
</ha-card>
`;
}
_header(title, openCount) {
return html`
<div class="ac-hd">
<span class="ac-hd-gavel"><ha-icon icon="mdi:gavel"></ha-icon></span>
<div class="ac-hd-text">
<div class="ac-hd-title">${title}</div>
<div class="ac-hd-sub">${this._t("auction.subtitle")}</div>
</div>
${openCount !== null ? html`<span class="ac-hd-pill">${this._t("auction.open_count", { count: openCount })}</span>` : ""}
</div>
`;
}
_otherBids(a) {
const others = Math.max(0, (a.bid_count || 0) - (a.my_bid != null ? 1 : 0));
if (!others) return this._t("auction.no_other_bids");
return others === 1 ? this._t("auction.other_bids_one") : this._t("auction.other_bids", { count: others });
}
_gauge(a) {
return html`
<div class="ac-gauge">
<div class="ac-gauge-row">
<span>${a.min_points} ★</span><span class="ac-caps">${this._t("auction.lowest_wins")}</span><span>${this._t("auction.gauge_max", { points: a.max_points })}</span>
</div>
<div class="ac-gauge-bar"></div>
<div class="ac-gauge-hint">${this._t("auction.gauge_hint", { points: a.normal_points })}</div>
</div>`;
}
_lot(a) {
const me = String(this.config.child_id);
const head = html`
<div class="ac-lot-top">
<span class="ac-lot-ic"><ha-icon icon="${a.icon || "mdi:clipboard-check-outline"}"></ha-icon></span>
<div class="ac-grow">
<div class="ac-lot-t">${a.chore_name}</div>
<div class="ac-lot-when"><ha-icon icon="mdi:calendar-blank-outline"></ha-icon>${this._date(a.occurrence)}</div>
</div>
${a.status === "open" ? html`<span class="ac-tag ac-tag-max">${this._t("auction.up_to", { points: a.max_points })}</span>` : ""}
</div>`;
if (a.status === "open") {
const mine = a.my_bid != null;
return html`
<div class="ac-lot">
${head}
${mine ? html`
<div class="ac-sealed">
<span class="ac-env"><ha-icon icon="mdi:email-lock"></ha-icon></span>
<div class="ac-grow"><small>${this._t("auction.your_bid")}</small><b>${a.my_bid} ★</b></div>
<button class="ac-btn ac-ghost ac-sm" ?disabled=${this._busy} @click=${() => this._openSheet(a)}>${this._t("auction.change")}</button>
</div>` : this._gauge(a)}
<div class="ac-lot-foot">
<span class="ac-cdown"><ha-icon icon="mdi:timer-sand"></ha-icon>${this._t("auction.closes_in", { time: this._fmtDur(this._left(a)) })}</span>
<span class="ac-bidcount"><ha-icon icon="mdi:email-outline"></ha-icon>${this._otherBids(a)}</span>
<span class="ac-grow"></span>
${mine
? html`<button class="ac-btn ac-text ac-sm" ?disabled=${this._busy} @click=${() => this._withdraw(a)}>${this._t("auction.withdraw")}</button>`
: html`<button class="ac-btn ac-bid" ?disabled=${this._busy} @click=${() => this._openSheet(a)}><ha-icon icon="mdi:gavel"></ha-icon>${this._t("auction.place_bid")}</button>`}
</div>
</div>`;
}
const closed = html`<div class="ac-lot-foot"><span class="ac-faint">${this._t("auction.closed_at", { when: this._when(a.closed_at) })}</span></div>`;
if (!a.winner_id) {
return html`
<div class="ac-lot ac-nobid">
${head}
<div class="ac-result ac-nob">
<span class="ac-ri"><ha-icon icon="mdi:email-outline"></ha-icon></span>
<div>${this._t("auction.no_bids")}<small>${this._t("auction.no_bids_sub", { chore: a.chore_name, date: this._date(a.occurrence, false) })}</small></div>
</div>
${closed}
</div>`;
}
if (String(a.winner_id) === me) {
return html`
<div class="ac-lot ac-won">
${head}
<div class="ac-result ac-win">
<span class="ac-ri"><ha-icon icon="mdi:trophy"></ha-icon></span>
<div>${this._t("auction.you_won", { points: a.price })}<small>${this._t("auction.you_won_sub", { date: this._date(a.occurrence, false) })}</small></div>
</div>
<div class="ac-won-chore">
<ha-icon icon="${a.icon || "mdi:clipboard-check-outline"}"></ha-icon>
<span class="ac-grow ac-won-nm">${a.chore_name}</span>
<span class="ac-tag ac-tag-won"><ha-icon icon="mdi:gavel"></ha-icon>${this._t("child.won_at_auction")}</span>
<span class="ac-pts"><ha-icon icon="mdi:star"></ha-icon>${a.price}</span>
</div>
<div class="ac-lot-foot"><span class="ac-faint">${this._t("auction.in_your_chores", { date: this._date(a.occurrence, false) })}</span></div>
</div>`;
}
// Lowest bid wins and the earliest breaks a tie, so a losing bid equal to
// the price lost on timing, not on the amount.
const winner = this._childName(a.winner_id);
const sub = a.my_bid == null ? this._t("auction.sibling_won_sub", { points: a.price })
: Number(a.my_bid) === Number(a.price) ? this._t("auction.tied_sub", { points: a.price, name: winner })
: this._t("auction.sibling_won_sub_bid", { points: a.price, bid: a.my_bid });
return html`
<div class="ac-lot ac-lost">
${head}
<div class="ac-result ac-lose">
<span class="ac-ri ac-ri-av">${this._avatar(a.winner_id)}</span>
<div>${this._t("auction.sibling_won", { name: winner })}<small>${sub}</small></div>
</div>
${closed}
</div>`;
}
_renderSheet(a) {
const v = this._sheet.value;
const valid = v >= a.min_points && v <= a.max_points;
const others = Math.max(0, (a.bid_count || 0) - (a.my_bid != null ? 1 : 0));
const close = () => { this._sheet = null; };
const keys = ["1", "2", "3", "4", "5", "6", "7", "8", "9"];
return html`
<div class="ac-scrim" @click=${e => { if (e.target === e.currentTarget) close(); }}>
<div class="ac-sheet" role="dialog" aria-modal="true" aria-label="${a.chore_name}">
<div class="ac-grab"></div>
<h3>${a.chore_name}</h3>
<div class="ac-sheet-sub">${this._t("auction.sheet_sub", { date: this._date(a.occurrence), time: this._fmtDur(this._left(a)) })}</div>
<div class="ac-bigbid">${v} <ha-icon icon="mdi:star"></ha-icon></div>
<div class="ac-bidlbl">${this._t("auction.bid_label")}</div>
<input type="range" class="ac-range" min="${a.min_points}" max="${a.max_points}" .value=${String(Math.max(a.min_points, v))}
aria-label="${this._t("auction.bid_label")}"
@input=${e => this._setBid(e.target.value, a)}>
<div class="ac-rng-l"><span>${this._t("auction.range_low", { points: a.min_points })}</span><span>${this._t("auction.range_high", { points: a.max_points })}</span></div>
<div class="ac-keypad">
${keys.map(k => html`<button type="button" @click=${() => this._key(k, a)}>${k}</button>`)}
<button type="button" class="ac-key-clear" @click=${() => this._key("c", a)}>${this._t("auction.clear")}</button>
<button type="button" @click=${() => this._key("0", a)}>0</button>
<button type="button" aria-label="${this._t("auction.backspace")}" @click=${() => this._key("bs", a)}><ha-icon class="tm-rtl-flip" icon="mdi:backspace-outline"></ha-icon></button>
</div>
<div class="ac-rules">
<div><ha-icon icon="mdi:lock-outline"></ha-icon><span>${this._t("auction.rule_secret")}${others ? ` ${others === 1 ? this._t("auction.other_bids_one") : this._t("auction.other_bids", { count: others })}.` : ""}</span></div>
<div><ha-icon icon="mdi:trophy-outline"></ha-icon><span>${this._t("auction.rule_lowest")}</span></div>
${a.min_points > 1 ? html`<div><ha-icon icon="mdi:arrow-collapse-down"></ha-icon><span>${this._t("auction.rule_min", { points: a.min_points })}</span></div>` : ""}
<div><ha-icon icon="mdi:check"></ha-icon><span>${this._t("auction.rule_win", { date: this._date(a.occurrence, false) })}</span></div>
</div>
<div class="ac-sheet-acts">
<button type="button" class="ac-btn ac-ghost" @click=${close}>${this._t("auction.cancel")}</button>
<button type="button" class="ac-btn ac-bid ac-seal" ?disabled=${!valid || this._busy} @click=${() => this._seal(a)}>
<ha-icon icon="mdi:email-lock"></ha-icon>${this._t("auction.seal", { points: v })}
</button>
</div>
</div>
</div>`;
}
static get styles() {
const base = css`
:host { display: block; --ac-accent: var(--tmd-accent, #5e35b1); --ac-auc: #7e57c2; }
ha-card {
position: relative;
overflow: hidden;
background: var(--tmd-surface, var(--ha-card-background, var(--card-background-color, #fff)));
color: var(--tmd-text, var(--primary-text-color));
border-radius: var(--tmd-radius, var(--ha-card-border-radius, 12px));
font-family: var(--tmd-font-body, inherit);
}
ha-card.ac-sheet-open { min-height: 700px; }
.ac-hd {
display: flex; align-items: center; gap: 12px;
padding: 14px 18px;
background: var(--ac-accent);
color: var(--tmd-hd-text, #fff);
}
:host([data-tm-design="playroom"]) .ac-hd {
background: linear-gradient(135deg, var(--ac-accent), color-mix(in srgb, var(--ac-accent) 72%, #fff));
}
.ac-hd-gavel {
width: 48px; height: 48px; flex: none; border-radius: 14px;
display: grid; place-items: center;
background: rgba(255, 255, 255, .18);
}
.ac-hd-gavel ha-icon { --mdc-icon-size: 28px; }
.ac-hd-text { flex: 1; min-width: 0; }
.ac-hd-title {
font-family: var(--tmd-font-display, inherit);
font-size: 1.3rem; font-weight: 600;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.ac-hd-sub { font-size: .8rem; opacity: .85; margin-top: 1px; }
.ac-hd-pill {
flex: none; white-space: nowrap;
background: rgba(255, 255, 255, .2);
padding: 4px 12px; border-radius: 16px;
font-size: .9rem; font-weight: 500;
}
.ac-content { padding: 16px; display: flex; flex-direction: column; gap: 12px; }
.ac-empty {
text-align: center; padding: 30px 12px;
color: var(--tmd-dim, var(--secondary-text-color));
}
.ac-err { color: var(--tmd-bad, var(--error-color, #c62828)); padding: 12px; }
.ac-sec {
font-size: .8rem; font-weight: 700; letter-spacing: .06em; text-transform: uppercase;
color: var(--tmd-dim, var(--secondary-text-color));
margin: 4px 2px -4px;
}
.ac-grow { flex: 1; min-width: 0; }
.ac-faint { font-size: 12px; color: var(--tmd-dim, var(--secondary-text-color)); }
.ac-lot {
border-radius: var(--tmd-radius-sm, 18px);
background: var(--tmd-surface, var(--ha-card-background, var(--card-background-color, #fff)));
border: 2px solid color-mix(in srgb, var(--ac-auc) 45%, var(--tmd-border, var(--divider-color, #e0e0e0)));
overflow: hidden;
}
.ac-lot.ac-won { border-color: var(--tmd-gold, #f1c40f); }
.ac-lot.ac-lost, .ac-lot.ac-nobid { border-color: var(--tmd-border, var(--divider-color, #e0e0e0)); opacity: .85; }
.ac-lot-top { display: flex; gap: 12px; padding: 14px 14px 10px; align-items: flex-start; }
.ac-lot-ic {
width: 46px; height: 46px; flex: none; border-radius: 14px;
display: grid; place-items: center; color: #fff;
background: linear-gradient(135deg, var(--ac-auc), var(--ac-accent));
box-shadow: 0 3px 10px color-mix(in srgb, var(--ac-accent) 40%, transparent);
}
.ac-lot-ic ha-icon { --mdc-icon-size: 24px; }
:host([data-tm-design="accessible"]) .ac-lot-ic { box-shadow: none; border: 2px solid var(--tmd-border, #111); }
.ac-lot-t {
font-family: var(--tmd-font-display, inherit);
font-size: 1.12rem; font-weight: 700; line-height: 1.25; overflow-wrap: anywhere;
}
.ac-lot-when {
display: flex; align-items: center; gap: 6px; margin-top: 2px;
font-size: .85rem; color: var(--tmd-dim, var(--secondary-text-color));
}
.ac-lot-when ha-icon { --mdc-icon-size: 14px; }
.ac-tag {
display: inline-flex; align-items: center; gap: 4px; flex: none;
font-size: .72rem; font-weight: 700;
padding: 2px 9px; border-radius: 999px; white-space: nowrap;
background: var(--tmd-surface-2, rgba(127, 127, 127, .12));
color: var(--tmd-dim, var(--secondary-text-color));
border: 1px solid var(--tmd-border, transparent);
}
.ac-tag ha-icon { --mdc-icon-size: 12px; }
.ac-tag-max, .ac-tag-won {
background: color-mix(in srgb, var(--ac-auc) 16%, transparent);
color: color-mix(in srgb, var(--ac-auc) 70%, var(--tmd-text, var(--primary-text-color)));
}
.ac-gauge {
margin: 0 14px; padding: 10px 12px; border-radius: 12px;
background: var(--tmd-surface-2, var(--secondary-background-color, rgba(127, 127, 127, .1)));
border: 1px solid var(--tmd-border, transparent);
}
.ac-gauge-row {
display: flex; justify-content: space-between; gap: 8px;
font-size: .75rem; font-weight: 600; letter-spacing: .02em;
color: var(--tmd-dim, var(--secondary-text-color));
}
.ac-caps { text-transform: uppercase; }
.ac-gauge-bar {
height: 10px; border-radius: 5px; margin: 6px 0;
background: linear-gradient(90deg, var(--tmd-good, #2ecc71), var(--tmd-gold, #f1c40f) 60%, var(--tmd-warn, #e67e22));
}
/* Low (green) sits at the reading start, beside the minimum label. */
:host([dir="rtl"]) .ac-gauge-bar { transform: scaleX(-1); }
.ac-gauge-hint { font-size: .78rem; text-align: center; color: var(--tmd-dim, var(--secondary-text-color)); }
.ac-sealed {
margin: 0 14px; padding: 12px;
display: flex; gap: 12px; align-items: center;
border-radius: 14px;
background: color-mix(in srgb, var(--ac-auc) 14%, transparent);
border: 1px dashed color-mix(in srgb, var(--ac-auc) 60%, transparent);
}
.ac-env { width: 44px; height: 40px; flex: none; display: grid; place-items: center; color: var(--ac-auc); }
.ac-env ha-icon { --mdc-icon-size: 36px; }
.ac-sealed small { display: block; font-size: .78rem; color: var(--tmd-dim, var(--secondary-text-color)); }
.ac-sealed b { font-size: 1.4rem; font-family: var(--tmd-font-display, inherit); }
.ac-lot-foot { display: flex; align-items: center; gap: 10px; padding: 12px 14px; flex-wrap: wrap; }
.ac-cdown {
display: inline-flex; align-items: center; gap: 6px;
font-weight: 700; font-size: .9rem; font-variant-numeric: tabular-nums;
color: color-mix(in srgb, var(--ac-auc) 75%, var(--tmd-text, var(--primary-text-color)));
}
.ac-cdown ha-icon { --mdc-icon-size: 16px; }
.ac-bidcount {
display: inline-flex; align-items: center; gap: 5px;
font-size: .8rem; color: var(--tmd-dim, var(--secondary-text-color));
}
.ac-bidcount ha-icon { --mdc-icon-size: 14px; }
.ac-result {
margin: 0 14px; padding: 12px 14px; border-radius: 14px;
display: flex; gap: 12px; align-items: center; font-weight: 600;
}
.ac-result small { display: block; font-weight: 500; font-size: .82rem; opacity: .85; margin-top: 2px; }
.ac-win { background: linear-gradient(135deg, var(--tmd-gold, #f1c40f), #f39c12); color: #3b2a00; }
.ac-lose, .ac-nob {
background: var(--tmd-surface-2, var(--secondary-background-color, rgba(127, 127, 127, .1)));
border: 1px solid var(--tmd-border, transparent);
}
.ac-nob { color: var(--tmd-dim, var(--secondary-text-color)); }
.ac-ri {
width: 42px; height: 42px; flex: none; border-radius: 50%;
display: grid; place-items: center; background: rgba(255, 255, 255, .35);
}
.ac-ri-av { background: transparent; }
.ac-won-chore {
margin: 10px 14px 0; padding: 10px 12px;
display: flex; align-items: center; gap: 10px;
border-radius: 16px;
border: 3px solid var(--tmd-gold, #f1c40f);
background: color-mix(in srgb, var(--tmd-gold, #f1c40f) 8%, transparent);
}
.ac-won-nm { font-weight: 700; }
.ac-pts {
display: inline-flex; align-items: center; gap: 4px;
font-weight: 700; color: var(--tmd-warn, #e67e22);
}
.ac-pts ha-icon { --mdc-icon-size: 16px; }
.ac-av {
width: var(--ac-s, 30px); height: var(--ac-s, 30px); flex: none;
border-radius: 50%;
background: var(--ac-ac, #9b59b6); color: #fff;
display: inline-grid; place-items: center;
font-weight: 700; font-size: calc(var(--ac-s, 30px) * .42); line-height: 1;
box-sizing: border-box;
}
.ac-btn {
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
border: 0; cursor: pointer;
border-radius: var(--tmd-radius-sm, 10px);
padding: 9px 16px; min-height: 36px;
font: inherit; font-family: var(--tmd-font-display, inherit);
font-size: .92rem; font-weight: 600; color: #fff; white-space: nowrap;
}
.ac-btn ha-icon { --mdc-icon-size: 17px; }
.ac-btn[disabled] { opacity: .45; cursor: default; }
.ac-sm { padding: 6px 12px; min-height: 30px; font-size: .82rem; }
.ac-bid { background: linear-gradient(135deg, var(--ac-auc), var(--ac-accent)); }
.ac-text { background: transparent; color: var(--tmd-dim, var(--secondary-text-color)); }
.ac-ghost {
background: transparent; color: var(--tmd-text, var(--primary-text-color));
border: 1px solid var(--tmd-border, var(--divider-color, #ccc));
}
.ac-scrim {
position: absolute; inset: 0; z-index: 5;
display: flex; align-items: flex-end;
background: rgba(0, 0, 0, .6);
border-radius: inherit;
}
.ac-sheet {
width: 100%; box-sizing: border-box;
padding: 10px 18px 20px;
background: var(--tmd-surface, var(--ha-card-background, var(--card-background-color, #fff)));
color: var(--tmd-text, var(--primary-text-color));
border-radius: 26px 26px 0 0;
border: 1px solid var(--tmd-border, transparent);
box-shadow: 0 -10px 40px rgba(0, 0, 0, .45);
}
.ac-grab {
width: 40px; height: 5px; border-radius: 3px; margin: 0 auto 10px;
background: var(--tmd-border, var(--divider-color, #9aa3ad));
}
.ac-sheet h3 { margin: 0; font-size: 1.1rem; text-align: center; font-family: var(--tmd-font-display, inherit); }
.ac-sheet-sub { text-align: center; font-size: .85rem; margin: 2px 0 8px; color: var(--tmd-dim, var(--secondary-text-color)); }
.ac-bigbid {
display: flex; align-items: center; justify-content: center; gap: 8px;
font-size: 3.2rem; font-weight: 800; line-height: 1;
margin: 8px 0 2px; font-variant-numeric: tabular-nums;
font-family: var(--tmd-font-display, inherit);
}
.ac-bigbid ha-icon { --mdc-icon-size: 38px; color: var(--tmd-gold, #f1c40f); }
.ac-bidlbl { text-align: center; font-size: .8rem; margin-bottom: 8px; color: var(--tmd-dim, var(--secondary-text-color)); }
.ac-range { width: 100%; height: 28px; accent-color: var(--ac-auc); margin: 0; }
.ac-rng-l {
display: flex; justify-content: space-between;
font-size: .72rem; color: var(--tmd-dim, var(--secondary-text-color));
}
.ac-keypad { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin: 12px 0; }
.ac-keypad button {
height: 44px; border-radius: 12px; cursor: pointer;
display: grid; place-items: center;
font: inherit; font-size: 1.2rem; font-weight: 600;
background: var(--tmd-surface-2, var(--secondary-background-color, rgba(127, 127, 127, .12)));
color: var(--tmd-text, var(--primary-text-color));
border: 1px solid var(--tmd-border, transparent);
}
.ac-keypad .ac-key-clear { font-size: .9rem; color: var(--tmd-dim, var(--secondary-text-color)); }
.ac-rules {
font-size: .8rem; line-height: 1.5;
padding: 10px 12px; margin-bottom: 12px; border-radius: 12px;
background: var(--tmd-surface-2, var(--secondary-background-color, rgba(127, 127, 127, .1)));
color: var(--tmd-dim, var(--secondary-text-color));
}
.ac-rules div { display: flex; gap: 8px; align-items: flex-start; }
.ac-rules ha-icon { --mdc-icon-size: 14px; margin-top: 2px; flex: none; color: var(--ac-auc); }
.ac-sheet-acts { display: flex; gap: 10px; }
.ac-sheet-acts .ac-ghost { flex: 1; }
.ac-sheet-acts .ac-seal { flex: 2; }
`;
// The token block is :host-scoped, so it only takes effect when included
// in THIS card's styles — a document-level rule cannot cross into the
// shadow root.
const tokens = window.__taskmate_design && window.__taskmate_design.styles
? window.__taskmate_design.styles() : null;
return tokens ? [tokens, base] : base;
}
}
class TaskMateAuctionCardEditor extends LitElement {
static get properties() {
return { hass: { type: Object }, config: { type: Object } };
}
setConfig(config) { this.config = config; }
_t(key, params) {
const fn = window.__taskmate_localize;
return fn ? fn(this.hass, key, params) : key;
}
_buildSchema() {
const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config?.entity))
|| this.hass?.states?.[this.config?.entity]?.attributes || {};
const children = attrs.children || [];
return [
{ name: "entity", selector: { entity: { domain: "sensor" } } },
{
name: "child_id",
selector: {
select: { options: children.map(c => ({ value: c.id, label: c.name })), mode: "dropdown" },
},
},
{ name: "title", selector: { text: {} } },
{
name: "card_design",
selector: {
select: {
options: window.__taskmate_design
? window.__taskmate_design.editorOptions(this._t.bind(this))
: [{ value: "global", label: "Use global default" }],
mode: "dropdown",
},
},
},
];
}
_computeLabel = (entry) => ({
entity: this._t("common.editor.overview_entity"),
child_id: this._t("auction.editor.child"),
title: this._t("common.editor.card_title"),
card_design: this._t("common.design.field_label"),
}[entry.name] ?? entry.name);
_computeHelper = (entry) => ({
entity: this._t("common.editor.overview_entity_helper"),
child_id: this._t("auction.editor.child_helper"),
}[entry.name] ?? "");
render() {
if (!this.hass || !this.config) return html``;
const data = {
entity: this.config.entity || "",
child_id: this.config.child_id || "",
title: this.config.title || "",
card_design: this.config.card_design || "global",
};
return html`
<ha-form
.hass=${this.hass}
.data=${data}
.schema=${this._buildSchema()}
.computeLabel=${this._computeLabel}
.computeHelper=${this._computeHelper}
@value-changed=${this._formChanged}
></ha-form>
${this._renderColourPicker("header_color", DEFAULT_ACCENT)}
`;
}
_renderColourPicker(key, defaultValue) {
const d = window.__taskmate_design;
const current = this.config[key] || defaultValue;
if (!d || !d.colourPicker) return html``;
return d.colourPicker({
defaultValue, current,
label: this._t("common.editor.header_colour"),
helper: this._t("common.editor.header_colour_helper"),
resetLabel: this._t("common.reset"),
onInput: (v) => this._updateConfig(key, v),
onPreset: (v) => this._updateConfig(key, v),
onReset: () => this._updateConfig(key, defaultValue),
});
}
_formChanged(e) {
const newValues = e.detail.value || {};
const newConfig = { ...this.config };
for (const [key, value] of Object.entries(newValues)) {
// "global" is the absence of a per-card override, not a value to store.
if (value === "" || value === null || value === undefined || (key === "card_design" && value === "global")) {
delete newConfig[key];
} else {
newConfig[key] = value;
}
}
this._fire(newConfig);
}
_updateConfig(key, value) {
const newConfig = { ...this.config, [key]: value };
if (value === null || value === "" || value === undefined) delete newConfig[key];
this._fire(newConfig);
}
_fire(config) {
this.dispatchEvent(new CustomEvent("config-changed", {
detail: { config }, bubbles: true, composed: true,
}));
}
static get styles() {
return css`
:host { display: block; }
ha-form { display: block; margin-bottom: 16px; }
.colour-field { display: flex; flex-direction: column; gap: 8px; padding: 12px 16px; border: 1px solid var(--outline-color, var(--divider-color, #e0e0e0)); border-radius: 4px; background: var(--mdc-text-field-fill-color, var(--card-background-color)); }
.colour-field-label { font-size: 0.82rem; color: var(--primary-color); font-weight: 500; }
.colour-field-body { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.colour-swatch-wrapper { position: relative; width: 36px; height: 36px; border-radius: 50%; overflow: hidden; cursor: pointer; border: 2px solid var(--divider-color, #e0e0e0); flex-shrink: 0; }
.colour-swatch-wrapper input[type="color"] { position: absolute; inset: 0; opacity: 0; cursor: pointer; border: 0; padding: 0; }
.colour-swatch-preview { position: absolute; inset: 0; pointer-events: none; }
.colour-hex { font-family: var(--code-font-family, monospace); font-size: 0.85rem; color: var(--secondary-text-color); min-width: 70px; }
.colour-presets { display: flex; gap: 6px; flex-wrap: wrap; }
.preset-swatch { width: 22px; height: 22px; border-radius: 50%; cursor: pointer; border: 2px solid var(--divider-color, #e0e0e0); transition: transform 0.1s; padding: 0; }
.preset-swatch:hover { transform: scale(1.15); }
.preset-swatch.active { border-color: var(--primary-text-color); box-shadow: 0 0 0 2px var(--primary-color); }
.colour-reset { font-size: 0.78rem; color: var(--secondary-text-color); background: none; border: 1px solid var(--divider-color, #e0e0e0); border-radius: 4px; padding: 4px 10px; cursor: pointer; margin-inline-start: auto; }
.colour-helper { color: var(--secondary-text-color); font-size: 0.82rem; line-height: 1.3; }
`;
}
}
customElements.define("taskmate-auction-card", TaskMateAuctionCard);
customElements.define("taskmate-auction-card-editor", TaskMateAuctionCardEditor);
window.customCards = window.customCards || [];
window.customCards.push({
type: "taskmate-auction-card",
name: "TaskMate Chore Auction",
description: "Children bid the fewest points they'd do an unpopular chore for — lowest bid wins",
preview: true,
getEntitySuggestion: (hass, entityId) =>
window.__taskmate_suggest(hass, entityId, "taskmate-auction-card", "overview"),
});
@@ -21,6 +21,9 @@ const css = LitElement.prototype.css;
const DESIGN_HEADER = "#6c5ce7";
const _safeColor = (c, d) => (typeof c === "string" && /^#[0-9a-fA-F]{3,8}$/.test(c) ? c : d);
// Keeps number runs like "3 / 1" reading left to right in RTL text (#995);
// identity until the design layer has loaded (it is also what stamps dir).
const _ltrNums = (s) => (window.__taskmate_design && window.__taskmate_design.ltrNums ? window.__taskmate_design.ltrNums(s) : s);
class TaskMateBadgesCard extends LitElement {
static get properties() {
@@ -324,7 +327,7 @@ class TaskMateBadgesCard extends LitElement {
.badge-bonus {
position: absolute;
top: -4px;
right: -4px;
inset-inline-end: -4px;
background: linear-gradient(135deg, var(--t), color-mix(in srgb, var(--t), black 15%));
color: #fff;
font-size: 0.65rem;
@@ -374,7 +377,7 @@ class TaskMateBadgesCard extends LitElement {
.bd-bonus {
position: absolute;
top: 6px;
right: 8px;
inset-inline-end: 8px;
font-size: 10px;
font-weight: 800;
color: var(--tmd-good);
@@ -506,8 +509,8 @@ class TaskMateBadgesCard extends LitElement {
const latestEarned = earned.length > 0 ? earned[0] : null;
const countLabel = earned.length > 0 && latestEarned
? this._t("badges.count_label_latest", { earned: earned.length, total: totalBadges, latest: latestEarned.name })
: this._t("badges.count_label", { earned: earned.length, total: totalBadges });
? _ltrNums(this._t("badges.count_label_latest", { earned: earned.length, total: totalBadges, latest: latestEarned.name }))
: _ltrNums(this._t("badges.count_label", { earned: earned.length, total: totalBadges }));
const title = this.config.title || (childName ? this._t("badges.title_with_name", { name: childName }) : this._t("badges.default_title"));
@@ -561,7 +564,7 @@ class TaskMateBadgesCard extends LitElement {
<ha-icon icon="${b.icon || 'mdi:medal'}"></ha-icon>
</div>
<div class="badge-tier">${this._tierLabel(b.tier)}</div>
<div class="badge-name">${b.name}</div>
<div class="badge-name">${_ltrNums(b.name)}</div>
<div class="badge-meta">${this._t("badge.earned_date", { date: this._formatDate(b.earned_at) })}</div>
</div>
`;
@@ -571,8 +574,8 @@ class TaskMateBadgesCard extends LitElement {
// progress: either provided directly, or computed from closest_criterion
const pct = (b.progress_pct != null) ? b.progress_pct
: (b.closest_criterion ? Math.min(100, Math.round((b.closest_criterion.current / b.closest_criterion.target) * 100)) : 0);
const progressLabel = b.progress_label
|| (b.closest_criterion ? `${b.closest_criterion.current} / ${b.closest_criterion.target}` : null);
const crit = b.closest_criterion;
const progressLabel = _ltrNums(b.progress_label || (crit ? `${crit.current} / ${crit.target}` : null));
return html`
<div class="badge locked tier-${b.tier}">
@@ -581,7 +584,7 @@ class TaskMateBadgesCard extends LitElement {
<ha-icon icon="${b.icon || 'mdi:medal-outline'}"></ha-icon>
</div>
<div class="badge-tier">${this._tierLabel(b.tier)}</div>
<div class="badge-name">${b.name}</div>
<div class="badge-name">${_ltrNums(b.name)}</div>
${progressLabel ? html`
<div class="badge-progress">
<div class="badge-progress-fill" style="width:${pct}%"></div>
@@ -626,7 +629,7 @@ TaskMateBadgesCard.prototype._designEarnedTile = function (b) {
<div class="bd-tile earned" style="--bt:${tone}">
${b.point_bonus > 0 ? html`<div class="bd-bonus">+${b.point_bonus}</div>` : ""}
<div class="bd-ic earned" style="--bt:${tone}">${icon}</div>
<div class="bd-name">${b.name}</div>
<div class="bd-name">${_ltrNums(b.name)}</div>
<div class="muted bd-date">${this._formatDate(b.earned_at)}</div>
</div>`;
};
@@ -635,15 +638,15 @@ TaskMateBadgesCard.prototype._designLockedTile = function (b) {
const tone = this._tierTone(b.tier);
const pct = (b.progress_pct != null) ? b.progress_pct
: (b.closest_criterion ? Math.min(100, Math.round((b.closest_criterion.current / b.closest_criterion.target) * 100)) : 0);
const progressLabel = b.progress_label
|| (b.closest_criterion ? `${b.closest_criterion.current} / ${b.closest_criterion.target}` : null);
const crit = b.closest_criterion;
const progressLabel = _ltrNums(b.progress_label || (crit ? `${crit.current} / ${crit.target}` : null));
const a = b.icon || "mdi:medal-outline";
const icon = a.startsWith("mdi:") ? html`<ha-icon icon="${a}"></ha-icon>` : a;
return html`
<div class="bd-tile locked" style="--bt:${tone}">
${b.point_bonus > 0 ? html`<div class="bd-bonus" style="opacity:0.45">+${b.point_bonus}</div>` : ""}
<div class="bd-ic locked">${icon}</div>
<div class="bd-name">${b.name}</div>
<div class="bd-name">${_ltrNums(b.name)}</div>
${progressLabel ? html`
<div class="bar" style="height:7px;margin-top:6px"><i style="width:${pct}%"></i></div>
<div class="muted bd-prog-label">${progressLabel}</div>` : ""}
@@ -654,7 +657,7 @@ TaskMateBadgesCard.prototype._renderDesigned = function (design, d) {
const hd = _safeColor(this.config.header_color, DESIGN_HEADER);
const ic = design === "console" ? "✦" : design === "cleanpro" ? "◈" : "🎖️";
const sub = d.childName;
const countPill = this._t("badges.count_label", { earned: d.earned.length, total: d.totalBadges });
const countPill = _ltrNums(this._t("badges.count_label", { earned: d.earned.length, total: d.totalBadges }));
return html`
<ha-card class="tmd" style="--hd:${hd}">
@@ -666,7 +669,7 @@ TaskMateBadgesCard.prototype._renderDesigned = function (design, d) {
<div class="tmd-bd">
${this._designFilters(d.filter)}
${(d.filteredEarned.length + d.filteredLocked.length) === 0
? html`<div class="tmd-empty">${this._t("badges.count_label", { earned: 0, total: d.totalBadges })}</div>`
? html`<div class="tmd-empty">${_ltrNums(this._t("badges.count_label", { earned: 0, total: d.totalBadges }))}</div>`
: html`<div class="bd-grid">
${d.filteredEarned.map((b) => this._designEarnedTile(b))}
${d.filteredLocked.map((b) => this._designLockedTile(b))}
@@ -0,0 +1,804 @@
/**
* TaskMate Bounty Board Card (#931)
*
* One-off jobs a parent posts for a fixed number of points. The child this
* card belongs to sees the bounties they may claim, claims one (it's theirs
* for the claim lock), marks it done — with a photo when the bounty asks for
* one — and it goes through the normal approval queue. A sibling's claim shows
* dimmed with the time until it returns to the board; bounties the child can't
* claim are hidden.
*
* Reads the `bounties` attribute of sensor.taskmate_bounties through the
* attribute resolver, and `children` from the overview entity.
*
* Single layout, so it consumes the design tokens directly (like the routine
* card) rather than carrying a second _renderDesigned() template. Every
* --tmd-* reference has the card's original value as its fallback, so classic
* — which defines no tokens — keeps the wireframe look. Layout classes carry a
* bb- prefix: the shared design kit defines its own .btn / .bar / .av / .chip.
*/
const LitElement = customElements.get("hui-masonry-view")
? Object.getPrototypeOf(customElements.get("hui-masonry-view"))
: Object.getPrototypeOf(customElements.get("hui-view"));
const html = LitElement.prototype.html;
const css = LitElement.prototype.css;
const _safeColor = (c, d) => (typeof c === "string" && /^#[0-9a-fA-F]{3,8}$/.test(c) ? c : d);
const DEFAULT_ACCENT = "#e67e22";
const BOUNTIES_ENTITY = "sensor.taskmate_bounties";
// Child avatar colours by position; a design's own palette wins when it has one.
const CHILD_COLOURS = ["#ff6b9d", "#3498db", "#1abc9c", "#e67e22", "#9b59b6", "#2ecc71"];
class TaskMateBountyCard extends LitElement {
static get properties() {
return {
hass: { type: Object },
config: { type: Object },
_dialog: { type: Object },
_busy: { type: Boolean },
};
}
constructor() {
super();
this._dialog = null;
this._busy = false;
this._tick = null;
}
setConfig(config) {
if (!config || !config.entity) throw new Error("You need to define an entity");
if (!config.child_id) throw new Error("You need to define a child_id");
this.config = config;
}
static getStubConfig() {
return { entity: "sensor.taskmate_overview", child_id: "" };
}
static getConfigElement() {
return document.createElement("taskmate-bounty-card-editor");
}
getCardSize() {
return 6;
}
connectedCallback() {
super.connectedCallback();
// Countdowns tick client-side from the server's timestamps. Re-render once
// a second only while something on the board is actually counting down.
this._tick = setInterval(() => {
if (this._hasCountdown()) this.requestUpdate();
}, 1000);
}
disconnectedCallback() {
super.disconnectedCallback();
clearInterval(this._tick);
this._tick = null;
}
shouldUpdate(changedProps) {
if (changedProps.has("hass") && changedProps.size === 1) {
return window.__taskmate_hasChanged
? window.__taskmate_hasChanged(changedProps.get("hass"), this.hass, this.config?.entity)
: true;
}
return true;
}
_t(key, params) {
const fn = window.__taskmate_localize;
return fn ? fn(this.hass, key, params) : key;
}
_attrs() {
return (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity))
|| this.hass?.states?.[this.config.entity]?.attributes || {};
}
_bounties() {
const attrs = this._attrs();
const list = attrs.bounties || this.hass?.states?.[BOUNTIES_ENTITY]?.attributes?.bounties;
return Array.isArray(list) ? list : [];
}
_children() {
return this._attrs().children || [];
}
_child() {
return this._children().find(c => String(c.id) === String(this.config.child_id));
}
_childName(id) {
const c = this._children().find(ch => String(ch.id) === String(id));
return c ? c.name : "?";
}
_eligible(b, childId) {
return !Array.isArray(b.eligible) || b.eligible.length === 0 || b.eligible.map(String).includes(String(childId));
}
_hasCountdown() {
return this._bounties().some(b => b.status === "claimed" || (b.status === "open" && b.expires_at));
}
/**
* Resolve the design, stamp data-tm-design on the host (so the :host-scoped
* token block applies inside this shadow root) and settle the header colour.
* The colour is an inline host property: a <style> in the template is
* ordered before adoptedStyleSheets and would lose to the :host default.
*/
_applyDesign() {
const design = window.__taskmate_design
? window.__taskmate_design.apply(this, this.hass, this.config, this.config.entity)
: "classic";
const configured = this.config.header_color;
if (typeof configured === "string" && /^#[0-9a-fA-F]{3,8}$/.test(configured)) {
this.style.setProperty("--bb-accent", _safeColor(configured, DEFAULT_ACCENT));
} else {
this.style.removeProperty("--bb-accent");
}
return design;
}
_fmtDur(ms) {
const sec = Math.max(0, Math.round(ms / 1000));
const d = Math.floor(sec / 86400);
const h = Math.floor((sec % 86400) / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = sec % 60;
if (d) return this._t("bounty.dur_days", { d, h });
if (h) return this._t("bounty.dur_hours", { h, m: String(m).padStart(2, "0") });
return this._t("bounty.dur_minutes", { m, s: String(s).padStart(2, "0") });
}
_msUntil(iso, now) {
const t = Date.parse(iso || "");
return Number.isFinite(t) ? t - now : null;
}
_avatar(childId, size = 22) {
const children = this._children();
const idx = Math.max(0, children.findIndex(c => String(c.id) === String(childId)));
const name = this._childName(childId);
const colour = `var(--tmd-c${(idx % 6) + 1}, ${CHILD_COLOURS[idx % CHILD_COLOURS.length]})`;
return html`<span class="bb-av" style="--bb-ac:${colour};--bb-s:${size}px" title="${name}">${name.charAt(0).toUpperCase()}</span>`;
}
// ── actions ─────────────────────────────────────────────────────────────
async _call(service, data) {
if (this._busy) return false;
this._busy = true;
try {
await this.hass.callService("taskmate", service, data);
return true;
} catch (err) {
this.dispatchEvent(new CustomEvent("hass-notification", {
detail: { message: String(err?.message || err) }, bubbles: true, composed: true,
}));
return false;
} finally {
this._busy = false;
this.requestUpdate();
}
}
async _claim(b) {
const ok = await this._call("claim_bounty", { bounty_id: b.id, child_id: this.config.child_id });
if (ok) this._dialog = null;
}
async _giveBack(b) {
await this._call("give_back_bounty", { bounty_id: b.id, child_id: this.config.child_id });
}
async _done(b) {
if (b.require_photo) {
this._dialog = { kind: "photo", id: b.id };
return;
}
const ok = await this._call("complete_bounty", { bounty_id: b.id, child_id: this.config.child_id });
if (ok) this._dialog = { kind: "yay", id: b.id };
}
async _undo(b) {
await this._call("undo_chore", { completion_id: b.completion_id });
}
_pickPhoto() {
const input = this.renderRoot && this.renderRoot.querySelector("#bb-photo");
if (input) input.click();
}
/** Shrink the picked photo to a ≤1280px JPEG, upload it, then submit. */
async _photoPicked(e, b) {
const file = e.target.files && e.target.files[0];
e.target.value = "";
if (!file) return;
this._dialog = { kind: "photo", id: b.id, uploading: true };
try {
const blob = await this._shrink(file);
const photoUrl = await this._upload(blob);
this._dialog = null;
const ok = await this._call("complete_bounty", {
bounty_id: b.id, child_id: this.config.child_id, photo_url: photoUrl,
});
if (ok) this._dialog = { kind: "yay", id: b.id };
} catch (_err) {
this._dialog = { kind: "photo", id: b.id, error: this._t("child.photo_upload_failed") };
}
}
_shrink(file) {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
const max = 1280;
let { width, height } = img;
if (width > max || height > max) {
const scale = Math.min(max / width, max / height);
width = Math.round(width * scale);
height = Math.round(height * scale);
}
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
canvas.getContext("2d").drawImage(img, 0, 0, width, height);
URL.revokeObjectURL(url);
canvas.toBlob(blob => (blob ? resolve(blob) : reject(new Error("no blob"))), "image/jpeg", 0.8);
};
img.onerror = () => { URL.revokeObjectURL(url); reject(new Error("bad image")); };
img.src = url;
});
}
async _upload(blob) {
// Bearer-authenticated POST, refreshing a stale access token once on 401 —
// the same upload the child card's photo chores use.
const post = () => {
const token = this.hass?.auth?.data?.access_token;
const fd = new FormData();
fd.append("file", blob, "evidence.jpg");
return fetch("/api/taskmate/photo", {
method: "POST", headers: token ? { Authorization: `Bearer ${token}` } : {}, body: fd,
});
};
if (this.hass?.auth?.expired && this.hass.auth.refreshAccessToken) {
try { await this.hass.auth.refreshAccessToken(); } catch (_e) { /* retried below */ }
}
let resp = await post();
if (resp.status === 401 && this.hass?.auth?.refreshAccessToken) {
await this.hass.auth.refreshAccessToken();
resp = await post();
}
if (!resp.ok) throw new Error(`upload failed (${resp.status})`);
const { photo_url: photoUrl } = await resp.json();
if (!photoUrl) throw new Error("no photo_url");
return photoUrl;
}
// ── render ──────────────────────────────────────────────────────────────
render() {
if (!this.hass || !this.config) return html``;
this._applyDesign();
const child = this._child();
const title = this.config.title || this._t("bounty.title");
if (!child) {
return html`<ha-card>${this._header(title, null)}<div class="bb-empty">${this._t("bounty.no_child")}</div></ha-card>`;
}
const me = String(child.id);
const now = Date.now();
const all = this._bounties();
const myClaim = all.find(b => b.status === "claimed" && String(b.claimed_by) === me);
// Hidden unless the child may claim it — or it's theirs.
const visible = all.filter(b => this._eligible(b, me) || String(b.claimed_by || "") === me);
const active = visible.filter(b => b.status !== "completed");
const recent = visible.filter(b => b.status === "completed");
const openCount = visible.filter(b => b.status === "open").length;
const dialogBounty = this._dialog && all.find(b => b.id === this._dialog.id);
return html`
<ha-card>
${this._header(title, openCount)}
<div class="bb-content">
${active.length
? active.map(b => this._row(b, me, now, !!myClaim))
: html`<div class="bb-empty">${this._t("bounty.empty")}</div>`}
${recent.length ? html`
<div class="bb-sec">${this._t("bounty.recent")}</div>
${recent.map(b => this._row(b, me, now, !!myClaim))}
` : ""}
</div>
${dialogBounty ? this._renderDialog(dialogBounty) : ""}
</ha-card>
`;
}
_header(title, openCount) {
return html`
<div class="bb-hd">
<ha-icon class="bb-hd-icon" icon="mdi:flag-outline"></ha-icon>
<div class="bb-hd-text">
<div class="bb-hd-title">${title}</div>
<div class="bb-hd-sub">${this._t("bounty.subtitle")}</div>
</div>
${openCount !== null ? html`<span class="bb-hd-pill">${this._t("bounty.open_count", { count: openCount })}</span>` : ""}
</div>
`;
}
_row(b, me, now, holdingClaim) {
const mine = String(b.claimed_by || "") === me;
const eligibleIds = Array.isArray(b.eligible) && b.eligible.length ? b.eligible : this._children().map(c => c.id);
const expLeft = b.expires_at ? this._msUntil(b.expires_at, now) : null;
const showMeta = b.status === "open" || b.status === "claimed";
let cls = "";
let body = "";
if (b.status === "open") {
body = html`
<div class="bb-act">
<button class="bb-btn bb-claim" ?disabled=${holdingClaim || this._busy}
@click=${() => { this._dialog = { kind: "claim", id: b.id }; }}>
<ha-icon icon="mdi:hand-back-right-outline"></ha-icon>${this._t("bounty.claim")}
</button>
<span class="bb-hint">${holdingClaim
? this._t("bounty.finish_first")
: this._t("bounty.claim_hint", { hours: b.claim_hours })}</span>
</div>`;
} else if (b.status === "claimed" && mine) {
cls = "mine";
const left = this._msUntil(b.claim_until, now) ?? 0;
const span = (Date.parse(b.claim_until || "") - Date.parse(b.claimed_at || "")) || (b.claim_hours * 3600e3);
const frac = Math.max(0, Math.min(1, left / span));
body = html`
<div class="bb-claim-line"><ha-icon icon="mdi:timer-sand"></ha-icon>${this._t("bounty.claimed_by_you", { time: this._fmtDur(left) })}</div>
<div class="bb-lock"><i style="width:${(frac * 100).toFixed(1)}%"></i></div>
<div class="bb-act">
<button class="bb-btn bb-done" ?disabled=${this._busy} @click=${() => this._done(b)}>
<ha-icon icon="mdi:check"></ha-icon>${this._t("bounty.done")}
</button>
<button class="bb-btn bb-text" ?disabled=${this._busy} @click=${() => this._giveBack(b)}>${this._t("bounty.give_back")}</button>
</div>`;
} else if (b.status === "claimed") {
cls = "locked";
const left = this._msUntil(b.claim_until, now) ?? 0;
body = html`
<div class="bb-sib">${this._avatar(b.claimed_by, 20)}
<span><b>${this._t("bounty.sibling_on_it", { name: this._childName(b.claimed_by) })}</b> — ${this._t("bounty.sibling_back_in", { time: this._fmtDur(left) })}</span>
</div>`;
} else if (b.status === "pending" && mine) {
body = html`
<div class="bb-act">
<span class="bb-tag warn bb-wait"><ha-icon icon="mdi:timer-sand"></ha-icon>${this._t("bounty.waiting", { points: b.points })}</span>
${b.undo && b.completion_id ? html`
<button class="bb-btn bb-text" ?disabled=${this._busy} @click=${() => this._undo(b)}>
<ha-icon class="tm-rtl-flip" icon="mdi:undo-variant"></ha-icon>${this._t("bounty.undo")}
</button>` : ""}
</div>`;
} else if (b.status === "pending") {
cls = "locked";
body = html`<div class="bb-sib">${this._avatar(b.claimed_by, 20)}<span>${this._t("bounty.sibling_checking", { name: this._childName(b.claimed_by) })}</span></div>`;
} else if (b.status === "completed") {
body = mine
? html`<div class="bb-act"><span class="bb-tag good"><ha-icon icon="mdi:check"></ha-icon>${this._t("bounty.you_earned", { points: b.points_awarded ?? b.points })}</span></div>`
: html`<div class="bb-sib">${this._avatar(b.claimed_by, 20)}<span>${this._t("bounty.done_by", { name: this._childName(b.claimed_by) })}</span></div>`;
}
return html`
<div class="bb-row ${cls}">
<div class="bb-cost"><b>${b.points}</b><small>★ ${this._t("bounty.pts")}</small></div>
<div class="bb-main">
<div class="bb-title-row">
<span class="bb-ic"><ha-icon icon="${b.icon || "mdi:flag-outline"}"></ha-icon></span>
<div class="bb-title">${b.title}</div>
</div>
${b.description ? html`<div class="bb-desc">${b.description}</div>` : ""}
<div class="bb-meta">
${showMeta ? (expLeft !== null
? html`<span class="bb-tag ${expLeft < 6 * 3600e3 ? "warn" : ""}"><ha-icon icon="mdi:clock-outline"></ha-icon>${this._t("bounty.left", { time: this._fmtDur(expLeft) })}</span>`
: html`<span class="bb-tag"><ha-icon icon="mdi:clock-outline"></ha-icon>${this._t("bounty.no_expiry")}</span>`) : ""}
<span class="bb-stack">${eligibleIds.map(id => this._avatar(id, 22))}</span>
${b.require_photo ? html`<span class="bb-tag purple"><ha-icon icon="mdi:camera-outline"></ha-icon>${this._t("bounty.photo_tag")}</span>` : ""}
</div>
${body}
</div>
</div>
`;
}
_renderDialog(b) {
const d = this._dialog;
const close = () => { this._dialog = null; };
let inner = "";
if (d.kind === "claim") {
inner = html`
<div class="bb-dlg" style="--bb-dlg:var(--bb-accent)">
<div class="bb-big-ic"><ha-icon icon="${b.icon || "mdi:flag-outline"}"></ha-icon></div>
<h3>${this._t("bounty.claim_title", { title: b.title })}</h3>
<p>${this._t("bounty.claim_body", { hours: b.claim_hours })}</p>
<div class="bb-dlg-acts">
<button class="bb-btn bb-ghost" @click=${close}>${this._t("bounty.not_now")}</button>
<button class="bb-btn bb-claim" ?disabled=${this._busy} @click=${() => this._claim(b)}>${this._t("bounty.claim_it", { points: b.points })}</button>
</div>
</div>`;
} else if (d.kind === "photo") {
inner = html`
<div class="bb-dlg" style="--bb-dlg:#9b59b6">
<h3>${this._t("bounty.photo_title")}</h3>
<p>${this._t("bounty.photo_body", { title: b.title })}</p>
<div class="bb-photo-ph"><ha-icon icon="mdi:camera-outline"></ha-icon></div>
${d.error ? html`<p class="bb-err">${d.error}</p>` : ""}
<input id="bb-photo" type="file" accept="image/*" capture="environment" hidden
@change=${e => this._photoPicked(e, b)}>
<div class="bb-dlg-acts">
<button class="bb-btn bb-ghost" @click=${close}>${this._t("bounty.cancel")}</button>
<button class="bb-btn bb-done" ?disabled=${!!d.uploading || this._busy} @click=${() => this._pickPhoto()}>
<ha-icon icon="mdi:camera-outline"></ha-icon>${d.uploading ? this._t("bounty.uploading") : this._t("bounty.take_photo")}
</button>
</div>
</div>`;
} else if (d.kind === "yay") {
inner = html`
<div class="bb-dlg" style="--bb-dlg:var(--tmd-good, #2ecc71)">
<div class="bb-big-ic"><ha-icon icon="mdi:check"></ha-icon></div>
<h3>${this._t("bounty.yay_title")}</h3>
<p>${this._t("bounty.yay_body", { title: b.title, points: b.points })}</p>
<div class="bb-dlg-acts"><button class="bb-btn bb-done" @click=${close}>${this._t("bounty.ok")}</button></div>
</div>`;
}
return html`<div class="bb-scrim" @click=${e => { if (e.target === e.currentTarget) close(); }}>${inner}</div>`;
}
static get styles() {
const base = css`
:host { display: block; --bb-accent: var(--tmd-accent, #e67e22); }
ha-card {
position: relative;
overflow: hidden;
background: var(--tmd-surface, var(--ha-card-background, var(--card-background-color, #fff)));
color: var(--tmd-text, var(--primary-text-color));
border-radius: var(--tmd-radius, var(--ha-card-border-radius, 12px));
font-family: var(--tmd-font-body, inherit);
}
.bb-hd {
display: flex; align-items: center; gap: 12px;
padding: 14px 18px;
background: var(--bb-accent);
color: var(--tmd-hd-text, #fff);
}
:host([data-tm-design="playroom"]) .bb-hd {
background: linear-gradient(135deg, var(--bb-accent), color-mix(in srgb, var(--bb-accent) 72%, #fff));
}
.bb-hd-icon { --mdc-icon-size: 30px; flex: none; opacity: .95; }
.bb-hd-text { flex: 1; min-width: 0; }
.bb-hd-title {
font-family: var(--tmd-font-display, inherit);
font-size: 1.3rem; font-weight: 600;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.bb-hd-sub { font-size: .8rem; opacity: .85; margin-top: 1px; }
.bb-hd-pill {
flex: none; white-space: nowrap;
background: rgba(255, 255, 255, .2);
padding: 4px 12px; border-radius: 16px;
font-size: .9rem; font-weight: 500;
}
.bb-content { padding: 16px; display: flex; flex-direction: column; gap: 12px; }
.bb-empty {
text-align: center; padding: 30px 12px;
color: var(--tmd-dim, var(--secondary-text-color));
}
.bb-sec {
font-size: .8rem; font-weight: 700; letter-spacing: .06em; text-transform: uppercase;
color: var(--tmd-dim, var(--secondary-text-color));
margin: 4px 2px -4px;
}
.bb-row {
display: flex; gap: 14px; align-items: flex-start;
padding: 14px;
background: var(--tmd-surface, var(--ha-card-background, var(--card-background-color, #fff)));
border: 1px solid var(--tmd-border, var(--divider-color, #e0e0e0));
border-radius: var(--tmd-radius-sm, 14px);
}
.bb-row.mine {
border: 2px solid var(--tmd-warn, #e67e22);
background: color-mix(in srgb, var(--tmd-warn, #e67e22) 8%, var(--tmd-surface, var(--ha-card-background, var(--card-background-color, #fff))));
}
.bb-row.locked { opacity: .62; }
.bb-cost {
flex: none; min-width: 66px; padding: 10px 6px;
display: flex; flex-direction: column; align-items: center; justify-content: center;
border-radius: var(--tmd-radius-sm, 12px);
background: linear-gradient(135deg, var(--tmd-gold, #f1c40f), color-mix(in srgb, var(--tmd-gold, #f39c12) 80%, #e67e22));
color: #fff;
box-shadow: 0 2px 8px rgba(241, 196, 15, .25);
}
.bb-cost b {
font-family: var(--tmd-font-display, inherit);
font-size: 1.35rem; line-height: 1; text-shadow: 0 1px 2px rgba(0, 0, 0, .2);
}
.bb-cost small { font-size: .62rem; font-weight: 700; letter-spacing: .5px; margin-top: 3px; opacity: .95; }
:host([data-tm-design="accessible"]) .bb-cost { color: #111; box-shadow: none; border: 2px solid var(--tmd-border, #111); }
:host([data-tm-design="accessible"]) .bb-cost b { text-shadow: none; }
.bb-main { flex: 1; min-width: 0; }
.bb-title-row { display: flex; align-items: center; gap: 10px; }
.bb-ic {
flex: none; width: 30px; height: 30px; border-radius: 9px;
display: grid; place-items: center;
background: var(--tmd-surface-2, var(--secondary-background-color, rgba(127, 127, 127, .1)));
color: var(--bb-accent);
}
.bb-ic ha-icon { --mdc-icon-size: 18px; }
.bb-title {
flex: 1; min-width: 0;
font-family: var(--tmd-font-display, inherit);
font-size: 1.1rem; font-weight: 700; line-height: 1.25;
overflow-wrap: anywhere;
}
.bb-desc { font-size: .88rem; color: var(--tmd-dim, var(--secondary-text-color)); margin-top: 4px; }
.bb-meta { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-top: 6px; }
.bb-tag {
display: inline-flex; align-items: center; gap: 4px;
font-size: .72rem; font-weight: 700;
padding: 2px 9px; border-radius: 999px; white-space: nowrap;
background: var(--tmd-surface-2, rgba(127, 127, 127, .12));
color: var(--tmd-dim, var(--secondary-text-color));
border: 1px solid var(--tmd-border, transparent);
}
.bb-tag ha-icon { --mdc-icon-size: 13px; }
.bb-tag.warn { background: color-mix(in srgb, var(--tmd-warn, #f39c12) 16%, transparent); color: var(--tmd-warn, #d68910); }
.bb-tag.good { background: color-mix(in srgb, var(--tmd-good, #2ecc71) 16%, transparent); color: var(--tmd-good, #239b56); font-size: .8rem; padding: 4px 10px; }
.bb-tag.purple { background: rgba(155, 89, 182, .18); color: #8e44ad; }
.bb-tag.bb-wait { font-size: .8rem; padding: 5px 10px; white-space: normal; border-radius: 10px; }
:host([data-tm-dark]) .bb-tag.purple { color: #c39bd3; }
.bb-stack { display: inline-flex; }
.bb-stack .bb-av + .bb-av { margin-inline-start: -8px; }
.bb-av {
width: var(--bb-s, 22px); height: var(--bb-s, 22px); flex: none;
border-radius: 50%;
background: var(--bb-ac, #9b59b6); color: #fff;
display: inline-grid; place-items: center;
font-weight: 700; font-size: calc(var(--bb-s, 22px) * .45); line-height: 1;
border: 2px solid var(--tmd-surface, var(--ha-card-background, var(--card-background-color, #fff)));
box-sizing: border-box;
}
.bb-act { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-top: 10px; }
.bb-hint { font-size: 12px; color: var(--tmd-dim, var(--secondary-text-color)); }
.bb-claim-line {
display: flex; align-items: center; gap: 6px; margin-top: 8px;
font-size: .9rem; font-weight: 600; color: var(--tmd-warn, #d68910);
}
.bb-claim-line ha-icon { --mdc-icon-size: 16px; }
.bb-lock {
height: 6px; margin-top: 8px; overflow: hidden; border-radius: 3px;
background: var(--tmd-surface-2, var(--divider-color, #e5e7eb));
border: 1px solid var(--tmd-border, transparent);
box-sizing: border-box;
}
.bb-lock > i { display: block; height: 100%; background: var(--tmd-warn, #e67e22); transition: width .9s linear; }
.bb-sib {
display: flex; align-items: flex-start; gap: 8px; margin-top: 8px;
font-size: .88rem; line-height: 1.35;
color: var(--tmd-dim, var(--secondary-text-color));
}
.bb-sib b { color: var(--tmd-text, var(--primary-text-color)); }
.bb-btn {
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
border: 0; cursor: pointer;
border-radius: var(--tmd-radius-sm, 10px);
padding: 9px 16px; min-height: 36px;
font: inherit; font-family: var(--tmd-font-display, inherit);
font-size: .92rem; font-weight: 600; color: #fff;
}
.bb-btn ha-icon { --mdc-icon-size: 17px; }
.bb-btn[disabled] { opacity: .45; cursor: default; }
.bb-claim { background: var(--tmd-warn, #e67e22); padding: 6px 12px; font-size: .85rem; }
:host([data-tm-design]:not([data-tm-design="classic"])) .bb-claim { color: #2b1700; }
.bb-done { background: var(--tmd-good, linear-gradient(135deg, #2ecc71, #27ae60)); }
:host([data-tm-design]:not([data-tm-design="classic"])) .bb-done { color: #06301f; }
.bb-text { background: transparent; color: var(--tmd-dim, var(--secondary-text-color)); padding: 9px 8px; }
.bb-ghost {
background: transparent; color: var(--tmd-text, var(--primary-text-color));
border: 1px solid var(--tmd-border, var(--divider-color, #ccc));
}
.bb-scrim {
position: absolute; inset: 0; z-index: 5;
display: flex; align-items: center; justify-content: center;
padding: 16px; background: rgba(0, 0, 0, .55);
border-radius: inherit;
}
.bb-dlg {
width: 100%; max-width: 340px; box-sizing: border-box;
padding: 22px; text-align: center;
background: var(--tmd-surface, var(--ha-card-background, var(--card-background-color, #fff)));
color: var(--tmd-text, var(--primary-text-color));
border-radius: 20px;
border: 1px solid var(--tmd-border, transparent);
box-shadow: 0 12px 40px rgba(0, 0, 0, .5);
}
.bb-dlg h3 { margin: 0 0 6px; font-size: 1.2rem; font-family: var(--tmd-font-display, inherit); }
.bb-dlg p { margin: 0 0 16px; color: var(--tmd-dim, var(--secondary-text-color)); }
.bb-big-ic {
width: 64px; height: 64px; margin: 0 auto 10px; border-radius: 50%;
display: grid; place-items: center;
background: color-mix(in srgb, var(--bb-dlg, #e67e22) 22%, transparent);
color: var(--bb-dlg, #e67e22);
}
.bb-big-ic ha-icon { --mdc-icon-size: 34px; }
.bb-photo-ph {
height: 130px; margin-bottom: 14px; border-radius: 14px;
display: grid; place-items: center;
background: repeating-linear-gradient(45deg, var(--tmd-surface-2, rgba(127, 127, 127, .14)) 0 12px, transparent 12px 24px);
color: var(--tmd-dim, var(--secondary-text-color));
}
.bb-photo-ph ha-icon { --mdc-icon-size: 40px; }
.bb-err { color: var(--tmd-bad, var(--error-color, #c62828)) !important; }
.bb-dlg-acts { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; }
`;
// The token block is :host-scoped, so it only takes effect when included
// in THIS card's styles — a document-level rule cannot cross into the
// shadow root.
const tokens = window.__taskmate_design && window.__taskmate_design.styles
? window.__taskmate_design.styles() : null;
return tokens ? [tokens, base] : base;
}
}
class TaskMateBountyCardEditor extends LitElement {
static get properties() {
return { hass: { type: Object }, config: { type: Object } };
}
setConfig(config) { this.config = config; }
_t(key, params) {
const fn = window.__taskmate_localize;
return fn ? fn(this.hass, key, params) : key;
}
_buildSchema() {
const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config?.entity))
|| this.hass?.states?.[this.config?.entity]?.attributes || {};
const children = attrs.children || [];
return [
{ name: "entity", selector: { entity: { domain: "sensor" } } },
{
name: "child_id",
selector: {
select: { options: children.map(c => ({ value: c.id, label: c.name })), mode: "dropdown" },
},
},
{ name: "title", selector: { text: {} } },
{
name: "card_design",
selector: {
select: {
options: window.__taskmate_design
? window.__taskmate_design.editorOptions(this._t.bind(this))
: [{ value: "global", label: "Use global default" }],
mode: "dropdown",
},
},
},
];
}
_computeLabel = (entry) => ({
entity: this._t("common.editor.overview_entity"),
child_id: this._t("bounty.editor.child"),
title: this._t("common.editor.card_title"),
card_design: this._t("common.design.field_label"),
}[entry.name] ?? entry.name);
_computeHelper = (entry) => ({
entity: this._t("common.editor.overview_entity_helper"),
child_id: this._t("bounty.editor.child_helper"),
}[entry.name] ?? "");
render() {
if (!this.hass || !this.config) return html``;
const data = {
entity: this.config.entity || "",
child_id: this.config.child_id || "",
title: this.config.title || "",
card_design: this.config.card_design || "global",
};
return html`
<ha-form
.hass=${this.hass}
.data=${data}
.schema=${this._buildSchema()}
.computeLabel=${this._computeLabel}
.computeHelper=${this._computeHelper}
@value-changed=${this._formChanged}
></ha-form>
${this._renderColourPicker("header_color", DEFAULT_ACCENT)}
`;
}
_renderColourPicker(key, defaultValue) {
const d = window.__taskmate_design;
const current = this.config[key] || defaultValue;
if (!d || !d.colourPicker) return html``;
return d.colourPicker({
defaultValue, current,
label: this._t("common.editor.header_colour"),
helper: this._t("common.editor.header_colour_helper"),
resetLabel: this._t("common.reset"),
onInput: (v) => this._updateConfig(key, v),
onPreset: (v) => this._updateConfig(key, v),
onReset: () => this._updateConfig(key, defaultValue),
});
}
_formChanged(e) {
const newValues = e.detail.value || {};
const newConfig = { ...this.config };
for (const [key, value] of Object.entries(newValues)) {
// "global" is the absence of a per-card override, not a value to store.
if (value === "" || value === null || value === undefined || (key === "card_design" && value === "global")) {
delete newConfig[key];
} else {
newConfig[key] = value;
}
}
this._fire(newConfig);
}
_updateConfig(key, value) {
const newConfig = { ...this.config, [key]: value };
if (value === null || value === "" || value === undefined) delete newConfig[key];
this._fire(newConfig);
}
_fire(config) {
this.dispatchEvent(new CustomEvent("config-changed", {
detail: { config }, bubbles: true, composed: true,
}));
}
static get styles() {
return css`
:host { display: block; }
ha-form { display: block; margin-bottom: 16px; }
.colour-field { display: flex; flex-direction: column; gap: 8px; padding: 12px 16px; border: 1px solid var(--outline-color, var(--divider-color, #e0e0e0)); border-radius: 4px; background: var(--mdc-text-field-fill-color, var(--card-background-color)); }
.colour-field-label { font-size: 0.82rem; color: var(--primary-color); font-weight: 500; }
.colour-field-body { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.colour-swatch-wrapper { position: relative; width: 36px; height: 36px; border-radius: 50%; overflow: hidden; cursor: pointer; border: 2px solid var(--divider-color, #e0e0e0); flex-shrink: 0; }
.colour-swatch-wrapper input[type="color"] { position: absolute; inset: 0; opacity: 0; cursor: pointer; border: 0; padding: 0; }
.colour-swatch-preview { position: absolute; inset: 0; pointer-events: none; }
.colour-hex { font-family: var(--code-font-family, monospace); font-size: 0.85rem; color: var(--secondary-text-color); min-width: 70px; }
.colour-presets { display: flex; gap: 6px; flex-wrap: wrap; }
.preset-swatch { width: 22px; height: 22px; border-radius: 50%; cursor: pointer; border: 2px solid var(--divider-color, #e0e0e0); transition: transform 0.1s; padding: 0; }
.preset-swatch:hover { transform: scale(1.15); }
.preset-swatch.active { border-color: var(--primary-text-color); box-shadow: 0 0 0 2px var(--primary-color); }
.colour-reset { font-size: 0.78rem; color: var(--secondary-text-color); background: none; border: 1px solid var(--divider-color, #e0e0e0); border-radius: 4px; padding: 4px 10px; cursor: pointer; margin-inline-start: auto; }
.colour-helper { color: var(--secondary-text-color); font-size: 0.82rem; line-height: 1.3; }
`;
}
}
customElements.define("taskmate-bounty-card", TaskMateBountyCard);
customElements.define("taskmate-bounty-card-editor", TaskMateBountyCardEditor);
window.customCards = window.customCards || [];
window.customCards.push({
type: "taskmate-bounty-card",
name: "TaskMate Bounty Board",
description: "One-off jobs any child can claim, finish and get approved",
preview: true,
getEntitySuggestion: (hass, entityId) =>
window.__taskmate_suggest(hass, entityId, "taskmate-bounty-card", "overview"),
});
@@ -13,6 +13,9 @@ const html = LitElement.prototype.html;
const css = LitElement.prototype.css;
const _safeColor = (c, d) => (typeof c === "string" && /^#[0-9a-fA-F]{3,8}$/.test(c) ? c : d);
// Keeps number runs like "3 / 1" reading left to right in RTL text (#995);
// identity until the design layer has loaded (it is also what stamps dir).
const _ltrNums = (s) => (window.__taskmate_design && window.__taskmate_design.ltrNums ? window.__taskmate_design.ltrNums(s) : s);
const DAY_NAMES = [
"monday", "tuesday", "wednesday", "thursday",
@@ -185,10 +188,10 @@ class TaskMateCalendarCard extends LitElement {
padding: 6px 10px;
background: var(--card-background-color, white);
border-radius: 8px;
border-left: 4px solid var(--cal-grey);
border-inline-start: 4px solid var(--cal-grey);
}
.chore-row.approved { border-left-color: var(--cal-green); }
.chore-row.pending { border-left-color: var(--cal-amber); }
.chore-row.approved { border-inline-start-color: var(--cal-green); }
.chore-row.pending { border-inline-start-color: var(--cal-amber); }
.chore-row.rotating { opacity: 0.6; font-style: italic; }
.chore-icon { --mdc-icon-size: 18px; color: var(--secondary-text-color); }
@@ -261,7 +264,7 @@ class TaskMateCalendarCard extends LitElement {
.cal-card-cn { background: var(--tmd-surface-2); border: 1px solid var(--tmd-border); border-radius: 8px; padding: 10px 11px; }
.cal-head-cn { margin-bottom: 7px; }
.cal-name-cn { font-weight: 700; font-size: 12.5px; }
.cal-count { margin-left: auto; font-size: 10px; }
.cal-count { margin-inline-start: auto; font-size: 10px; }
/* Clean Pro */
.cal-grid-cp { gap: 0; }
@@ -310,6 +313,17 @@ class TaskMateCalendarCard extends LitElement {
_isChoreScheduledOn(chore, dayDow, dayDate, todayKey, tz) {
if (chore.enabled === false) return false;
// One occurrence moved or removed from the HA calendar (#977):
// {scheduled date: new date, or "" when removed}. Mirrors the backend's
// occurrence_override — a day something was moved onto is on, a day
// something was moved away from or removed is off.
const moved = chore.moved_occurrences;
if (moved && typeof moved === "object") {
const key = ymd(dayDate, tz);
if (Object.values(moved).includes(key)) return true;
if (Object.prototype.hasOwnProperty.call(moved, key)) return false;
}
const scheduleMode = chore.schedule_mode || "specific_days";
const createdDate = chore.created_date || "";
@@ -394,7 +408,19 @@ class TaskMateCalendarCard extends LitElement {
return assignedTo.includes(childId);
}
// What the chore pays ``childId`` on ``dayKey``: a won auction occurrence
// pays the winning bid, days ahead included (#998).
_dayPoints(chore, childId, dayKey) {
const won = (chore.auction_wins || {})[dayKey];
const price = (chore.auction_prices || {})[dayKey];
if (won && String(won) === String(childId) && Number.isFinite(price)) return price;
return chore.points;
}
_rotationRenderMode(chore, childId, dayKey, todayKey) {
// Won at auction (#982): that day's occurrence is the winner's alone.
const won = (chore.auction_wins || {})[dayKey];
if (won) return String(won) === String(childId) ? "active" : "hidden";
const mode = chore.assignment_mode || "everyone";
if (mode === "everyone") return "active";
const current = chore.assignment_current_child_id || "";
@@ -483,11 +509,11 @@ class TaskMateCalendarCard extends LitElement {
</div>
<div class="day-nav">
<button @click=${() => this._shiftDay(-1)} title=${this._t("calendar.prev_day")}>
<ha-icon icon="mdi:chevron-left"></ha-icon>
<ha-icon class="tm-rtl-flip" icon="mdi:chevron-left"></ha-icon>
</button>
<span class="day-label">${isToday ? this._t("common.today") : dayLabel}</span>
<button @click=${() => this._shiftDay(1)} title=${this._t("calendar.next_day")}>
<ha-icon icon="mdi:chevron-right"></ha-icon>
<ha-icon class="tm-rtl-flip" icon="mdi:chevron-right"></ha-icon>
</button>
${!isToday ? html`
<button @click=${() => this._resetDay()} title=${this._t("common.today")}>
@@ -556,7 +582,7 @@ class TaskMateCalendarCard extends LitElement {
<ha-icon class="chore-icon ${state}" icon="${stateIcon}"></ha-icon>
<span class="chore-name">${chore.name}</span>
<span class="chore-points">
<ha-icon icon="${pointsIcon}"></ha-icon>${chore.points}
<ha-icon icon="${pointsIcon}"></ha-icon>${this._dayPoints(chore, child.id, day.key)}
</span>
</div>
`;
@@ -564,7 +590,7 @@ class TaskMateCalendarCard extends LitElement {
const summary = dueCount === 0
? this._t("calendar.no_chores_today")
: this._t("calendar.child_summary", { done: doneCount, total: dueCount });
: _ltrNums(this._t("calendar.child_summary", { done: doneCount, total: dueCount }));
return html`
<div class="child-block">
@@ -667,7 +693,7 @@ class TaskMateCalendarCard extends LitElement {
const sub = isToday ? this._t("common.today") : dayShort;
const pill = design === "console" && totalPending
? this._t("calendar.child_summary", { done: 0, total: totalPending }) : "";
? _ltrNums(this._t("calendar.child_summary", { done: 0, total: totalPending })) : "";
return wrap(sub, pill, body);
}
@@ -779,7 +805,7 @@ class TaskMateCalendarCardEditor extends LitElement {
.preset-swatch { width: 22px; height: 22px; border-radius: 50%; cursor: pointer; border: 2px solid var(--divider-color, #e0e0e0); transition: transform 0.1s; padding: 0; }
.preset-swatch:hover { transform: scale(1.15); }
.preset-swatch.active { border-color: var(--primary-text-color); box-shadow: 0 0 0 2px var(--primary-color); }
.colour-reset { font-size: 0.78rem; color: var(--secondary-text-color); background: none; border: 1px solid var(--divider-color, #e0e0e0); border-radius: 4px; padding: 4px 10px; cursor: pointer; margin-left: auto; }
.colour-reset { font-size: 0.78rem; color: var(--secondary-text-color); background: none; border: 1px solid var(--divider-color, #e0e0e0); border-radius: 4px; padding: 4px 10px; cursor: pointer; margin-inline-start: auto; }
.colour-helper { color: var(--secondary-text-color); font-size: 0.82rem; line-height: 1.3; }
`;
}
File diff suppressed because it is too large Load Diff
@@ -151,8 +151,8 @@
.tmd-hd .ic{width:32px;height:32px;border-radius:9px;display:grid;place-items:center;background:rgba(255,255,255,.22);font-size:17px;flex:none}
.tmd-hd .tt{font-family:var(--tmd-font-display);font-weight:800;font-size:16px;flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.tmd-hd .tt small{display:block;font-family:var(--tmd-font-body);font-weight:600;font-size:11px;opacity:.85;margin-top:1px}
.tmd-hd .pill{margin-left:auto;font-size:11px;font-weight:800;padding:4px 9px;border-radius:999px;background:rgba(255,255,255,.25);text-transform:capitalize}
.tmd-hd .cnt{margin-left:auto;min-width:22px;height:22px;padding:0 6px;border-radius:999px;background:var(--tmd-bad);color:#fff;font-size:12px;font-weight:800;display:grid;place-items:center}
.tmd-hd .pill{margin-inline-start:auto;font-size:11px;font-weight:800;padding:4px 9px;border-radius:999px;background:rgba(255,255,255,.25);text-transform:capitalize}
.tmd-hd .cnt{margin-inline-start:auto;min-width:22px;height:22px;padding:0 6px;border-radius:999px;background:var(--tmd-bad);color:#fff;font-size:12px;font-weight:800;display:grid;place-items:center}
.tmd-bd{padding:15px}
.tmd-empty{text-align:center;padding:26px 12px;color:var(--tmd-dim);font-size:.9rem}
.av{width:var(--av,42px);height:var(--av,42px);border-radius:50%;flex:none;display:grid;place-items:center;font-family:var(--tmd-font-display);font-weight:800;color:#fff;font-size:calc(var(--av,42px)*.4);line-height:1;text-align:center;background:var(--ac,var(--tmd-accent));box-shadow:0 2px 6px rgba(0,0,0,.18);overflow:hidden}
@@ -233,7 +233,7 @@
:host([data-tm-design="graphite"]) .tmd-checklist{border:1px solid var(--tmd-border);border-radius:var(--tmd-radius-sm)}
:host([data-tm-design="graphite"]) .tmd-check{position:relative;padding:9px 12px;min-height:46px;box-sizing:border-box}
:host([data-tm-design="graphite"]) .tmd-check+.tmd-check{border-top:0}
:host([data-tm-design="graphite"]) .tmd-check+.tmd-check::before{content:"";position:absolute;top:0;left:46px;right:0;height:1px;background:var(--tmd-border)}
:host([data-tm-design="graphite"]) .tmd-check+.tmd-check::before{content:"";position:absolute;top:0;inset-inline-start:46px;inset-inline-end:0;height:1px;background:var(--tmd-border)}
:host([data-tm-design="graphite"]) .tmd-check .c-num{width:22px;height:22px;border-radius:50%;
background:transparent;border:1.5px solid var(--tmd-border);color:transparent;font-size:0}
:host([data-tm-design="graphite"]) .tmd-check.done .c-num{background:var(--tmd-good);border-color:var(--tmd-good);color:#fff;font-size:13px}
@@ -306,7 +306,7 @@
:host([data-tm-design="graphite"]) .lb-cp-score{font-variant-numeric:tabular-nums;font-weight:600;letter-spacing:-.6px}
:host([data-tm-design="graphite"]) .lb-cp-unit{color:var(--tmd-dim);font-weight:400}
:host([data-tm-design="graphite"]) .lb-cp-row.win{background:transparent}
:host([data-tm-design="graphite"]) .lb-cp-meta ha-icon{--mdc-icon-size:14px;vertical-align:-2px;margin-right:3px;color:var(--tmd-dim)}
:host([data-tm-design="graphite"]) .lb-cp-meta ha-icon{--mdc-icon-size:14px;vertical-align:-2px;margin-inline-end:3px;color:var(--tmd-dim)}
/* Overview: hairline-separated stats, then a grouped per-child list. */
:host([data-tm-design="graphite"]) .ov-kids{border:1px solid var(--tmd-border);border-radius:var(--tmd-radius-sm)}
@@ -317,7 +317,13 @@
:host([data-tm-design="graphite"]) .ov-today .val,
:host([data-tm-design="graphite"]) .pts{font-variant-numeric:tabular-nums;font-weight:600;letter-spacing:-.6px}
:host([data-tm-design="graphite"]) .nm,
:host([data-tm-design="graphite"]) .ttl{font-weight:500;letter-spacing:-.18px}`;
:host([data-tm-design="graphite"]) .ttl{font-weight:500;letter-spacing:-.18px}
/* Right-to-left (#979). apply() stamps dir on the host; an icon that points
along the reading direction (chevron, undo, backspace) carries .tm-rtl-flip
so it turns round with the layout. Text arrows (→ ↩) swap to their mirrored
character instead, which leaves LTR markup untouched. Not design-scoped. */
:host([dir="rtl"]) .tm-rtl-flip,[dir="rtl"] .tm-rtl-flip{transform:scaleX(-1)}`;
if (!document.getElementById("taskmate-design-fonts")) {
const styleEl = document.createElement("style");
@@ -410,16 +416,60 @@
return !!(window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches);
}
/**
* Text direction of HA's current language (#979): "rtl" or "ltr". HA marks
* RTL languages in its translation metadata (the same isRTL flag its own
* computeRTL reads); for a language it has no metadata for, follow the
* direction HA already gave the page.
*/
let _lastDir = "ltr";
function direction(hass) {
const lang = hass && hass.language;
const meta = hass && hass.translationMetadata && hass.translationMetadata.translations;
const root = typeof document !== "undefined" && document.documentElement;
if (lang && meta && meta[lang]) _lastDir = meta[lang].isRTL ? "rtl" : "ltr";
else _lastDir = root && root.dir === "rtl" ? "rtl" : "ltr";
return _lastDir;
}
/**
* Keep number runs reading left to right inside right-to-left text (#995).
* "3 / 1", "1,200 / 5,000" and "3-day" are one unit each, but the Unicode
* bidi algorithm lays their parts out right to left in an RTL paragraph, so
* "3 / 1" shows as "1 / 3" and "3-Day Streak" as "Day Streak-3". Each run is
* wrapped in a left-to-right isolate, and a label holding one, or opening
* with a number ("2 of 5 done", which otherwise reads "of 5 done 2"), is
* wrapped in a first-strong isolate so it keeps its own reading order (an
* English label stays English-ordered, a translated RTL one stays RTL). The
* isolates (U+2066/2068 … U+2069) are invisible, but splitting a text run
* still nudges glyph positioning by a sub-pixel, so they are only added
* while the page is right-to-left: the direction last resolved by
* direction(), which every card calls through apply() at the top of
* render() and the panel calls in its hass setter (HA has one language per
* page). In LTR, and for text with no number run that doesn't open with a
* number, the value comes back untouched. Works on plain strings: Lit text,
* the panel's HTML strings and translated messages alike.
*/
const _NUM = String.raw`\p{Nd}+(?:[.,\u066B\u066C]\p{Nd}+)*`;
const _NUM_RUN = new RegExp(String.raw`${_NUM}(?:\s*\/\s*${_NUM})+|(?<![\p{L}-])${_NUM}-\p{L}+`, "gu");
function ltrNums(text) {
if (text === null || text === undefined || _lastDir !== "rtl") return text;
const s = String(text);
const out = s.replace(_NUM_RUN, (run) => "\u2066" + run + "\u2069");
return out === s && !/^\p{Nd}/u.test(s) ? s : "\u2068" + out + "\u2069";
}
/**
* Resolve the design AND stamp the host element: data-tm-design + (in dark
* mode) data-tm-dark. Cards call this once at the top of render() and branch
* on the returned id. Returns "classic" when no design applies.
* mode) data-tm-dark + dir. Cards call this once at the top of render() and
* branch on the returned id. Returns "classic" when no design applies.
*/
function apply(el, hass, config, entity) {
const design = resolve(hass, config, entity);
if (el) {
el.setAttribute("data-tm-design", design);
el.toggleAttribute("data-tm-dark", design !== "classic" && isDark(hass, el));
el.setAttribute("dir", direction(hass));
}
// The host keeps the real id (its CSS keys off it); the card gets the
// layout id so aliased styles reuse an existing render path.
@@ -487,7 +537,7 @@
`;
}
window.__taskmate_design = { IDS, resolve, isDark, apply, active, editorOptions, styles, cssText, tokensCSS: TOKENS, colourPicker };
window.__taskmate_design = { IDS, resolve, isDark, direction, ltrNums, apply, active, editorOptions, styles, cssText, tokensCSS: TOKENS, colourPicker };
/**
* The single place that decides what a chore looks like (#750).
@@ -541,6 +591,29 @@
return { config: Object.assign({}, stub, { type: "custom:" + type, entity: entityId }) };
};
// ── Birthday "first open today" gate (#924) ───────────────────────────────
// True the first time it's asked about a child on a given day on this device,
// false after that — so the birthday confetti plays once per day, not on every
// hass update or page load. Shared by every card that celebrates, so a child
// card and a points card on the same dashboard don't both go off. Keyed on
// HA's time zone so the day rolls over with the backend, not the browser.
window.__taskmate_birthday_once = window.__taskmate_birthday_once || function (hass, childId) {
if (!childId) return false;
let day;
try {
day = new Date().toLocaleDateString("en-CA", { timeZone: hass?.config?.time_zone || undefined });
} catch (e) {
day = new Date().toLocaleDateString("en-CA");
}
const KEY = "taskmate_celebrated_day";
let seen;
try { seen = JSON.parse(window.localStorage.getItem(KEY) || "{}") || {}; } catch (e) { seen = {}; }
if (seen[childId] === day) return false;
seen[childId] = day;
try { window.localStorage.setItem(KEY, JSON.stringify(seen)); } catch (e) { /* private mode: plays each load */ }
return true;
};
// ── Chore-photo lightbox ───────────────────────────────────────────────────
// A single shared, design-AGNOSTIC image viewer for evidence photos. It is
// mounted on document.body (NOT inside any card's shadow root), so it paints
@@ -582,7 +655,7 @@
"background:#111;box-shadow:0 18px 60px rgba(0,0,0,.6);}" +
".tm-lightbox .tm-lb-cap{color:#f4f6fb;font-size:14px;font-weight:600;text-align:center;" +
"max-width:92vw;line-height:1.4;text-shadow:0 1px 2px rgba(0,0,0,.6);}" +
".tm-lightbox .tm-lb-close{position:absolute;top:14px;right:16px;width:42px;height:42px;border:none;" +
".tm-lightbox .tm-lb-close{position:absolute;top:14px;inset-inline-end:16px;width:42px;height:42px;border:none;" +
"border-radius:50%;background:rgba(255,255,255,.14);color:#fff;font-size:22px;line-height:1;" +
"cursor:pointer;transition:background .15s;}" +
".tm-lightbox .tm-lb-close:hover{background:rgba(255,255,255,.28);}" +
@@ -18,6 +18,16 @@ const html = LitElement.prototype.html;
const css = LitElement.prototype.css;
const _safeColor = (c, d) => (typeof c === "string" && /^#[0-9a-fA-F]{3,8}$/.test(c) ? c : d);
// Keeps number runs like "3 / 1" reading left to right in RTL text (#995);
// identity until the design layer has loaded (it is also what stamps dir).
const _ltrNums = (s) => (window.__taskmate_design && window.__taskmate_design.ltrNums ? window.__taskmate_design.ltrNums(s) : s);
// "a / b" for a template: the same text nodes as a literal `${a} / ${b}` in
// LTR (so it renders pixel-for-pixel as before), the isolated string in RTL.
const _ltrRatio = (a, b) => {
const s = `${a} / ${b}`;
const r = _ltrNums(s);
return r === s ? html`${a} / ${b}` : r;
};
const DEFAULT_ACCENT = "#16a085";
class TaskMateFamilyGoalCard extends LitElement {
@@ -93,7 +103,7 @@ class TaskMateFamilyGoalCard extends LitElement {
<div class="fg-body">
<div class="fg-bar"><div class="fg-fill ${achieved ? "done" : ""}" style="width:${pct}%"></div></div>
<div class="fg-stats">
<span>${progress} / ${target} ${pointsName}</span>
<span>${_ltrRatio(progress, target)} ${pointsName}</span>
<span>${pct}%</span>
</div>
${achieved
@@ -214,7 +214,9 @@ class TaskMateGraphCard extends LitElement {
.tmg-plot-pl { background: var(--tmd-surface-2); border-radius: 16px; padding: 12px 10px 6px; }
.tmg-plot-cn { background: #0b1424; border: 1px solid var(--tmd-border); border-radius: 8px; padding: 12px 10px 6px; }
.tmg-plot-cp { padding: 2px 2px 0; }
.tmg-xaxis { justify-content: space-between; font-size: 10.5px; font-weight: 600; margin-top: 4px; }
/* The plot is an SVG drawn left to right in every language, so its date
labels must not mirror under RTL (#979) or they'd sit under the wrong day. */
.tmg-xaxis { justify-content: space-between; font-size: 10.5px; font-weight: 600; margin-top: 4px; direction: ltr; }
.tmg-plot-cn .tmg-xaxis { font-size: 9.5px; }
.tmg-legend { gap: 14px; justify-content: center; margin-top: 11px; font-size: 12px; font-weight: 700; flex-wrap: wrap; }
.tmg-plot-cp ~ .tmg-legend { justify-content: flex-start; font-weight: 600; }
@@ -942,7 +944,7 @@ class TaskMateGraphCardEditor extends LitElement {
.preset-swatch { width: 22px; height: 22px; border-radius: 50%; cursor: pointer; border: 2px solid var(--divider-color, #e0e0e0); transition: transform 0.1s; padding: 0; }
.preset-swatch:hover { transform: scale(1.15); }
.preset-swatch.active { border-color: var(--primary-text-color); box-shadow: 0 0 0 2px var(--primary-color); }
.colour-reset { font-size: 0.78rem; color: var(--secondary-text-color); background: none; border: 1px solid var(--divider-color, #e0e0e0); border-radius: 4px; padding: 4px 10px; cursor: pointer; margin-left: auto; }
.colour-reset { font-size: 0.78rem; color: var(--secondary-text-color); background: none; border: 1px solid var(--divider-color, #e0e0e0); border-radius: 4px; padding: 4px 10px; cursor: pointer; margin-inline-start: auto; }
.colour-helper { color: var(--secondary-text-color); font-size: 0.82rem; line-height: 1.3; }
`;
}
@@ -332,7 +332,7 @@ export function createIncentiveCard(P) {
.toast {
position: fixed;
bottom: 24px;
left: 50%;
left: 50%; /* rtl-ok: centred with translate(-50%), symmetric */
transform: translateX(-50%) translateY(0);
background: #333;
color: white;
@@ -365,7 +365,7 @@ export function createIncentiveCard(P) {
.d-tabs { display: flex; gap: 8px; margin-bottom: 13px; align-items: center;
overflow-x: auto; scrollbar-width: none; }
.d-tabs::-webkit-scrollbar { display: none; }
.d-tab { display: flex; align-items: center; gap: 6px; padding: 4px 12px 4px 4px;
.d-tab { display: flex; align-items: center; gap: 6px; padding-block: 4px; padding-inline: 4px 12px;
border-radius: 999px; font-weight: 800; font-size: 13px; cursor: pointer;
white-space: nowrap; color: var(--tmd-text);
background: color-mix(in srgb, var(--ac, var(--tmd-accent)) 16%, transparent);
@@ -980,7 +980,7 @@ export function createIncentiveCard(P) {
<span class="tt">${this.config.title || this._tp('default_title')}${
bonuses.length ? html`<small>${bonuses.length}</small>` : ""}</span>
${this._canManage() ? html`
<button class="btn ghost sm" style="margin-left:auto"
<button class="btn ghost sm" style="margin-inline-start:auto"
title="${this._tp('manage_title')}"
@click=${this._toggleEditMode}>
<ha-icon icon="mdi:pencil"></ha-icon>
@@ -1054,7 +1054,7 @@ export function createIncentiveCard(P) {
.preset-swatch { width: 22px; height: 22px; border-radius: 50%; cursor: pointer; border: 2px solid var(--divider-color, #e0e0e0); transition: transform 0.1s; padding: 0; }
.preset-swatch:hover { transform: scale(1.15); }
.preset-swatch.active { border-color: var(--primary-text-color); box-shadow: 0 0 0 2px var(--primary-color); }
.colour-reset { font-size: 0.78rem; color: var(--secondary-text-color); background: none; border: 1px solid var(--divider-color, #e0e0e0); border-radius: 4px; padding: 4px 10px; cursor: pointer; margin-left: auto; }
.colour-reset { font-size: 0.78rem; color: var(--secondary-text-color); background: none; border: 1px solid var(--divider-color, #e0e0e0); border-radius: 4px; padding: 4px 10px; cursor: pointer; margin-inline-start: auto; }
.colour-helper { color: var(--secondary-text-color); font-size: 0.82rem; line-height: 1.3; }
`;
}
File diff suppressed because it is too large Load Diff
@@ -141,7 +141,7 @@ class TaskMateLeaderboardCard extends LitElement {
.stat-chip ha-icon { --mdc-icon-size: 13px; }
.rank-score {
text-align: right; flex-shrink: 0;
text-align: end; flex-shrink: 0;
display: flex; flex-direction: column; align-items: flex-end; gap: 2px;
}
@@ -165,7 +165,7 @@ class TaskMateLeaderboardCard extends LitElement {
}
.tie-line .tie-label {
position: absolute; top: 50%; left: 50%;
position: absolute; top: 50%; left: 50%; /* rtl-ok: centred with translate(-50%), symmetric */
transform: translate(-50%, -50%);
background: var(--secondary-background-color, #f5f5f5);
padding: 0 6px;
@@ -264,7 +264,7 @@ class TaskMateLeaderboardCard extends LitElement {
.lb-cn-mid { flex: 1; min-width: 0; }
.lb-cn-name { font-weight: 700; }
.lb-cn-meta { font-size: 10px; gap: 8px; margin-top: 5px; }
.lb-cn-right { text-align: right; }
.lb-cn-right { text-align: end; }
.lb-cn-score { font-size: 23px; }
.lb-cn-score.win { color: var(--tmd-accent); }
.lb-cn-unit { font-size: 10px; }
@@ -278,7 +278,7 @@ class TaskMateLeaderboardCard extends LitElement {
.lb-cp-mid { flex: 1; min-width: 0; }
.lb-cp-name { font-weight: 600; }
.lb-cp-meta { font-size: 11.5px; gap: 10px; margin-top: 2px; }
.lb-cp-right { text-align: right; }
.lb-cp-right { text-align: end; }
.lb-cp-score { font-size: 19px; }
.lb-cp-unit { font-size: 10.5px; }
`;
@@ -787,7 +787,7 @@ class TaskMateLeaderboardCardEditor extends LitElement {
.preset-swatch { width: 22px; height: 22px; border-radius: 50%; cursor: pointer; border: 2px solid var(--divider-color, #e0e0e0); transition: transform 0.1s; padding: 0; }
.preset-swatch:hover { transform: scale(1.15); }
.preset-swatch.active { border-color: var(--primary-text-color); box-shadow: 0 0 0 2px var(--primary-color); }
.colour-reset { font-size: 0.78rem; color: var(--secondary-text-color); background: none; border: 1px solid var(--divider-color, #e0e0e0); border-radius: 4px; padding: 4px 10px; cursor: pointer; margin-left: auto; }
.colour-reset { font-size: 0.78rem; color: var(--secondary-text-color); background: none; border: 1px solid var(--divider-color, #e0e0e0); border-radius: 4px; padding: 4px 10px; cursor: pointer; margin-inline-start: auto; }
.colour-helper { color: var(--secondary-text-color); font-size: 0.82rem; line-height: 1.3; }
`;
}
@@ -15,6 +15,16 @@ const html = LitElement.prototype.html;
const css = LitElement.prototype.css;
const _safeColor = (c, d) => (typeof c === "string" && /^#[0-9a-fA-F]{3,8}$/.test(c) ? c : d);
// Keeps number runs like "3 / 1" reading left to right in RTL text (#995);
// identity until the design layer has loaded (it is also what stamps dir).
const _ltrNums = (s) => (window.__taskmate_design && window.__taskmate_design.ltrNums ? window.__taskmate_design.ltrNums(s) : s);
// "a / b" for a template: the same text nodes as a literal `${a} / ${b}` in
// LTR (so it renders pixel-for-pixel as before), the isolated string in RTL.
const _ltrRatio = (a, b) => {
const s = `${a} / ${b}`;
const r = _ltrNums(s);
return r === s ? html`${a} / ${b}` : r;
};
class TaskMateOverviewCard extends LitElement {
static get properties() {
@@ -219,7 +229,7 @@ class TaskMateOverviewCard extends LitElement {
color: var(--secondary-text-color);
white-space: nowrap;
min-width: 36px;
text-align: right;
text-align: end;
}
.progress-label.complete { color: var(--ov-green); }
@@ -327,11 +337,11 @@ class TaskMateOverviewCard extends LitElement {
@media (max-width: 480px) { .ov-kids { grid-template-columns: repeat(2, 1fr); } }
.ov-kid { text-align: center; padding: 11px; border-radius: 16px; background: var(--tmd-surface-2); position: relative; }
.ov-kid.tm-clickable { cursor: pointer; }
.ov-kid-flags { position: absolute; top: 6px; right: 7px; display: flex; gap: 4px; }
.ov-kid-flags { position: absolute; top: 6px; inset-inline-end: 7px; display: flex; gap: 4px; }
.ov-kid-flag { font-size: 9.5px; font-weight: 800; padding: 1px 5px; border-radius: 999px; line-height: 1.5; }
.ov-kid-flag.pend { background: color-mix(in srgb, var(--tmd-warn) 22%, transparent); color: var(--tmd-warn); }
.ov-kid-flag.wait { background: color-mix(in srgb, var(--tmd-bad) 20%, transparent); color: var(--tmd-bad); }
.ov-behalf { margin-top: 10px; text-align: left; background: var(--tmd-surface-2); border-radius: var(--tmd-radius-sm); padding: 9px 11px; }
.ov-behalf { margin-top: 10px; text-align: start; background: var(--tmd-surface-2); border-radius: var(--tmd-radius-sm); padding: 9px 11px; }
.ov-behalf-hdr { font-size: 10px; text-transform: uppercase; letter-spacing: .04em; color: var(--tmd-dim); margin-bottom: 6px; }
.ov-behalf-done { font-size: 12px; font-style: italic; color: var(--tmd-dim); padding: 3px 0; }
.ov-behalf-row { display: flex; align-items: center; gap: 8px; padding: 4px 0; }
@@ -573,7 +583,9 @@ class TaskMateOverviewCard extends LitElement {
const at = Array.isArray(c.assigned_to) ? c.assigned_to.map(String) : [];
const assigned = at.length === 0 || at.includes(String(child.id));
if (!assigned) return false;
if (c.schedule_mode !== 'recurring') {
// Calendar move/removal (#977) settles today outright.
if (c.occ_today === false) return false;
if (c.schedule_mode !== 'recurring' && c.occ_today !== true) {
const dueDays = Array.isArray(c.due_days) ? c.due_days : [];
if (dueDays.length > 0 && !dueDays.includes(todayDow)) return false;
}
@@ -686,7 +698,7 @@ class TaskMateOverviewCard extends LitElement {
<div class="divide"></div>
<div class="ov-today cp">
<span class="lbl">${this._t("overview.designed.todays_progress")}</span>
<span class="val num">${this._t("overview.designed.progress_detail", { done, total, pct })}</span>
<span class="val num">${_ltrNums(this._t("overview.designed.progress_detail", { done, total, pct }))}</span>
</div>`;
}
const lbl = design === "console"
@@ -695,7 +707,7 @@ class TaskMateOverviewCard extends LitElement {
return html`
<div class="ov-today ${cls}">
<span class="lbl">${lbl}</span>
<span class="val">${done} / ${total}</span>
<span class="val">${_ltrRatio(done, total)}</span>
</div>`;
}
@@ -716,8 +728,11 @@ class TaskMateOverviewCard extends LitElement {
const assigned = at.length === 0 || at.includes(String(child.id));
if (!assigned) return false;
// Calendar move/removal (#977) settles today outright.
if (c.occ_today === false) return false;
// Mode A: due days check
if (c.schedule_mode !== 'recurring') {
if (c.schedule_mode !== 'recurring' && c.occ_today !== true) {
const dueDays = Array.isArray(c.due_days) ? c.due_days : [];
if (dueDays.length > 0 && !dueDays.includes(todayDow)) return false;
}
@@ -871,7 +886,7 @@ class TaskMateOverviewCardEditor extends LitElement {
.preset-swatch { width: 22px; height: 22px; border-radius: 50%; cursor: pointer; border: 2px solid var(--divider-color, #e0e0e0); transition: transform 0.1s; padding: 0; }
.preset-swatch:hover { transform: scale(1.15); }
.preset-swatch.active { border-color: var(--primary-text-color); box-shadow: 0 0 0 2px var(--primary-color); }
.colour-reset { font-size: 0.78rem; color: var(--secondary-text-color); background: none; border: 1px solid var(--divider-color, #e0e0e0); border-radius: 4px; padding: 4px 10px; cursor: pointer; margin-left: auto; }
.colour-reset { font-size: 0.78rem; color: var(--secondary-text-color); background: none; border: 1px solid var(--divider-color, #e0e0e0); border-radius: 4px; padding: 4px 10px; cursor: pointer; margin-inline-start: auto; }
.colour-helper { color: var(--secondary-text-color); font-size: 0.82rem; line-height: 1.3; }
`;
}
File diff suppressed because it is too large Load Diff
@@ -27,6 +27,9 @@ const tmSafePhotoUrl = (u) =>
const tmClaimList = (v) => (Array.isArray(v) ? v : []);
const _safeColor = (c, d) => (typeof c === "string" && /^#[0-9a-fA-F]{3,8}$/.test(c) ? c : d);
// Keeps number runs like "3 / 1" reading left to right in RTL text (#995);
// identity until the design layer has loaded (it is also what stamps dir).
const _ltrNums = (s) => (window.__taskmate_design && window.__taskmate_design.ltrNums ? window.__taskmate_design.ltrNums(s) : s);
class TaskMateParentDashboardCard extends LitElement {
static get properties() {
@@ -270,7 +273,7 @@ class TaskMateParentDashboardCard extends LitElement {
.progress-label {
font-size: 0.75rem; font-weight: 600;
color: var(--secondary-text-color);
white-space: nowrap; min-width: 32px; text-align: right;
white-space: nowrap; min-width: 32px; text-align: end;
}
/* ── Approval items ── */
@@ -552,18 +555,21 @@ class TaskMateParentDashboardCard extends LitElement {
setConfig(config) {
if (!config.entity) throw new Error("Please define an entity");
this.config = {
title: "Parent Dashboard",
title: "",
quick_points_amount: 5,
show_claims: true,
header_color: '#c0392b',
...config,
};
// Dashboards saved before #1010 stored the English default title; treat it
// as unset so the translated default is shown instead.
if (this.config.title === "Parent Dashboard") this.config.title = "";
}
getCardSize() { return 6; }
static getConfigElement() { return document.createElement("taskmate-parent-dashboard-card-editor"); }
static getStubConfig() {
return { entity: "sensor.taskmate_overview", title: "Parent Dashboard" };
return { entity: "sensor.taskmate_overview" };
}
render() {
@@ -681,10 +687,12 @@ class TaskMateParentDashboardCard extends LitElement {
const assigned = at.length === 0 || at.includes(child.id);
if (!assigned) return false;
const perChild = availability[c.id];
// Calendar move/removal (#977) settles today outright.
if (c.occ_today === false) return false;
if (c.schedule_mode === 'one_shot') {
if (perChild && perChild[child.id] === false) return false;
}
if (c.schedule_mode === 'specific_days') {
if (c.schedule_mode === 'specific_days' && c.occ_today !== true) {
const dueDays = Array.isArray(c.due_days) ? c.due_days : [];
if (dueDays.length > 0 && !dueDays.includes(todayDow)) return false;
}
@@ -856,7 +864,7 @@ class TaskMateParentDashboardCard extends LitElement {
${this._av(child.name, child.avatar, tone, 42)}
<div style="flex:1;min-width:0">
<div class="pd-name">${child.name}</div>
<div class="muted pd-sub">${this._t('dashboard.chores_done', { done: approved, total }, `${approved} / ${total} chores done`)}</div>
<div class="muted pd-sub">${_ltrNums(this._t('dashboard.chores_done', { done: approved, total }, `${approved} / ${total} chores done`))}</div>
</div>
<div class="big pd-pts">${child.points}⭐</div>
</div>
@@ -1029,12 +1037,14 @@ class TaskMateParentDashboardCard extends LitElement {
const assigned = at.length === 0 || at.includes(child.id);
if (!assigned) return false;
const perChild = availability[c.id];
// Calendar move/removal (#977) settles today outright.
if (c.occ_today === false) return false;
// One-shot: use is_available check (same as recurring)
if (c.schedule_mode === 'one_shot') {
if (perChild && perChild[child.id] === false) return false;
}
// Mode A: due days check
if (c.schedule_mode === 'specific_days') {
if (c.schedule_mode === 'specific_days' && c.occ_today !== true) {
const dueDays = Array.isArray(c.due_days) ? c.due_days : [];
if (dueDays.length > 0 && !dueDays.includes(todayDow)) return false;
}
@@ -1445,7 +1455,7 @@ class TaskMateParentDashboardCardEditor extends LitElement {
.preset-swatch { width: 22px; height: 22px; border-radius: 50%; cursor: pointer; border: 2px solid var(--divider-color, #e0e0e0); transition: transform 0.1s; padding: 0; }
.preset-swatch:hover { transform: scale(1.15); }
.preset-swatch.active { border-color: var(--primary-text-color); box-shadow: 0 0 0 2px var(--primary-color); }
.colour-reset { font-size: 0.78rem; color: var(--secondary-text-color); background: none; border: 1px solid var(--divider-color, #e0e0e0); border-radius: 4px; padding: 4px 10px; cursor: pointer; margin-left: auto; }
.colour-reset { font-size: 0.78rem; color: var(--secondary-text-color); background: none; border: 1px solid var(--divider-color, #e0e0e0); border-radius: 4px; padding: 4px 10px; cursor: pointer; margin-inline-start: auto; }
.colour-helper { color: var(--secondary-text-color); font-size: 0.82rem; line-height: 1.3; }
`;
}
@@ -283,7 +283,7 @@ class TaskMatePointsCard extends LitElement {
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--secondary-text-color);
text-align: right;
text-align: end;
margin-bottom: 1px;
}
@@ -291,8 +291,8 @@ class TaskMatePointsCard extends LitElement {
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
inset-inline-start: 0;
inset-inline-end: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
@@ -478,7 +478,7 @@ class TaskMatePointsCard extends LitElement {
.notification {
position: fixed;
bottom: 24px;
left: 50%;
left: 50%; /* rtl-ok: centred with translate(-50%), symmetric */
transform: translateX(-50%);
max-width: calc(100vw - 24px);
white-space: normal;
@@ -609,7 +609,7 @@ class TaskMatePointsCard extends LitElement {
.pc-mid { flex: 1; min-width: 0; }
.pc-name { font-weight: 800; font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.pc-pts { font-family: var(--tmd-font-display); font-weight: 800; font-size: 22px; line-height: 1; color: var(--tmd-accent); }
.pc-pts span { font-size: 12px; margin-left: 2px; }
.pc-pts span { font-size: 12px; margin-inline-start: 2px; }
.pc-sub { font-size: 11px; }
/* Console list variant */
@@ -622,7 +622,7 @@ class TaskMatePointsCard extends LitElement {
.cp-row { background: transparent; border-radius: 0; padding: 11px 2px; }
.cp-row + .cp-row { border-top: 1px solid var(--tmd-border); }
.cp-name { flex: 1; min-width: 0; font-weight: 600; }
.cp-num { width: 46px; text-align: right; font-size: 16px; }
.cp-num { width: 46px; text-align: end; font-size: 16px; }
.pc-quick { gap: 7px; flex-wrap: wrap; }
.pc-reason { margin-top: 12px; cursor: pointer; }
@@ -673,9 +673,12 @@ class TaskMatePointsCard extends LitElement {
throw new Error("Please define an entity (taskmate overview sensor)");
}
this.config = {
title: "Manage Points",
title: "",
...config,
};
// Dashboards saved before #1010 stored the English default title; treat it
// as unset so the translated default is shown instead.
if (this.config.title === "Manage Points") this.config.title = "";
}
getCardSize() {
@@ -689,7 +692,6 @@ class TaskMatePointsCard extends LitElement {
static getStubConfig() {
return {
entity: "sensor.taskmate_overview",
title: "Manage Points",
};
}
@@ -1235,7 +1237,7 @@ class TaskMatePointsCardEditor extends LitElement {
.preset-swatch { width: 22px; height: 22px; border-radius: 50%; cursor: pointer; border: 2px solid var(--divider-color, #e0e0e0); transition: transform 0.1s; padding: 0; }
.preset-swatch:hover { transform: scale(1.15); }
.preset-swatch.active { border-color: var(--primary-text-color); box-shadow: 0 0 0 2px var(--primary-color); }
.colour-reset { font-size: 0.78rem; color: var(--secondary-text-color); background: none; border: 1px solid var(--divider-color, #e0e0e0); border-radius: 4px; padding: 4px 10px; cursor: pointer; margin-left: auto; }
.colour-reset { font-size: 0.78rem; color: var(--secondary-text-color); background: none; border: 1px solid var(--divider-color, #e0e0e0); border-radius: 4px; padding: 4px 10px; cursor: pointer; margin-inline-start: auto; }
.colour-helper { color: var(--secondary-text-color); font-size: 0.82rem; line-height: 1.3; }
`;
}
@@ -88,6 +88,7 @@ class TaskMatePointsDisplayCard extends LitElement {
hass: { type: Object },
config: { type: Object },
_animated: { type: Object },
_confetti: { type: Array },
};
}
@@ -103,6 +104,7 @@ class TaskMatePointsDisplayCard extends LitElement {
constructor() {
super();
this._animated = {};
this._confetti = [];
}
_t(key, params) {
@@ -228,7 +230,7 @@ class TaskMatePointsDisplayCard extends LitElement {
content: "\u2B50";
font-size: 7rem;
position: absolute;
right: -12px;
inset-inline-end: -12px;
bottom: -18px;
opacity: 0.06;
line-height: 1;
@@ -254,7 +256,7 @@ class TaskMatePointsDisplayCard extends LitElement {
font-size: 2rem;
animation: star-spin 3s linear infinite;
display: inline-block;
margin-right: 6px;
margin-inline-end: 6px;
vertical-align: middle;
}
@keyframes star-spin {
@@ -329,7 +331,7 @@ class TaskMatePointsDisplayCard extends LitElement {
.child-tile .rank-badge {
position: absolute;
top: 8px;
right: 10px;
inset-inline-end: 10px;
font-size: 1.1rem;
line-height: 1;
}
@@ -351,7 +353,7 @@ class TaskMatePointsDisplayCard extends LitElement {
.child-tile .tile-star {
font-size: 1rem;
vertical-align: middle;
margin-right: 2px;
margin-inline-end: 2px;
display: inline-block;
animation: star-spin 4s linear infinite;
}
@@ -383,8 +385,8 @@ class TaskMatePointsDisplayCard extends LitElement {
content: "";
position: absolute;
bottom: 0;
left: 0;
right: 0;
inset-inline-start: 0;
inset-inline-end: 0;
height: 4px;
border-radius: 0 0 18px 18px;
}
@@ -414,7 +416,7 @@ class TaskMatePointsDisplayCard extends LitElement {
content: "\u{1F31F}";
font-size: 6rem;
position: absolute;
right: -10px;
inset-inline-end: -10px;
bottom: -14px;
opacity: 0.07;
pointer-events: none;
@@ -474,7 +476,7 @@ class TaskMatePointsDisplayCard extends LitElement {
font-size: 0.85rem;
animation: star-spin 5s linear infinite;
display: inline-block;
margin-right: 2px;
margin-inline-end: 2px;
}
.cumul-bar-wrap {
width: 100%;
@@ -503,16 +505,45 @@ class TaskMatePointsDisplayCard extends LitElement {
}
.empty-state ha-icon { --mdc-icon-size: 40px; opacity: 0.35; display: block; margin: 0 auto 10px; }
/* ── Birthday (#924) ── */
.birthday-banner {
display: flex; align-items: center; gap: 12px; padding: 12px 18px;
background: linear-gradient(135deg, #ffd1e8, #e3c8ff); color: #5b1f6b;
}
.birthday-banner ha-icon { --mdc-icon-size: 28px; flex-shrink: 0; }
.bday-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.birthday-banner .bday-title { font-weight: 800; font-size: 1rem; }
.birthday-banner .bday-sub { font-size: 0.85rem; font-weight: 600; opacity: 0.85; }
.tmd-birthday {
display: flex; align-items: center; gap: 10px; padding: 10px 12px; margin-bottom: 11px;
background: color-mix(in srgb, var(--tmd-accent) 16%, transparent); color: var(--tmd-text);
border: 1px solid color-mix(in srgb, var(--tmd-accent) 40%, transparent);
border-radius: var(--tmd-radius-sm);
}
.tmd-birthday ha-icon { --mdc-icon-size: 26px; color: var(--tmd-accent); flex-shrink: 0; }
.tmd-birthday .bday-title { font-weight: 800; font-size: 14px; }
.tmd-birthday .bday-sub { font-size: 12px; font-weight: 600; color: var(--tmd-dim); }
.confetti-container {
position: fixed; top: 0; inset-inline-start: 0; width: 100%; height: 100%;
pointer-events: none; z-index: 10000; overflow: hidden;
}
.confetti { position: absolute; width: 10px; height: 10px; animation: confetti-fall 3s linear forwards; }
@keyframes confetti-fall {
0% { transform: translateY(-100px) rotate(0deg); opacity: 1; }
100% { transform: translateY(100vh) rotate(720deg); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) { .confetti-container { display: none; } }
/* Shared .tmd kit + design tokens are provided by taskmate-design.js styles(). */
/* Points Display — Playroom */
.pl-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 11px; }
.pl-tile { background: var(--tmd-surface-2); border-radius: 18px; padding: 22px 12px 15px; text-align: center;
position: relative; display: flex; flex-direction: column; align-items: center; gap: 6px; }
.pl-medal { position: absolute; top: 9px; left: 11px; font-size: 16px; line-height: 1; }
.pl-medal { position: absolute; top: 9px; inset-inline-start: 11px; font-size: 16px; line-height: 1; }
.pl-pts { font-family: var(--tmd-font-display); font-weight: 800; font-size: 1.85rem; line-height: 1;
color: var(--tmd-accent); }
.pl-pts span { font-size: 0.9rem; margin-left: 2px; }
.pl-pts span { font-size: 0.9rem; margin-inline-start: 2px; }
.pl-name { font-weight: 800; font-size: 0.95rem; }
.pl-chips { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; }
@@ -523,7 +554,7 @@ class TaskMatePointsDisplayCard extends LitElement {
.cn-rank { font-size: 20px; width: 34px; color: var(--tmd-dim); }
.cn-mid { flex: 1; min-width: 0; }
.cn-name { font-weight: 700; margin-bottom: 6px; }
.cn-right { text-align: right; }
.cn-right { text-align: end; }
.cn-pts { font-size: 22px; }
.cn-sub { font-size: 10px; }
@@ -747,7 +778,7 @@ class TaskMatePointsDisplayCard extends LitElement {
<div class="tile-weekly">${this._t("points_display.weekly_plus", { count: weekly })}</div>` : ""}
${this.config.show_streak ? html`
<div class="streak-chip">\u{1F525} ${streak}</div>` : ""}
${isTop ? html`<div style="position:absolute;bottom:0;left:0;right:0;height:4px;background:${colour};border-radius:0 0 18px 18px;"></div>` : ""}
${isTop ? html`<div style="position:absolute;bottom:0;inset-inline-start:0;inset-inline-end:0;height:4px;background:${colour};border-radius:0 0 18px 18px;"></div>` : ""}
</div>`;
})}
</div>`;
@@ -814,6 +845,64 @@ class TaskMatePointsDisplayCard extends LitElement {
</div>`;
}
/* ── Birthday mode (#924) ───────────────────────────────────────────────
`child.birthday` is only on the sensor on the day. The banner helper is
called from BOTH render paths; confetti fires from updated(), which runs
for every design. Same confetti as the child card's chore celebration. */
_birthdayChildren() {
const mode = this.config.mode || "single";
const pool = mode === "single" ? [this._singleChild()].filter(Boolean) : this._allChildren();
return pool.filter(c => c && c.birthday);
}
_renderBirthdayBanners(designed) {
return this._birthdayChildren().map(child => {
const b = child.birthday;
const greeting = b.age != null
? this._t("birthday.banner_age", { name: child.name, age: b.age })
: this._t("birthday.banner", { name: child.name });
const mult = Number(b.multiplier) || 1;
const sub = mult > 1 ? this._t("birthday.multiplier", { multiplier: mult }) : "";
return html`
<div class="${designed ? "tmd-birthday" : "birthday-banner"}" role="status">
<ha-icon icon="mdi:cake-variant"></ha-icon>
<div class="bday-text">
<span class="bday-title">${greeting}</span>
${sub ? html`<span class="bday-sub">${sub}</span>` : ""}
</div>
</div>`;
});
}
updated(changed) {
super.updated(changed);
const once = window.__taskmate_birthday_once;
if (!once || !this.hass || !this.config) return;
// Evaluate every child (no short-circuit) so each is marked seen today.
const fire = this._birthdayChildren().map(c => once(this.hass, c.id)).some(Boolean);
if (fire) this._spawnConfetti();
}
_spawnConfetti() {
const pieces = [];
for (let i = 0; i < 50; i++) {
pieces.push({ x: Math.random() * 100, delay: Math.random() * 0.5, size: Math.random() * 8 + 6, round: Math.random() > 0.5 });
}
this._confetti = pieces;
setTimeout(() => { this._confetti = []; }, 3500);
}
_renderConfetti() {
if (!this._confetti.length) return "";
const colors = ["#ff6b9d", "#9b59b6", "#3498db", "#2ecc71", "#f1c40f", "#e67e22"];
return html`
<div class="confetti-container">
${this._confetti.map((p, i) => html`
<div class="confetti" style="inset-inline-start:${p.x}%;animation-delay:${p.delay}s;background:${colors[i % colors.length]};border-radius:${p.round ? "50%" : "0"};width:${p.size}px;height:${p.size}px;"></div>`)}
</div>`;
}
/* ── Main render ────────────────────────────────────────────────────── */
render() {
@@ -847,7 +936,9 @@ class TaskMatePointsDisplayCard extends LitElement {
</div>
<div class="mode-badge">${modeLabel}</div>
</div>
${this._renderBirthdayBanners(false)}
<div class="card-body">${body}</div>
${this._renderConfetti()}
</ha-card>`;
}
@@ -913,7 +1004,7 @@ class TaskMatePointsDisplayCard extends LitElement {
design === "console" ? this._designConsole(rows) :
this._designCleanpro(rows, total);
return html`<ha-card class="tmd" style="--hd:${hd}">${header}<div class="tmd-bd">${body}</div></ha-card>`;
return html`<ha-card class="tmd" style="--hd:${hd}">${header}<div class="tmd-bd">${this._renderBirthdayBanners(true)}${body}</div>${this._renderConfetti()}</ha-card>`;
}
_designPlayroom(rows) {
@@ -1051,7 +1142,7 @@ class TaskMatePointsDisplayCardEditor extends LitElement {
.preset-swatch { width: 22px; height: 22px; border-radius: 50%; cursor: pointer; border: 2px solid var(--divider-color, #e0e0e0); transition: transform 0.1s; padding: 0; }
.preset-swatch:hover { transform: scale(1.15); }
.preset-swatch.active { border-color: var(--primary-text-color); box-shadow: 0 0 0 2px var(--primary-color); }
.colour-reset { font-size: 0.78rem; color: var(--secondary-text-color); background: none; border: 1px solid var(--divider-color, #e0e0e0); border-radius: 4px; padding: 4px 10px; cursor: pointer; margin-left: auto; }
.colour-reset { font-size: 0.78rem; color: var(--secondary-text-color); background: none; border: 1px solid var(--divider-color, #e0e0e0); border-radius: 4px; padding: 4px 10px; cursor: pointer; margin-inline-start: auto; }
.colour-helper { color: var(--secondary-text-color); font-size: 0.82rem; line-height: 1.3; }
`;
}
File diff suppressed because it is too large Load Diff
@@ -180,7 +180,7 @@ class TaskMateReorderCard extends LitElement {
}
.time-category-header .count {
margin-left: auto;
margin-inline-start: auto;
font-size: 0.85em;
color: var(--secondary-text-color);
background: var(--card-background-color);
@@ -192,7 +192,7 @@ class TaskMateReorderCard extends LitElement {
display: flex;
flex-direction: column;
gap: 8px;
padding-left: 8px;
padding-inline-start: 8px;
}
.chore-item {
@@ -441,10 +441,10 @@ class TaskMateReorderCard extends LitElement {
NOTE: no ha-card { padding } rule — body padding comes from .tmd-bd.
══════════════════════════════════════════════════════════════════ */
.d-childchip { display: flex; align-items: center; gap: 5px; margin-left: auto;
.d-childchip { display: flex; align-items: center; gap: 5px; margin-inline-start: auto;
font-weight: 800; font-size: 12px; color: #fff;
background: rgba(255,255,255,.25); border-radius: 999px;
padding: 3px 10px 3px 3px; white-space: nowrap; }
padding-block: 3px; padding-inline: 3px 10px; white-space: nowrap; }
:host([data-tm-design="console"]) .d-childchip { border-radius: 6px; font-weight: 700; font-size: 11px;
background: var(--tmd-surface-2);
border: 1px solid color-mix(in srgb, var(--ac, var(--tmd-accent)) 55%, transparent);
@@ -520,9 +520,12 @@ class TaskMateReorderCard extends LitElement {
throw new Error("Please define a child_id");
}
this.config = {
title: "Reorder Chores",
title: "",
...config,
};
// Dashboards saved before #1010 stored the English default title; treat it
// as unset so the translated default is shown instead.
if (this.config.title === "Reorder Chores") this.config.title = "";
}
getCardSize() {
@@ -537,7 +540,6 @@ class TaskMateReorderCard extends LitElement {
return {
entity: "sensor.taskmate_overview",
child_id: "",
title: "Reorder Chores",
};
}
@@ -1199,7 +1201,7 @@ class TaskMateReorderCardEditor extends LitElement {
.preset-swatch { width: 22px; height: 22px; border-radius: 50%; cursor: pointer; border: 2px solid var(--divider-color, #e0e0e0); transition: transform 0.1s; padding: 0; }
.preset-swatch:hover { transform: scale(1.15); }
.preset-swatch.active { border-color: var(--primary-text-color); box-shadow: 0 0 0 2px var(--primary-color); }
.colour-reset { font-size: 0.78rem; color: var(--secondary-text-color); background: none; border: 1px solid var(--divider-color, #e0e0e0); border-radius: 4px; padding: 4px 10px; cursor: pointer; margin-left: auto; }
.colour-reset { font-size: 0.78rem; color: var(--secondary-text-color); background: none; border: 1px solid var(--divider-color, #e0e0e0); border-radius: 4px; padding: 4px 10px; cursor: pointer; margin-inline-start: auto; }
.colour-helper { color: var(--secondary-text-color); font-size: 0.82rem; line-height: 1.3; }
`;
}
@@ -13,6 +13,16 @@ const html = LitElement.prototype.html;
const css = LitElement.prototype.css;
const _safeColor = (c, d) => (typeof c === "string" && /^#[0-9a-fA-F]{3,8}$/.test(c) ? c : d);
// Keeps number runs like "3 / 1" reading left to right in RTL text (#995);
// identity until the design layer has loaded (it is also what stamps dir).
const _ltrNums = (s) => (window.__taskmate_design && window.__taskmate_design.ltrNums ? window.__taskmate_design.ltrNums(s) : s);
// "a / b" for a template: the same text nodes as a literal `${a} / ${b}` in
// LTR (so it renders pixel-for-pixel as before), the isolated string in RTL.
const _ltrRatio = (a, b) => {
const s = `${a} / ${b}`;
const r = _ltrNums(s);
return r === s ? html`${a} / ${b}` : r;
};
class TaskMateRewardProgressCard extends LitElement {
static get properties() {
@@ -158,7 +168,7 @@ class TaskMateRewardProgressCard extends LitElement {
}
.child-progress-cost {
text-align: right;
text-align: end;
flex-shrink: 0;
}
@@ -207,7 +217,7 @@ class TaskMateRewardProgressCard extends LitElement {
.big-progress-fill::after {
content: '';
position: absolute;
top: 0; left: -100%;
top: 0; inset-inline-start: -100%;
width: 60%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent);
@@ -215,8 +225,8 @@ class TaskMateRewardProgressCard extends LitElement {
}
@keyframes shimmer {
0% { left: -100%; }
100% { left: 200%; }
0% { inset-inline-start: -100%; }
100% { inset-inline-start: 200%; }
}
.big-progress-fill.affordable {
@@ -367,7 +377,7 @@ class TaskMateRewardProgressCard extends LitElement {
gap: 6px;
background: var(--card-background-color, #fff);
border-radius: 20px;
padding: 4px 10px 4px 6px;
padding-block: 4px; padding-inline: 6px 10px;
font-size: 0.82rem;
font-weight: 600;
color: var(--primary-text-color);
@@ -413,7 +423,7 @@ class TaskMateRewardProgressCard extends LitElement {
:host([data-tm-design="console"]) .rp-hero-emoji {
filter: drop-shadow(0 0 18px color-mix(in srgb, var(--tmd-accent) 70%, transparent)); }
.rp-name { font-size: 24px; color: var(--tmd-accent); }
.rp-desc { font-size: 13px; margin-top: 4px; max-width: 280px; margin-left: auto; margin-right: auto; }
.rp-desc { font-size: 13px; margin-top: 4px; max-width: 280px; margin-inline-start: auto; margin-inline-end: auto; }
.rp-jackpot { margin-top: 8px; background: var(--tmd-gold); color: #3a2e26;
border-color: transparent; font-weight: 800; text-transform: uppercase;
letter-spacing: 0.04em; font-size: 11px; }
@@ -430,7 +440,7 @@ class TaskMateRewardProgressCard extends LitElement {
.rp-need-c { font-weight: 800; font-size: 15px; margin-top: 2px; }
.rp-stat { justify-content: space-between; margin-top: 10px; }
.rp-stat .rp-pct { font-size: 34px; }
.rp-stat-r { text-align: right; }
.rp-stat-r { text-align: end; }
.rp-frac { font-size: 18px; }
.rp-need { font-size: 12px; }
.rp-kids { grid-template-columns: repeat(var(--n, 3), 1fr); gap: 9px; margin-top: 16px; }
@@ -440,7 +450,7 @@ class TaskMateRewardProgressCard extends LitElement {
flex-direction: column; align-items: center; gap: 4px; }
.rp-kid .av { margin: 0 auto 2px; }
.rp-kid-pts { font-size: 20px; color: var(--tmd-accent); }
.rp-kid-pts span { font-size: 12px; margin-left: 1px; }
.rp-kid-pts span { font-size: 12px; margin-inline-start: 1px; }
.rp-kid-name { font-size: 12px; font-weight: 800; }
.rp-kid-bar { width: 100%; height: 7px; margin-top: 3px; }
`;
@@ -612,7 +622,7 @@ class TaskMateRewardProgressCard extends LitElement {
<div class="big-progress-fill ${cls}" style="width: ${pct}%"></div>
</div>
<div class="progress-stat-row">
<span class="progress-have">${have} / ${cost} ${pointsName}</span>
<span class="progress-have">${_ltrRatio(have, cost)} ${pointsName}</span>
${canAfford
? html`<span class="progress-need">${this._t('reward_progress.ready_to_claim_emoji')}</span>`
: html`<span class="progress-need">${this._t('reward_progress.more_needed', { amount: cost - have })}</span>`}
@@ -678,7 +688,7 @@ class TaskMateRewardProgressCard extends LitElement {
<div class="big-progress-fill ${cls}" style="width: ${pct}%"></div>
</div>
<div class="progress-stat-row">
<span class="progress-have">${totalHave} / ${cost} ${pointsName}</span>
<span class="progress-have">${_ltrRatio(totalHave, cost)} ${pointsName}</span>
${canAfford
? html`<span class="progress-need">${this._t('reward_progress.ready')}</span>`
: html`<span class="progress-need">${this._t('reward_progress.more_needed', { amount: cost - totalHave })}</span>`}
@@ -816,7 +826,7 @@ class TaskMateRewardProgressCard extends LitElement {
? html`<div class="row rp-stat">
<div class="big rp-pct">${pct}%</div>
<div class="rp-stat-r">
<div class="num rp-frac">${have} / ${cost}</div>
<div class="num rp-frac">${_ltrRatio(have, cost)}</div>
<div class="muted rp-need">${this._t('reward_progress.more_needed', { amount: remaining })}</div>
</div>
</div>`
@@ -865,7 +875,7 @@ class TaskMateRewardProgressCardEditor extends LitElement {
.preset-swatch { width: 22px; height: 22px; border-radius: 50%; cursor: pointer; border: 2px solid var(--divider-color, #e0e0e0); transition: transform 0.1s; padding: 0; }
.preset-swatch:hover { transform: scale(1.15); }
.preset-swatch.active { border-color: var(--primary-text-color); box-shadow: 0 0 0 2px var(--primary-color); }
.colour-reset { font-size: 0.78rem; color: var(--secondary-text-color); background: none; border: 1px solid var(--divider-color, #e0e0e0); border-radius: 4px; padding: 4px 10px; cursor: pointer; margin-left: auto; }
.colour-reset { font-size: 0.78rem; color: var(--secondary-text-color); background: none; border: 1px solid var(--divider-color, #e0e0e0); border-radius: 4px; padding: 4px 10px; cursor: pointer; margin-inline-start: auto; }
.colour-helper { color: var(--secondary-text-color); font-size: 0.82rem; line-height: 1.3; }
`;
}
@@ -23,6 +23,16 @@ const css = LitElement.prototype.css;
const tmClaimList = (v) => (Array.isArray(v) ? v : []);
const _safeColor = (c, d) => (typeof c === "string" && /^#[0-9a-fA-F]{3,8}$/.test(c) ? c : d);
// Keeps number runs like "3 / 1" reading left to right in RTL text (#995);
// identity until the design layer has loaded (it is also what stamps dir).
const _ltrNums = (s) => (window.__taskmate_design && window.__taskmate_design.ltrNums ? window.__taskmate_design.ltrNums(s) : s);
// "a / b" for a template: the same text nodes as a literal `${a} / ${b}` in
// LTR (so it renders pixel-for-pixel as before), the isolated string in RTL.
const _ltrRatio = (a, b) => {
const s = `${a} / ${b}`;
const r = _ltrNums(s);
return r === s ? html`${a} / ${b}` : r;
};
class TaskMateRewardsCard extends LitElement {
static get properties() {
@@ -287,7 +297,7 @@ class TaskMateRewardsCard extends LitElement {
content: '';
position: absolute;
top: 0;
left: -100%;
inset-inline-start: -100%;
width: 50%;
height: 100%;
background: linear-gradient(
@@ -300,8 +310,8 @@ class TaskMateRewardsCard extends LitElement {
}
@keyframes shimmer {
0% { left: -100%; }
100% { left: 200%; }
0% { inset-inline-start: -100%; }
100% { inset-inline-start: 200%; }
}
.progress-text {
@@ -310,7 +320,7 @@ class TaskMateRewardsCard extends LitElement {
color: var(--text-secondary);
white-space: nowrap;
min-width: 0;
text-align: right;
text-align: end;
}
/* Jackpot reward styles */
@@ -325,8 +335,8 @@ class TaskMateRewardsCard extends LitElement {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
inset-inline-start: 0;
inset-inline-end: 0;
bottom: 0;
background: linear-gradient(
45deg,
@@ -393,7 +403,7 @@ class TaskMateRewardsCard extends LitElement {
content: '';
position: absolute;
top: 0;
left: -100%;
inset-inline-start: -100%;
width: 100%;
height: 100%;
background: linear-gradient(
@@ -407,21 +417,21 @@ class TaskMateRewardsCard extends LitElement {
/* Shimmer 3 times then wait ~30s before repeating */
@keyframes jackpot-segment-shimmer {
0% { left: -100%; }
2.5% { left: 200%; }
5% { left: -100%; }
7.5% { left: 200%; }
10% { left: -100%; }
12.5% { left: 200%; }
15%, 100% { left: -100%; opacity: 0; }
0% { inset-inline-start: -100%; }
2.5% { inset-inline-start: 200%; }
5% { inset-inline-start: -100%; }
7.5% { inset-inline-start: 200%; }
10% { inset-inline-start: -100%; }
12.5% { inset-inline-start: 200%; }
15%, 100% { inset-inline-start: -100%; opacity: 0; }
}
.jackpot-segment:first-child {
border-radius: 9px 0 0 9px;
border-start-start-radius: 9px; border-start-end-radius: 0; border-end-end-radius: 0; border-end-start-radius: 9px;
}
.jackpot-segment:last-child {
border-radius: 0 9px 9px 0;
border-start-start-radius: 0; border-start-end-radius: 9px; border-end-end-radius: 9px; border-end-start-radius: 0;
}
.jackpot-segment:only-child {
@@ -546,7 +556,21 @@ class TaskMateRewardsCard extends LitElement {
/* Pending approval state */
.reward-row.pending-approval {
opacity: 0.6;
border-left: 3px solid #e67e22;
border-inline-start: 3px solid #e67e22;
}
/* Reject reason (#976) */
.reject-note {
margin-top: 6px;
padding: 4px 8px;
border-radius: 8px;
font-size: 0.75rem;
font-weight: 600;
line-height: 1.3;
white-space: normal;
overflow-wrap: anywhere;
background: rgba(244, 67, 54, 0.12);
color: var(--error-color, #d32f2f);
}
.pending-label {
@@ -747,14 +771,14 @@ class TaskMateRewardsCard extends LitElement {
font-weight: 700;
font-size: 1.15rem;
color: var(--text-primary);
margin-left: auto;
margin-inline-start: auto;
}
.spendable-banner .spendable-of {
font-weight: 400;
color: var(--text-secondary);
font-size: 0.82rem;
margin-left: 4px;
margin-inline-start: 4px;
}
/* Pool Mode controls — render BELOW the progress bar, full width */
@@ -801,7 +825,7 @@ class TaskMateRewardsCard extends LitElement {
display: flex;
align-items: stretch;
gap: 6px;
margin-left: auto;
margin-inline-start: auto;
flex: 0 0 auto;
}
.deposit-custom input {
@@ -941,7 +965,7 @@ class TaskMateRewardsCard extends LitElement {
flex-direction: column;
gap: 6px;
font-size: 0.9rem;
text-align: left;
text-align: start;
}
.dialog-points-row {
display: flex;
@@ -995,6 +1019,12 @@ class TaskMateRewardsCard extends LitElement {
.rw-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
.rw-name { font-weight: 800; font-size: 15px; }
.rw-desc { font-size: 12px; }
.rw-reject {
margin: 2px 0 6px; padding: 4px 8px; border-radius: 8px;
font-size: 12px; font-weight: 700; line-height: 1.3;
white-space: normal; overflow-wrap: anywhere;
background: color-mix(in srgb, var(--tmd-bad, #e74c3c) 14%, transparent); color: var(--tmd-bad, #e74c3c);
}
.rw-avail { align-self: flex-start; font-size: 11px; }
.rw-avail-warn { background: color-mix(in srgb, var(--tmd-warn) 16%, transparent);
color: var(--tmd-warn); }
@@ -1010,7 +1040,7 @@ class TaskMateRewardsCard extends LitElement {
.rw-status { font-size: 12px; }
.rw-status-good { color: var(--tmd-good); font-weight: 800; }
.rw-pool-btns { gap: 6px; flex-wrap: wrap; align-items: center; }
.rw-pool-btns .deposit-custom { margin-left: auto; gap: 6px; }
.rw-pool-btns .deposit-custom { margin-inline-start: auto; gap: 6px; }
.rw-deposit-input {
width: 70px;
min-width: 0;
@@ -1047,6 +1077,9 @@ class TaskMateRewardsCard extends LitElement {
header_color: '#e67e22',
...config,
};
// Dashboards saved before #1010 stored the English default title; treat it
// as unset so the translated default is shown instead.
if (this.config.title === "Rewards") this.config.title = "";
}
getCardSize() {
@@ -1060,7 +1093,6 @@ class TaskMateRewardsCard extends LitElement {
static getStubConfig() {
return {
entity: "sensor.taskmate_overview",
title: "Rewards",
child_id: null,
show_child_badges: true,
enable_pool_mode: false,
@@ -1287,8 +1319,10 @@ class TaskMateRewardsCard extends LitElement {
<ha-icon icon="${pointsIcon}"></ha-icon>
<span class="spendable-label">${this._t('rewards.spendable_balance')}</span>
<span class="spendable-value">
${spendable}
${detail}
${spendable !== gross && this.getAttribute("dir") === "rtl"
// "12 / 40" spans two elements, so isolate it with markup (#995).
? html`<bdi dir="ltr">${spendable} ${detail}</bdi>`
: html`${spendable} ${detail}`}
</span>
</div>
`;
@@ -1437,7 +1471,7 @@ class TaskMateRewardsCard extends LitElement {
<ha-icon icon="mdi:clock-outline"></ha-icon>
${this._t('rewards.awaiting_approval')}
</div>
` : ''}
` : this._renderRejectNote(reward, childId ? relevantChild : null, 'reject-note')}
${showChildBadges && !isJackpot
? html`
@@ -1472,6 +1506,17 @@ class TaskMateRewardsCard extends LitElement {
`;
}
/**
* Reject reasons (#976): why a parent turned down this child's last claim.
* Only with a child in context — the reason is personal. Called from the
* classic row AND _designRewardRow, so every design shows it.
*/
_renderRejectNote(reward, child, cls) {
const rej = ((child && child.rejections) || []).find(r => r.kind === "reward" && r.id === reward.id);
if (!rej || !rej.reason) return '';
return html`<div class="${cls}" role="note">↩️ ${this._t('rewards.rejected_note', { reason: rej.reason })}</div>`;
}
/** "Available Fri, Sat · 18:00–21:00" for a time-locked reward (#857). */
_timeLockLabel(reward) {
const summary = window.__taskmate_time_lock_label
@@ -2060,7 +2105,7 @@ class TaskMateRewardsCard extends LitElement {
? html`<span class="muted rw-status">⏳ ${this._t('rewards.awaiting_approval')}</span>`
: d.canAfford && d.showClaim
? html`<span class="rw-status rw-status-good">${this._t('reward_progress.ready_to_claim')}</span>`
: html`<span class="muted rw-status">${d.currentStars} / ${d.displayCost}</span>`;
: html`<span class="muted rw-status">${_ltrRatio(d.currentStars, d.displayCost)}</span>`;
return html`
<div class="rw-card ${d.isJackpot ? 'rw-jackpot' : ''} ${d.isUnavailable || d.isTimeLocked ? 'rw-unavail' : ''}"
@@ -2076,6 +2121,7 @@ class TaskMateRewardsCard extends LitElement {
</div>
${costBadge}
</div>
${d.hasPendingClaim ? '' : this._renderRejectNote(reward, d.childId ? d.relevantChild : null, 'rw-reject')}
${bar}
${d.isJackpot ? html`
<div class="row rw-pool-foot">
@@ -2231,7 +2277,7 @@ class TaskMateRewardsCardEditor extends LitElement {
border-radius: 4px;
padding: 4px 10px;
cursor: pointer;
margin-left: auto;
margin-inline-start: auto;
}
.colour-helper {
color: var(--secondary-text-color);
@@ -69,6 +69,72 @@ class TaskMateRoutineCard extends LitElement {
return { entity: "sensor.taskmate_overview", child_id: "", time_category: "morning" };
}
updated(changedProps) {
super.updated?.(changedProps);
this._scheduleUndoExpiry();
}
disconnectedCallback() {
super.disconnectedCallback();
clearTimeout(this._undoExpiryTimer);
this._undoExpiryTimer = null;
}
// ── Child undo (#918) ───────────────────────────────────────────────────
// A step finished in this run can be taken back while the server says so:
// child_undo_pending (not yet reviewed) or child_undo_until (auto-approved,
// until that instant). Neither is set while the global window is 0.
_undoableCompletion(chore, now = Date.now()) {
const child = this._child();
if (!child || !chore) return null;
const latest = (this._attrs().todays_completions || [])
.filter(c => String(c.child_id) === String(child.id)
&& String(c.chore_id) === String(chore.id) && !c.bonus_subtask_id)
.sort((a, b) => new Date(b.completed_at || 0) - new Date(a.completed_at || 0))[0];
if (!latest || !(latest.completion_id || latest.id)) return null;
if (latest.child_undo_pending === true) return latest;
const until = Date.parse(latest.child_undo_until || "");
return Number.isFinite(until) && until > now ? latest : null;
}
/** Re-render when the soonest undo window closes, so its button goes away. */
_scheduleUndoExpiry() {
clearTimeout(this._undoExpiryTimer);
this._undoExpiryTimer = null;
const child = this._child?.();
if (!child) return;
const now = Date.now();
const deadlines = (this._attrs().todays_completions || [])
.filter(c => String(c.child_id) === String(child.id))
.map(c => Date.parse(c.child_undo_until || ""))
.filter(t => Number.isFinite(t) && t > now);
if (!deadlines.length) return;
this._undoExpiryTimer = setTimeout(() => {
this._undoExpiryTimer = null;
this.requestUpdate();
}, Math.min(Math.min(...deadlines) - now + 50, 2 ** 31 - 1));
}
async _undo(chore, completion) {
if (this._busy) return;
this._busy = true;
try {
// The service takes the child from the stored completion itself.
await this.hass.callService("taskmate", "undo_chore", {
completion_id: completion.completion_id || completion.id,
});
this._runCompleted.delete(String(chore.id));
} catch (err) {
this.dispatchEvent(new CustomEvent("hass-notification", {
detail: { message: String(err?.message || err) }, bubbles: true, composed: true,
}));
} finally {
this._busy = false;
this.requestUpdate();
}
}
shouldUpdate(changedProps) {
if (changedProps.has("hass")) {
return window.__taskmate_hasChanged
@@ -294,7 +360,17 @@ class TaskMateRoutineCard extends LitElement {
${done
? html`<button class="rt-btn rt-done is-done" disabled>
${done.pending ? this._t("routine.sent_for_checking") : this._t("routine.completed")}
</button>`
</button>
${(() => {
const undoable = this._undoableCompletion(chore);
return undoable
? html`<button class="rt-btn rt-undo" ?disabled=${this._busy}
@click=${() => this._undo(chore, undoable)}>
<ha-icon class="tm-rtl-flip" icon="mdi:undo-variant"></ha-icon>
${this._t("child.undo_named", { name: chore.name })}
</button>`
: "";
})()}`
: html`<button class="rt-btn rt-done" ?disabled=${this._busy}
@click=${() => this._complete(chore)}>
${this._t("routine.done")}
@@ -508,6 +584,15 @@ class TaskMateRoutineCard extends LitElement {
background: var(--tmd-dim, var(--disabled-text-color, #9ca3af));
}
:host([data-tm-design]:not([data-tm-design="classic"])) .rt-done.is-done { color: #fff; }
.rt-undo {
display: flex; align-items: center; justify-content: center; gap: 8px;
background: transparent;
color: var(--tmd-text, var(--primary-text-color));
border: 2px solid var(--routine-accent, var(--tmd-accent, #7c3aed));
padding: 12px; font-size: 15px; min-height: 44px;
}
.rt-undo ha-icon { --mdc-icon-size: 20px; }
.rt-undo:disabled { opacity: 0.5; }
.rt-row { display: flex; gap: 10px; }
.rt-skip {
background: var(--tmd-surface-2, var(--divider-color, #e5e7eb));
@@ -132,6 +132,18 @@ class TaskMateStreakCard extends LitElement {
.streak-emoji { font-size: 1.4rem; }
/* Streak freeze tokens (#925) */
.streak-freeze {
margin-inline-start: auto;
padding: 2px 9px;
border-radius: 999px;
font-size: 0.8rem;
font-weight: 700;
white-space: nowrap;
background: rgba(52, 152, 219, 0.14);
color: #1f6fa8;
}
/* Streak bar - visual days */
.streak-days {
display: flex;
@@ -247,6 +259,9 @@ class TaskMateStreakCard extends LitElement {
.sk-seg-cp { flex: 1; height: 9px; border-radius: 3px; background: var(--tmd-surface-2); }
.sk-seg-cp.on { background: var(--tmd-good); }
.sk-badges-cp { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 9px; }
/* Streak freeze tokens (#925), every designed layout */
.sk-freeze { white-space: nowrap; flex: none; }
`;
const tokens = window.__taskmate_design && window.__taskmate_design.styles
? window.__taskmate_design.styles() : null;
@@ -343,6 +358,7 @@ class TaskMateStreakCard extends LitElement {
<span class="streak-number ${streakClass}">${streak}</span>
<span class="streak-label">${this._t('streak.day_streak')}</span>
<span class="streak-emoji">${streakEmoji}</span>
${this._freezeChip(child, "streak-freeze")}
</div>
</div>
</div>
@@ -370,6 +386,20 @@ class TaskMateStreakCard extends LitElement {
`;
}
/**
* Streak-freeze tokens (#925): "❄️ N". The sensor only sends
* streak_freezes while the feature is on, so an absent field means
* "not in use" and renders nothing — a 0 still shows, so a child can
* see they have none left. Used by the classic tile AND every designed
* layout (the two-render-paths rule).
*/
_freezeChip(child, cls) {
const n = child.streak_freezes;
if (typeof n !== "number") return "";
return html`<span class="${cls}" title="${this._t('streak.freezes_tooltip')}"
aria-label="${this._t('streak.freezes_aria', { count: n })}">❄️ ${n}</span>`;
}
_calculateStreak(completions) {
const tz = this.hass?.config?.time_zone || Intl.DateTimeFormat().resolvedOptions().timeZone;
@@ -577,6 +607,7 @@ class TaskMateStreakCard extends LitElement {
<div class="muted sk-sub">${this._t('streak.day_streak')}</div>
</div>
<div class="big sk-count">${r.streak} ${this._streakEmoji(r.streak)}</div>
${this._freezeChip(r.child, "chip soft sk-freeze")}
</div>
<div class="sk-dots">
${r.dayDots.map((d) => html`<i class="sk-dot ${d.cssClass !== "inactive" ? "on" : ""}" title="${d.label}"></i>`)}
@@ -598,6 +629,7 @@ class TaskMateStreakCard extends LitElement {
<div class="muted sk-combo-label">${this._t('streak.achievements')}</div>
</div>
<div class="num sk-combo">x${r.streak} ${this._streakEmoji(r.streak)}</div>
${this._freezeChip(r.child, "chip sk-freeze")}
</div>
<div class="sk-segs">
${r.dayDots.map((d) => html`<i class="sk-seg ${d.cssClass !== "inactive" ? "on" : ""}"></i>`)}
@@ -618,6 +650,7 @@ class TaskMateStreakCard extends LitElement {
${this._av(r.child, r.tone, 34)}
<div style="flex:1;min-width:0"><div style="font-weight:600">${r.child.name}</div></div>
<div class="num" style="color:${tone(r.streak)}">${this._streakEmoji(r.streak)} ${this._t('common.d_streak', { count: r.streak })}</div>
${this._freezeChip(r.child, "chip sk-freeze")}
</div>
<div class="sk-segs-cp">
${r.dayDots.map((d) => html`<i class="sk-seg-cp ${d.cssClass !== "inactive" ? "on" : ""}"></i>`)}
@@ -654,7 +687,7 @@ class TaskMateStreakCardEditor extends LitElement {
.preset-swatch { width: 22px; height: 22px; border-radius: 50%; cursor: pointer; border: 2px solid var(--divider-color, #e0e0e0); transition: transform 0.1s; padding: 0; }
.preset-swatch:hover { transform: scale(1.15); }
.preset-swatch.active { border-color: var(--primary-text-color); box-shadow: 0 0 0 2px var(--primary-color); }
.colour-reset { font-size: 0.78rem; color: var(--secondary-text-color); background: none; border: 1px solid var(--divider-color, #e0e0e0); border-radius: 4px; padding: 4px 10px; cursor: pointer; margin-left: auto; }
.colour-reset { font-size: 0.78rem; color: var(--secondary-text-color); background: none; border: 1px solid var(--divider-color, #e0e0e0); border-radius: 4px; padding: 4px 10px; cursor: pointer; margin-inline-start: auto; }
.colour-helper { color: var(--secondary-text-color); font-size: 0.82rem; line-height: 1.3; }
`;
}
@@ -752,7 +752,7 @@ class TaskMateWeeklyCardEditor extends LitElement {
.preset-swatch { width: 22px; height: 22px; border-radius: 50%; cursor: pointer; border: 2px solid var(--divider-color, #e0e0e0); transition: transform 0.1s; padding: 0; }
.preset-swatch:hover { transform: scale(1.15); }
.preset-swatch.active { border-color: var(--primary-text-color); box-shadow: 0 0 0 2px var(--primary-color); }
.colour-reset { font-size: 0.78rem; color: var(--secondary-text-color); background: none; border: 1px solid var(--divider-color, #e0e0e0); border-radius: 4px; padding: 4px 10px; cursor: pointer; margin-left: auto; }
.colour-reset { font-size: 0.78rem; color: var(--secondary-text-color); background: none; border: 1px solid var(--divider-color, #e0e0e0); border-radius: 4px; padding: 4px 10px; cursor: pointer; margin-inline-start: auto; }
.colour-helper { color: var(--secondary-text-color); font-size: 0.82rem; line-height: 1.3; }
`;
}
@@ -0,0 +1,973 @@
/**
* TaskMate Wishlist Card (#932)
*
* One child's wishlist: things they want, what they have saved towards each,
* and what the family has pledged. The child adds a wish (it waits for a
* grown-up), moves points in, takes their own savings back out, and asks to
* redeem once it's funded — which becomes an ordinary reward claim for the
* parent. Pledges are entered by a parent in the admin panel.
*
* Reads the overview sensor (children, points name/icon) plus the child's own
* wishlist sensor, found by its `wishlist_child_id` attribute. Pictures are
* stored images, signed per viewer through auth/sign_path before use.
*
* Single layout, so it consumes the design tokens directly (like the family
* goal card) rather than carrying a second _renderDesigned() template. Every
* --tmd-* reference has the card's original value as its fallback, so classic
* — which defines no tokens — renders as designed. Layout classes carry a
* wl- prefix because the shared design kit defines its own .btn / .bar.
*/
const LitElement = customElements.get("hui-masonry-view")
? Object.getPrototypeOf(customElements.get("hui-masonry-view"))
: Object.getPrototypeOf(customElements.get("hui-view"));
const html = LitElement.prototype.html;
const css = LitElement.prototype.css;
const _safeColor = (c, d) => (typeof c === "string" && /^#[0-9a-fA-F]{3,8}$/.test(c) ? c : d);
// Keeps number runs like "3 / 1" reading left to right in RTL text (#995);
// identity until the design layer has loaded (it is also what stamps dir).
const _ltrNums = (s) => (window.__taskmate_design && window.__taskmate_design.ltrNums ? window.__taskmate_design.ltrNums(s) : s);
// "a / b" for a template: the same text nodes as a literal `${a} / ${b}` in
// LTR (so it renders pixel-for-pixel as before), the isolated string in RTL.
const _ltrRatio = (a, b) => {
const s = `${a} / ${b}`;
const r = _ltrNums(s);
return r === s ? html`${a} / ${b}` : r;
};
const DEFAULT_ACCENT = "#e91e63";
const IMAGE_PREFIX = "/api/taskmate/image/";
const TARGET_STEP = 10;
const MAX_TARGET = 100000;
/**
* A wish link is typed by a child. Only an absolute http(s) URL is ever
* rendered as a link — never javascript:, data: or a relative path — even
* though the backend already refuses anything else.
*/
function safeLink(value) {
if (typeof value !== "string" || !value) return null;
try {
const url = new URL(value);
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
return { href: url.href, host: url.hostname.replace(/^www\./, "") };
} catch (_e) {
return null;
}
}
class TaskMateWishlistCard extends LitElement {
static get properties() {
return {
hass: { type: Object },
config: { type: Object },
_sheet: { state: true },
_signed: { state: true },
};
}
constructor() {
super();
this._sheet = null;
this._signed = {};
this._inflight = new Set();
}
setConfig(config) {
if (!config || !config.entity) {
throw new Error("A TaskMate overview entity is required");
}
this.config = config;
}
static getStubConfig() {
return { entity: "sensor.taskmate_overview" };
}
static getConfigElement() {
return document.createElement("taskmate-wishlist-card-editor");
}
getCardSize() {
return 5;
}
shouldUpdate(changedProps) {
if (changedProps.has("hass")) {
const old = changedProps.get("hass");
const wlId = this._wishlistEntityId();
if (old && wlId && old.states && this.hass.states[wlId] !== old.states[wlId]) return true;
return window.__taskmate_hasChanged
? window.__taskmate_hasChanged(old, this.hass, this.config && this.config.entity)
: true;
}
return true;
}
_t(key, params) {
const fn = window.__taskmate_localize;
return fn ? fn(this.hass, key, params) : key;
}
/**
* Resolve the active design, stamp data-tm-design on the host, and set the
* accent. The accent is an inline host property, not a template <style>: a
* shadow tree's <style> is ordered before adoptedStyleSheets, so it would
* lose to the :host default in static styles().
*/
_applyDesign() {
if (window.__taskmate_design) {
window.__taskmate_design.apply(this, this.hass, this.config, this.config.entity);
}
const configured = this.config.header_color;
if (typeof configured === "string" && /^#[0-9a-fA-F]{3,8}$/.test(configured)) {
this.style.setProperty("--wl-accent", _safeColor(configured, DEFAULT_ACCENT));
} else {
this.style.removeProperty("--wl-accent");
}
}
// ── data ────────────────────────────────────────────────────────────────
/** The child's own wishlist sensor, found by the child id it carries. */
_wishlistEntityId() {
const childId = this.config && this.config.child_id;
if (!childId || !this.hass || !this.hass.states) return null;
const cached = this._wlEntityId;
const cachedState = cached && this.hass.states[cached];
if (cachedState && cachedState.attributes && cachedState.attributes.wishlist_child_id === childId) return cached;
this._wlEntityId = null;
for (const [id, st] of Object.entries(this.hass.states)) {
if (id.startsWith("sensor.") && st && st.attributes && st.attributes.wishlist_child_id === childId) {
this._wlEntityId = id;
break;
}
}
return this._wlEntityId;
}
_wishlist() {
const id = this._wishlistEntityId();
const st = id && this.hass.states[id];
const attrs = (st && st.attributes) || {};
return {
wishes: Array.isArray(attrs.wishes) ? attrs.wishes : [],
max: Number(attrs.wish_max_open) || 5,
};
}
_pledged(w) {
return Number(w.pledged) || 0;
}
_remaining(w) {
return Math.max(0, (Number(w.target) || 0) - (Number(w.saved) || 0) - this._pledged(w));
}
_funded(w) {
return (Number(w.target) || 0) > 0 && this._remaining(w) === 0;
}
// ── pictures ────────────────────────────────────────────────────────────
async _ensureSigned(wishes) {
for (const w of wishes) {
const url = w.image_url;
// Only our own stored images are signed; nothing else is ever handed to
// auth/sign_path or rendered as a picture.
if (typeof url !== "string" || !url.startsWith(IMAGE_PREFIX)) continue;
if (this._signed[url] || this._inflight.has(url)) continue;
this._inflight.add(url);
try {
const res = await this.hass.callWS({ type: "auth/sign_path", path: url, expires: 3600 });
if (res && res.path) this._signed = { ...this._signed, [url]: res.path };
} catch (e) {
console.warn("taskmate: sign_path failed", e);
} finally {
this._inflight.delete(url);
}
}
}
updated() {
if (this.hass && this.config) this._ensureSigned(this._wishlist().wishes);
}
// ── actions ─────────────────────────────────────────────────────────────
async _call(service, data) {
this._sheet = this._sheet ? { ...this._sheet, busy: true, error: "" } : null;
try {
await this.hass.callService("taskmate", service, { child_id: this.config.child_id, ...data });
this._closeSheet();
return true;
} catch (err) {
const message = (err && err.message) || String(err);
if (this._sheet) {
this._sheet = { ...this._sheet, busy: false, error: this._t("wishlist.error", { error: message }) };
} else {
this._flash = this._t("wishlist.error", { error: message });
this.requestUpdate();
}
return false;
}
}
_openSheet(sheet) {
this._flash = "";
this._sheet = { busy: false, error: "", ...sheet };
}
_closeSheet() {
if (this._sheet && this._sheet.previewUrl) URL.revokeObjectURL(this._sheet.previewUrl);
this._sheet = null;
}
_openAdd() {
this._openSheet({ kind: "add", name: "", target: 50, link: "" });
}
_openMove(w, spendable) {
const max = Math.min(spendable, this._remaining(w));
this._openSheet({ kind: "move", id: w.id, amount: Math.min(max, 50) });
}
_openTake(w) {
this._openSheet({ kind: "take", id: w.id, amount: Number(w.saved) || 0 });
}
_setSheet(patch) {
this._sheet = { ...this._sheet, ...patch, error: "" };
}
_stepTarget(delta) {
const next = Math.min(MAX_TARGET, Math.max(1, (Number(this._sheet.target) || 0) + delta));
this._setSheet({ target: next });
}
_pickPicture() {
const input = this.renderRoot && this.renderRoot.querySelector("#wl-file");
if (input) {
input.value = "";
input.click();
}
}
/** Downscale to a small JPEG in the browser: the server keeps it as-is. */
_onPicture(e) {
const file = e.target.files && e.target.files[0];
e.target.value = "";
if (!file || !this._sheet) return;
if (!file.type || !file.type.startsWith("image/")) {
this._sheet = { ...this._sheet, error: this._t("wishlist.picture_not_image") };
return;
}
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
const max = 512;
let { width, height } = img;
if (width > max || height > max) {
const scale = Math.min(max / width, max / height);
width = Math.round(width * scale);
height = Math.round(height * scale);
}
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
canvas.getContext("2d").drawImage(img, 0, 0, width, height);
URL.revokeObjectURL(url);
canvas.toBlob((blob) => {
if (!this._sheet) return;
if (!blob) {
this._sheet = { ...this._sheet, error: this._t("wishlist.picture_failed") };
return;
}
if (this._sheet.previewUrl) URL.revokeObjectURL(this._sheet.previewUrl);
this._sheet = { ...this._sheet, blob, previewUrl: URL.createObjectURL(blob), error: "" };
}, "image/jpeg", 0.82);
};
img.onerror = () => {
URL.revokeObjectURL(url);
if (this._sheet) this._sheet = { ...this._sheet, error: this._t("wishlist.picture_failed") };
};
img.src = url;
}
/** Post the picture to the photo endpoint (open to children); the wish takes it over. */
async _uploadPicture(blob) {
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", blob, "wish.jpg");
return fetch("/api/taskmate/photo", {
method: "POST",
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: fd,
});
};
if (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.auth && this.hass.auth.refreshAccessToken) {
await this.hass.auth.refreshAccessToken();
resp = await post();
}
if (!resp.ok) throw new Error(`upload failed (${resp.status})`);
const body = await resp.json();
if (!body || !body.photo_url) throw new Error("no photo_url");
return body.photo_url;
}
async _submitAdd() {
const s = this._sheet;
const name = (s.name || "").trim();
if (!name) return;
const link = (s.link || "").trim();
if (link && !safeLink(link)) {
this._sheet = { ...s, error: this._t("wishlist.link_invalid") };
return;
}
const data = { name, target: Number(s.target) || 1 };
if (link) data.link = link;
if (s.blob) {
this._sheet = { ...s, busy: true, error: "" };
try {
data.photo_url = await this._uploadPicture(s.blob);
} catch (_e) {
this._sheet = { ...this._sheet, busy: false, error: this._t("wishlist.picture_failed") };
return;
}
}
await this._call("add_wish", data);
}
// ── render ──────────────────────────────────────────────────────────────
render() {
if (!this.hass || !this.config) return html``;
this._applyDesign();
const entity = this.hass.states[this.config.entity];
if (!entity) {
return html`<ha-card><div class="wl-empty"><ha-icon icon="mdi:alert-circle"></ha-icon><div>${this._t("common.entity_not_found", { entity: this.config.entity })}</div></div></ha-card>`;
}
const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) || entity.attributes || {};
const children = Array.isArray(attrs.children) ? attrs.children : [];
const child = children.find((c) => c.id === this.config.child_id);
if (!this.config.child_id || !child) {
return html`<ha-card><div class="wl-empty"><ha-icon icon="mdi:heart-outline"></ha-icon><div>${this._t("wishlist.pick_child")}</div></div></ha-card>`;
}
const pointsIcon = attrs.points_icon || "mdi:star";
const spendable = Number(child.spendable_balance != null ? child.spendable_balance : child.points) || 0;
const { wishes, max } = this._wishlist();
const open = wishes.filter((w) => w.status === "pending" || w.status === "active" || w.status === "redeem_requested");
const reserved = wishes
.filter((w) => w.status === "active" || w.status === "redeem_requested")
.reduce((sum, w) => sum + (Number(w.saved) || 0), 0);
const atLimit = open.length >= max;
// Declined wishes sink to the bottom; the rest keep the order they were made in.
const ordered = [...wishes.filter((w) => w.status !== "declined"), ...wishes.filter((w) => w.status === "declined")];
const title = this.config.title || this._t("wishlist.title", { name: child.name });
return html`
<ha-card class="${this._sheet ? "wl-has-sheet" : ""}">
<div class="wl-header">
<div class="wl-hd-left">
<ha-icon class="wl-hd-icon" icon="mdi:heart"></ha-icon>
<div class="wl-hd-text">
<div class="wl-hd-title">${title}</div>
<div class="wl-hd-sub">${this._t("wishlist.subtitle")}</div>
</div>
</div>
<button class="wl-hd-pill" ?disabled=${atLimit}
title="${atLimit ? this._t("wishlist.limit_reached", { max }) : this._t("wishlist.add")}"
@click=${() => this._openAdd()}>
<ha-icon icon="mdi:plus"></ha-icon>${this._t("wishlist.add")}
</button>
</div>
<div class="wl-balance">
<ha-icon icon="mdi:piggy-bank-outline"></ha-icon>
<span>${this._t("wishlist.can_spend")}</span>
<b class="wl-amount"><ha-icon icon="${pointsIcon}"></ha-icon>${spendable}</b>
<span class="wl-reserved">${this._t("wishlist.saved_in_wishes", { points: reserved })}</span>
</div>
${this._flash ? html`<div class="wl-flash" role="alert">${this._flash}</div>` : ""}
<div class="wl-content">
${ordered.length === 0
? html`<div class="wl-empty wl-empty-list"><ha-icon icon="mdi:gift-outline"></ha-icon><div>${this._t("wishlist.empty")}</div></div>`
: ordered.map((w) => this._renderWish(w, spendable))}
</div>
${this._sheet ? this._renderSheet(wishes, spendable, pointsIcon) : ""}
</ha-card>
`;
}
_renderThumb(w) {
const src = this._signed[w.image_url];
return src
? html`<img class="wl-thumb" src="${src}" alt="" loading="lazy">`
: html`<div class="wl-thumb wl-thumb-ph"><ha-icon icon="mdi:gift"></ha-icon></div>`;
}
_renderWish(w, spendable) {
const target = Number(w.target) || 0;
const saved = Number(w.saved) || 0;
const pledged = this._pledged(w);
const remaining = this._remaining(w);
const funded = this._funded(w);
const link = safeLink(w.link);
const showProgress = w.status === "active" || w.status === "redeem_requested";
const cls = w.status === "pending" ? "pending"
: w.status === "declined" ? "declined"
: (funded || w.status === "redeem_requested") ? "funded" : "";
const pct = (n) => (target > 0 ? Math.max(0, Math.min(100, (n / target) * 100)) : 0);
return html`
<div class="wl-wish ${cls}" data-wish="${w.id}">
<div class="wl-wish-top">
${this._renderThumb(w)}
<div class="wl-grow">
<div class="wl-wish-name">${w.name}</div>
<div class="wl-wish-meta">
<span class="wl-tag"><ha-icon icon="mdi:flag-checkered"></ha-icon>${this._t("wishlist.target_tag", { points: target })}</span>
${link ? html`<a class="wl-link" href="${link.href}" target="_blank" rel="noopener noreferrer"
title="${this._t("wishlist.open_link")}"><ha-icon icon="mdi:link-variant"></ha-icon>${link.host}</a>` : ""}
</div>
</div>
</div>
${showProgress ? html`
<div class="wl-split" role="img" aria-label="${this._t("wishlist.progress_label", { total: Math.min(target, saved + pledged), target })}">
<i class="wl-me" style="width:${pct(saved)}%"></i>
<i class="wl-fam" style="width:${pct(Math.min(pledged, Math.max(0, target - saved)))}%"></i>
</div>
<div class="wl-legend">
<span><span class="wl-sw wl-sw-me"></span>${this._t("wishlist.legend_me", { points: saved })}</span>
<span><span class="wl-sw wl-sw-fam"></span>${this._t("wishlist.legend_family", { points: pledged })}</span>
<span class="wl-tot">${_ltrRatio(Math.min(target, saved + pledged), target)}</span>
</div>
${Array.isArray(w.pledges) && w.pledges.length ? html`
<div class="wl-pledges">
${w.pledges.map((p) => html`<span class="wl-pl"><ha-icon icon="mdi:heart"></ha-icon>${p.name} +${p.points}</span>`)}
</div>` : ""}
` : ""}
<div class="wl-acts">${this._renderActions(w, spendable, remaining, funded)}</div>
</div>
`;
}
_renderActions(w, spendable, remaining, funded) {
if (w.status === "pending") {
return html`
<span class="wl-status warn"><ha-icon icon="mdi:timer-sand"></ha-icon>${this._t("wishlist.status_pending")}</span>
<button class="wl-btn text" @click=${() => this._call("withdraw_wish", { wish_id: w.id })}>${this._t("wishlist.withdraw")}</button>`;
}
if (w.status === "declined") {
return html`
<span class="wl-status bad"><ha-icon icon="mdi:close-circle-outline"></ha-icon>${this._t("wishlist.status_declined")}</span>
${w.decline_reason ? html`<span class="wl-reason">${w.decline_reason}</span>` : ""}
<button class="wl-btn text" @click=${() => this._call("withdraw_wish", { wish_id: w.id })}>${this._t("wishlist.dismiss")}</button>`;
}
if (w.status === "redeem_requested") {
return html`<span class="wl-status good"><ha-icon icon="mdi:gift"></ha-icon>${this._t("wishlist.status_redeem_requested")}</span>`;
}
const takeBack = (Number(w.saved) || 0) > 0
? html`<button class="wl-btn text" @click=${() => this._openTake(w)}>${this._t("wishlist.take_back")}</button>`
: "";
if (funded) {
return html`
<div class="wl-funded"><ha-icon icon="mdi:trophy"></ha-icon>${this._t("wishlist.fully_funded")}</div>
<button class="wl-btn green" @click=${() => this._openSheet({ kind: "redeem", id: w.id })}>
<ha-icon icon="mdi:gift"></ha-icon>${this._t("wishlist.ask_redeem")}</button>
${takeBack}`;
}
return html`
<button class="wl-btn accent" ?disabled=${spendable < 1} @click=${() => this._openMove(w, spendable)}>
<ha-icon icon="mdi:piggy-bank-outline"></ha-icon>${this._t("wishlist.move_points")}</button>
<span class="wl-to-go">${this._t("wishlist.to_go", { points: remaining })}</span>
<span class="wl-grow"></span>
${takeBack}`;
}
_renderSheet(wishes, spendable, pointsIcon) {
const s = this._sheet;
const wish = s.id ? wishes.find((w) => w.id === s.id) : null;
let body = "";
if (s.kind === "add") body = this._renderAddSheet(s);
else if (!wish) return "";
else if (s.kind === "move") body = this._renderAmountSheet(s, wish, Math.min(spendable, this._remaining(wish)), pointsIcon, "move", spendable);
else if (s.kind === "take") body = this._renderAmountSheet(s, wish, Number(wish.saved) || 0, pointsIcon, "take", spendable);
else if (s.kind === "redeem") body = this._renderRedeemSheet(s, wish);
return html`
<div class="wl-scrim" @click=${(e) => { if (e.target === e.currentTarget && !s.busy) this._closeSheet(); }}>
<div class="wl-sheet" role="dialog" aria-modal="true">
<div class="wl-grab"></div>
${body}
${s.error ? html`<div class="wl-error" role="alert">${s.error}</div>` : ""}
</div>
</div>`;
}
_renderAddSheet(s) {
return html`
<h3><ha-icon icon="mdi:star-shooting-outline"></ha-icon>${this._t("wishlist.add_title")}</h3>
<label class="wl-field">
<span class="wl-label">${this._t("wishlist.field_name")}</span>
<input class="wl-input" type="text" maxlength="60" .value=${s.name}
@input=${(e) => this._setSheet({ name: e.target.value })}>
</label>
<div class="wl-field">
<span class="wl-label">${this._t("wishlist.field_target")}</span>
<div class="wl-row">
<div class="wl-stepper">
<button aria-label="−" @click=${() => this._stepTarget(-TARGET_STEP)}>−</button>
<input type="number" min="1" max="${MAX_TARGET}" .value=${String(s.target)}
@input=${(e) => this._setSheet({ target: Math.max(1, Math.min(MAX_TARGET, parseInt(e.target.value, 10) || 1)) })}>
<button aria-label="+" @click=${() => this._stepTarget(TARGET_STEP)}>+</button>
</div>
<span class="wl-hint">${this._t("wishlist.target_hint")}</span>
</div>
</div>
<label class="wl-field">
<span class="wl-label">${this._t("wishlist.field_link")} <span class="wl-hint">${this._t("wishlist.optional")}</span></span>
<input class="wl-input" type="url" maxlength="500" placeholder="https://…" .value=${s.link}
@input=${(e) => this._setSheet({ link: e.target.value })}>
</label>
<div class="wl-field">
<span class="wl-label">${this._t("wishlist.field_picture")} <span class="wl-hint">${this._t("wishlist.optional")}</span></span>
<input id="wl-file" type="file" accept="image/*" hidden @change=${(e) => this._onPicture(e)}>
<button class="wl-drop" @click=${() => this._pickPicture()}>
${s.previewUrl
? html`<img src="${s.previewUrl}" alt=""><span>${this._t("wishlist.picture_change")}</span>`
: html`<ha-icon icon="mdi:image-plus"></ha-icon><span>${this._t("wishlist.picture_pick")}</span>`}
</button>
</div>
<div class="wl-sheet-acts">
<button class="wl-btn ghost" ?disabled=${s.busy} @click=${() => this._closeSheet()}>${this._t("common.cancel")}</button>
<button class="wl-btn accent" ?disabled=${s.busy || !(s.name || "").trim()} @click=${() => this._submitAdd()}>${this._t("wishlist.send")}</button>
</div>`;
}
_renderAmountSheet(s, wish, max, pointsIcon, mode, spendable) {
const amount = Math.max(0, Math.min(Number(s.amount) || 0, max));
const isMove = mode === "move";
const chips = [10, 25, 50].filter((n) => n < max);
const service = isMove ? "move_points_to_wish" : "take_points_from_wish";
return html`
<h3><ha-icon class=${isMove ? "" : "tm-rtl-flip"} icon="${isMove ? "mdi:piggy-bank-outline" : "mdi:undo-variant"}"></ha-icon>${this._t(isMove ? "wishlist.move_title" : "wishlist.take_title", { name: wish.name })}</h3>
<div class="wl-big-amt">${amount}<ha-icon icon="${pointsIcon}"></ha-icon></div>
<input class="wl-range" type="range" min="0" max="${max}" step="1" .value=${String(amount)}
@input=${(e) => this._setSheet({ amount: Number(e.target.value) })}>
<div class="wl-range-info">
<span>0</span>
<span>${isMove
? this._t("wishlist.move_info", { balance: spendable, needed: this._remaining(wish) })
: this._t("wishlist.take_info", { saved: Number(wish.saved) || 0 })}</span>
<span>${max}</span>
</div>
<div class="wl-chips">
${chips.map((n) => html`<button class="wl-chip ${amount === n ? "on" : ""}" @click=${() => this._setSheet({ amount: n })}>+${n}</button>`)}
<button class="wl-chip ${amount === max ? "on" : ""}" @click=${() => this._setSheet({ amount: max })}>
${this._t(isMove ? "wishlist.all_i_need" : "wishlist.take_all", { points: max })}</button>
</div>
<div class="wl-sheet-acts">
<button class="wl-btn ghost" ?disabled=${s.busy} @click=${() => this._closeSheet()}>${this._t("common.cancel")}</button>
<button class="wl-btn accent" ?disabled=${s.busy || amount < 1}
@click=${() => this._call(service, { wish_id: wish.id, points: amount })}>
${this._t(isMove ? "wishlist.move_confirm" : "wishlist.take_confirm", { points: amount })}</button>
</div>`;
}
_renderRedeemSheet(s, wish) {
return html`
<div class="wl-redeem">
<div class="wl-big-ic"><ha-icon icon="mdi:gift"></ha-icon></div>
<h3>${this._t("wishlist.redeem_title", { name: wish.name })}</h3>
<p>${this._t("wishlist.redeem_body")}</p>
<div class="wl-sheet-acts center">
<button class="wl-btn ghost" ?disabled=${s.busy} @click=${() => this._closeSheet()}>${this._t("wishlist.not_yet")}</button>
<button class="wl-btn green" ?disabled=${s.busy}
@click=${() => this._call("request_wish_redeem", { wish_id: wish.id })}>${this._t("wishlist.yes_please")}</button>
</div>
</div>`;
}
static get styles() {
const base = css`
:host {
display: block;
--wl-accent: var(--tmd-accent, #e91e63);
--wl-good: var(--tmd-good, #2ecc71);
--wl-gold: var(--tmd-gold, #f1c40f);
--wl-text: var(--tmd-text, var(--primary-text-color));
--wl-dim: var(--tmd-dim, var(--secondary-text-color));
--wl-surface: var(--tmd-surface, var(--ha-card-background, var(--card-background-color, #fff)));
--wl-surface-2: var(--tmd-surface-2, var(--secondary-background-color, #f5f5f5));
--wl-border: var(--tmd-border, var(--divider-color, #e0e0e0));
--wl-me: var(--wl-accent);
--wl-link: var(--tmd-accent, var(--primary-color, #03a9f4));
}
/* Graphite keeps colour for meaning only: its accent is a neutral that
all but vanishes on its own dark surfaces, so the child's share, links
and the main button follow the text colour instead (as its kit does). */
:host([data-tm-design="graphite"]) {
--wl-me: var(--tmd-text);
--wl-link: var(--tmd-text);
}
:host([data-tm-design="graphite"]) .wl-btn.accent { background: var(--tmd-text); color: var(--tmd-surface); }
ha-card {
position: relative;
overflow: hidden;
background: var(--wl-surface);
color: var(--wl-text);
border-radius: var(--tmd-radius, var(--ha-card-border-radius, 12px));
font-family: var(--tmd-font-body, inherit);
}
ha-card.wl-has-sheet { min-height: 560px; }
button { font-family: inherit; }
ha-icon { --mdc-icon-size: 18px; }
.wl-header {
display: flex; align-items: center; justify-content: space-between; gap: 12px;
padding: 14px 18px; background: var(--wl-accent); color: var(--tmd-hd-text, #fff);
}
.wl-hd-left { display: flex; align-items: center; gap: 12px; min-width: 0; flex: 1; }
.wl-hd-icon { --mdc-icon-size: 30px; flex: none; opacity: .95; }
.wl-hd-text { min-width: 0; }
.wl-hd-title {
font-size: 1.3rem; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
font-family: var(--tmd-font-display, inherit);
}
.wl-hd-sub { font-size: .8rem; opacity: .85; margin-top: 1px; }
.wl-hd-pill {
display: inline-flex; align-items: center; gap: 5px; flex: none; cursor: pointer;
background: rgba(255, 255, 255, .2); color: inherit; border: 0;
padding: 5px 12px; border-radius: 16px; font-size: .9rem; font-weight: 500; white-space: nowrap;
}
.wl-hd-pill[disabled] { opacity: .5; cursor: default; }
.wl-balance {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
padding: 10px 16px; font-size: .92rem;
background: color-mix(in srgb, var(--wl-accent) 10%, var(--wl-surface));
border-bottom: 1px solid var(--wl-border);
}
.wl-balance > ha-icon { color: var(--wl-accent); }
.wl-amount { display: inline-flex; align-items: center; gap: 3px; font-size: 1.05rem; color: var(--wl-gold); }
.wl-reserved { margin-inline-start: auto; font-size: .78rem; color: var(--wl-dim); }
.wl-flash { margin: 10px 16px 0; font-size: .85rem; color: var(--tmd-bad, #e74c3c); }
.wl-content { padding: 16px; display: flex; flex-direction: column; gap: 12px; }
.wl-empty {
display: flex; flex-direction: column; align-items: center; gap: 8px; text-align: center;
padding: 28px 16px; color: var(--wl-dim);
}
.wl-empty ha-icon { --mdc-icon-size: 32px; }
.wl-empty-list { padding: 18px 8px; }
.wl-wish {
background: var(--wl-surface-2); border: 1px solid var(--wl-border);
border-radius: var(--tmd-radius-sm, 14px); overflow: hidden;
}
.wl-wish.pending { border-style: dashed; opacity: .88; }
.wl-wish.declined { opacity: .8; }
.wl-wish.funded {
border: 2px solid var(--wl-good);
background: color-mix(in srgb, var(--wl-good) 8%, var(--wl-surface-2));
}
.wl-wish-top { display: flex; gap: 12px; padding: 12px; }
.wl-thumb {
width: 72px; height: 72px; flex: none; border-radius: var(--tmd-radius-sm, 12px);
object-fit: cover; display: block;
}
.wl-thumb-ph {
display: grid; place-items: center; color: rgba(255, 255, 255, .92);
background: linear-gradient(135deg, var(--wl-accent), color-mix(in srgb, var(--wl-accent) 45%, #3498db));
}
.wl-thumb-ph ha-icon { --mdc-icon-size: 34px; }
.wl-grow { flex: 1; min-width: 0; }
.wl-wish-name {
font-size: 1.1rem; font-weight: 700; line-height: 1.25; overflow-wrap: anywhere;
font-family: var(--tmd-font-display, inherit);
}
.wl-wish-meta { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; align-items: center; }
.wl-tag, .wl-link {
display: inline-flex; align-items: center; gap: 4px; font-size: .75rem; font-weight: 600;
padding: 2px 9px; border-radius: 999px; white-space: nowrap;
}
.wl-tag { background: color-mix(in srgb, var(--wl-text) 8%, transparent); color: var(--wl-dim); }
.wl-tag ha-icon, .wl-link ha-icon { --mdc-icon-size: 13px; }
.wl-link {
color: var(--wl-link); text-decoration: none;
background: color-mix(in srgb, var(--wl-link) 14%, transparent);
max-width: 100%; overflow: hidden; text-overflow: ellipsis;
}
.wl-split {
margin: 0 12px; height: 16px; border-radius: 8px; overflow: hidden; display: flex;
background: color-mix(in srgb, var(--wl-text) 12%, transparent);
border: 2px solid color-mix(in srgb, var(--wl-text) 8%, transparent);
}
.wl-split i { display: block; height: 100%; }
.wl-me { background: var(--wl-me); }
.wl-fam { background: repeating-linear-gradient(135deg, var(--wl-gold) 0 6px, #f39c12 6px 12px); }
.wl-legend {
display: flex; gap: 12px; flex-wrap: wrap; align-items: center;
padding: 6px 12px 0; font-size: .8rem; color: var(--wl-dim);
}
.wl-sw { width: 10px; height: 10px; border-radius: 3px; display: inline-block; margin-inline-end: 4px; vertical-align: -1px; }
.wl-sw-me { background: var(--wl-me); }
.wl-sw-fam { background: var(--wl-gold); }
.wl-tot { margin-inline-start: auto; color: var(--wl-text); font-weight: 700; font-variant-numeric: tabular-nums; }
.wl-pledges { padding: 8px 12px 0; display: flex; flex-wrap: wrap; gap: 6px; }
.wl-pl {
display: inline-flex; align-items: center; gap: 5px; font-size: .78rem; border-radius: 999px; padding: 3px 9px;
background: color-mix(in srgb, var(--wl-gold) 16%, transparent); color: var(--wl-text);
}
.wl-pl ha-icon { --mdc-icon-size: 13px; color: #ff6b9d; }
.wl-acts { display: flex; gap: 8px; padding: 10px 12px 12px; flex-wrap: wrap; align-items: center; }
.wl-status {
display: inline-flex; align-items: center; gap: 5px; font-size: .8rem; font-weight: 700;
padding: 4px 10px; border-radius: 10px; line-height: 1.3;
}
.wl-status.warn { background: color-mix(in srgb, var(--tmd-warn, #f39c12) 18%, transparent); color: var(--tmd-warn, #d68910); }
.wl-status.good { background: color-mix(in srgb, var(--wl-good) 18%, transparent); color: var(--wl-good); }
.wl-status.bad { background: color-mix(in srgb, var(--tmd-bad, #e74c3c) 16%, transparent); color: var(--tmd-bad, #e74c3c); }
.wl-reason { font-size: .82rem; color: var(--wl-dim); font-style: italic; overflow-wrap: anywhere; }
.wl-funded {
display: flex; align-items: center; gap: 6px; flex: 1 0 auto;
font-weight: 700; color: var(--wl-good); white-space: nowrap;
}
.wl-funded ha-icon { --mdc-icon-size: 20px; }
.wl-to-go { font-size: .8rem; color: var(--wl-dim); }
.wl-btn {
display: inline-flex; align-items: center; justify-content: center; gap: 6px; cursor: pointer;
border: 0; border-radius: var(--tmd-radius-sm, 10px); padding: 8px 14px;
font-size: .88rem; font-weight: 600; color: #fff; background: var(--wl-accent);
}
.wl-btn.accent { background: var(--wl-accent); color: var(--tmd-hd-text, #fff); }
.wl-btn.green { background: var(--wl-good); color: #fff; }
.wl-btn.ghost { background: transparent; color: var(--wl-text); border: 1px solid var(--wl-border); }
.wl-btn.text { background: transparent; color: var(--wl-dim); padding: 8px 6px; }
.wl-btn[disabled] { opacity: .45; cursor: default; }
.wl-scrim {
position: absolute; inset: 0; z-index: 5; display: flex; align-items: flex-end;
background: rgba(0, 0, 0, .5);
}
.wl-sheet {
width: 100%; box-sizing: border-box; max-height: 96%; overflow-y: auto;
background: var(--wl-surface); color: var(--wl-text);
border-radius: 18px 18px 0 0; padding: 8px 16px 16px;
box-shadow: 0 -10px 30px rgba(0, 0, 0, .35);
border-top: 1px solid var(--wl-border);
}
.wl-grab { width: 40px; height: 4px; border-radius: 2px; background: var(--wl-border); margin: 4px auto 10px; }
.wl-sheet h3 {
margin: 0 0 12px; font-size: 1.15rem; display: flex; align-items: center; gap: 8px;
font-family: var(--tmd-font-display, inherit);
}
.wl-sheet h3 ha-icon { --mdc-icon-size: 22px; color: var(--wl-accent); }
.wl-field { display: block; margin-bottom: 14px; }
.wl-label { display: block; font-size: .8rem; font-weight: 600; margin-bottom: 6px; }
.wl-hint { font-size: .75rem; color: var(--wl-dim); font-weight: 400; }
.wl-input {
width: 100%; box-sizing: border-box; padding: 10px 12px; font: inherit; font-size: .95rem;
color: var(--wl-text); background: var(--wl-surface-2);
border: 1px solid var(--wl-border); border-radius: var(--tmd-radius-sm, 8px);
}
.wl-input:focus { outline: 2px solid var(--wl-accent); outline-offset: 1px; }
.wl-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.wl-stepper {
display: inline-flex; align-items: center; overflow: hidden;
border: 1px solid var(--wl-border); border-radius: var(--tmd-radius-sm, 8px);
}
.wl-stepper button {
width: 36px; height: 36px; border: 0; cursor: pointer; font-size: 18px;
background: var(--wl-surface-2); color: var(--wl-text);
}
.wl-stepper input {
width: 72px; height: 36px; border: 0; text-align: center; font: inherit; font-weight: 700;
background: var(--wl-surface); color: var(--wl-text); -moz-appearance: textfield;
}
.wl-stepper input::-webkit-inner-spin-button { -webkit-appearance: none; }
.wl-drop {
width: 100%; display: flex; flex-direction: column; align-items: center; gap: 6px; cursor: pointer;
border: 2px dashed var(--wl-border); border-radius: var(--tmd-radius-sm, 12px);
padding: 14px; background: transparent; color: var(--wl-dim); font-size: .85rem;
}
.wl-drop ha-icon { --mdc-icon-size: 28px; }
.wl-drop img { width: 96px; height: 96px; object-fit: cover; border-radius: 10px; }
.wl-sheet-acts { display: flex; justify-content: flex-end; gap: 8px; margin-top: 6px; }
.wl-sheet-acts.center { justify-content: center; }
.wl-big-amt {
font-size: 44px; font-weight: 800; display: flex; align-items: center; justify-content: center; gap: 6px;
color: var(--wl-gold); margin: 4px 0; font-variant-numeric: tabular-nums;
}
.wl-big-amt ha-icon { --mdc-icon-size: 34px; }
.wl-range { width: 100%; accent-color: var(--wl-accent); }
.wl-range-info {
display: flex; justify-content: space-between; gap: 8px; font-size: .75rem; color: var(--wl-dim);
margin: 4px 0 12px; text-align: center;
}
.wl-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; }
.wl-chip {
cursor: pointer; font: inherit; font-size: .8rem; font-weight: 600; padding: 5px 12px; border-radius: 999px;
background: var(--wl-surface-2); color: var(--wl-text); border: 1px solid var(--wl-border);
}
.wl-chip.on { border-color: var(--wl-accent); color: var(--wl-accent); }
.wl-redeem { text-align: center; padding-top: 4px; }
.wl-redeem h3 { justify-content: center; }
.wl-redeem p { margin: 0 0 16px; color: var(--wl-dim); }
.wl-big-ic {
width: 64px; height: 64px; border-radius: 50%; display: grid; place-items: center; margin: 0 auto 10px;
background: color-mix(in srgb, var(--wl-good) 20%, transparent); color: var(--wl-good);
}
.wl-big-ic ha-icon { --mdc-icon-size: 34px; }
.wl-error { margin-top: 10px; font-size: .85rem; color: var(--tmd-bad, #e74c3c); }
`;
const tokens = window.__taskmate_design && window.__taskmate_design.styles
? window.__taskmate_design.styles() : null;
return tokens ? [tokens, base] : base;
}
}
// ── Card editor ────────────────────────────────────────────────────────────
class TaskMateWishlistCardEditor extends LitElement {
static get properties() {
return { hass: { type: Object }, config: { type: Object } };
}
_t(key, params) {
const fn = window.__taskmate_localize;
return fn ? fn(this.hass, key, params) : key;
}
static get styles() {
return css`
:host { display: block; }
ha-form { display: block; margin-bottom: 16px; }
`;
}
setConfig(config) { this.config = config; }
_buildSchema() {
const entity = this.config && this.config.entity ? this.hass && this.hass.states && this.hass.states[this.config.entity] : null;
const attrs = window.__taskmate_attrs && this.config && this.config.entity
? window.__taskmate_attrs(this.hass, this.config.entity)
: (entity && entity.attributes) || {};
const children = Array.isArray(attrs.children) ? attrs.children : [];
return [
{ name: "entity", selector: { entity: { domain: "sensor" } } },
{
name: "child_id",
selector: {
select: { options: children.map((c) => ({ value: c.id, label: c.name })), mode: "dropdown" },
},
},
{ name: "title", selector: { text: {} } },
{
name: "card_design",
selector: {
select: {
options: window.__taskmate_design
? window.__taskmate_design.editorOptions(this._t.bind(this))
: [{ value: "global", label: "Use global default" }],
mode: "dropdown",
},
},
},
];
}
_computeLabel = (entry) => {
const labels = {
entity: this._t("common.editor.overview_entity"),
child_id: this._t("wishlist.editor.child"),
title: this._t("common.editor.card_title"),
card_design: this._t("common.design.field_label"),
};
return labels[entry.name] ?? entry.name;
};
_computeHelper = (entry) => {
const helpers = {
entity: this._t("common.editor.overview_entity_helper"),
child_id: this._t("wishlist.editor.child_helper"),
title: this._t("wishlist.editor.title_helper"),
};
return helpers[entry.name] ?? "";
};
render() {
if (!this.hass || !this.config) return html``;
const data = {
entity: this.config.entity || "",
child_id: this.config.child_id || "",
title: this.config.title || "",
card_design: this.config.card_design || "global",
};
return html`
<ha-form
.hass=${this.hass}
.data=${data}
.schema=${this._buildSchema()}
.computeLabel=${this._computeLabel}
.computeHelper=${this._computeHelper}
@value-changed=${this._formChanged}
></ha-form>
${this._renderColourPicker("header_color", DEFAULT_ACCENT)}
`;
}
_renderColourPicker(key, defaultValue) {
const d = window.__taskmate_design;
if (!d || !d.colourPicker) return html``;
return d.colourPicker({
defaultValue,
current: this.config[key] || defaultValue,
label: this._t("common.editor.header_colour"),
helper: this._t("common.editor.header_colour_helper"),
resetLabel: this._t("common.reset"),
onInput: (v) => this._updateConfig(key, v),
onPreset: (v) => this._updateConfig(key, v),
onReset: () => this._updateConfig(key, defaultValue),
});
}
_formChanged(e) {
const newValues = e.detail.value || {};
const newConfig = { ...this.config };
for (const [key, value] of Object.entries(newValues)) {
if (value === "" || value === null || value === undefined || (key === "card_design" && value === "global")) {
delete newConfig[key];
} else {
newConfig[key] = value;
}
}
this.dispatchEvent(new CustomEvent("config-changed", {
detail: { config: newConfig }, bubbles: true, composed: true,
}));
}
_updateConfig(key, value) {
const newConfig = { ...this.config, [key]: value };
if (value === null || value === "" || value === undefined) delete newConfig[key];
this.dispatchEvent(new CustomEvent("config-changed", {
detail: { config: newConfig }, bubbles: true, composed: true,
}));
}
}
customElements.define("taskmate-wishlist-card", TaskMateWishlistCard);
customElements.define("taskmate-wishlist-card-editor", TaskMateWishlistCardEditor);
window.customCards = window.customCards || [];
window.customCards.push({
type: "taskmate-wishlist-card",
name: "TaskMate Wishlist",
description: "A child's wishlist: save towards things they want, with family pledges",
preview: true,
getEntitySuggestion: (hass, entityId) =>
window.__taskmate_suggest(hass, entityId, "taskmate-wishlist-card", "overview"),
});