This commit is contained in:
Home Assistant Version Control
2026-08-13 19:52:24 +00:00
parent 2d1ba0c035
commit 457f19d210
46 changed files with 3333 additions and 2108 deletions
+207 -146
View File
@@ -1,4 +1,5 @@
"""TaskMate - Family Chore Manager for Home Assistant.""" """TaskMate - Family Chore Manager for Home Assistant."""
from __future__ import annotations from __future__ import annotations
import copy import copy
@@ -121,7 +122,15 @@ from .websocket import async_register_websocket_commands
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.BUTTON, Platform.BINARY_SENSOR, Platform.CALENDAR, Platform.NUMBER, Platform.SELECT, Platform.TODO] PLATFORMS: list[Platform] = [
Platform.SENSOR,
Platform.BUTTON,
Platform.BINARY_SENSOR,
Platform.CALENDAR,
Platform.NUMBER,
Platform.SELECT,
Platform.TODO,
]
# Track if services are registered # Track if services are registered
SERVICES_REGISTERED = "services_registered" SERVICES_REGISTERED = "services_registered"
@@ -152,7 +161,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.async_create_task(coordinator.notifications.handle_mobile_action(event)) hass.async_create_task(coordinator.notifications.handle_mobile_action(event))
coordinator._unsub_mobile_action = hass.bus.async_listen( coordinator._unsub_mobile_action = hass.bus.async_listen(
"mobile_app_notification_action", _on_mobile_action, "mobile_app_notification_action",
_on_mobile_action,
) )
# Register frontend static paths # Register frontend static paths
@@ -171,6 +181,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# stack never blocks setup. # stack never blocks setup.
try: try:
from .intents import async_setup_intents from .intents import async_setup_intents
async_setup_intents(hass) async_setup_intents(hass)
except Exception as err: # noqa: BLE001 except Exception as err: # noqa: BLE001
_LOGGER.debug("TaskMate intents not registered: %s", err) _LOGGER.debug("TaskMate intents not registered: %s", err)
@@ -187,9 +198,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await hass.async_add_executor_job(_load_base_descriptions) await hass.async_add_executor_job(_load_base_descriptions)
_async_update_service_descriptions(hass) _async_update_service_descriptions(hass)
coordinator.async_add_listener( coordinator.async_add_listener(lambda: _async_update_service_descriptions(hass))
lambda: _async_update_service_descriptions(hass)
)
return True return True
@@ -207,10 +216,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# If no more entries, unregister services. Count only coordinator # If no more entries, unregister services. Count only coordinator
# instances — hass.data[DOMAIN] also holds bookkeeping flags. # instances — hass.data[DOMAIN] also holds bookkeeping flags.
remaining_entries = [ remaining_entries = [value for value in hass.data[DOMAIN].values() if isinstance(value, TaskMateCoordinator)]
value for value in hass.data[DOMAIN].values()
if isinstance(value, TaskMateCoordinator)
]
if not remaining_entries: if not remaining_entries:
_async_unregister_services(hass) _async_unregister_services(hass)
hass.data[DOMAIN][SERVICES_REGISTERED] = False hass.data[DOMAIN][SERVICES_REGISTERED] = False
@@ -329,8 +335,16 @@ async def _async_require_parent(hass: HomeAssistant, call: ServiceCall) -> None:
_AUDIT_TARGET_KEYS = ( _AUDIT_TARGET_KEYS = (
"chore_id", "reward_id", "penalty_id", "bonus_id", "badge_id", "chore_id",
"task_group_id", "miss_id", "claim_id", "transaction_id", "type_id", "reward_id",
"penalty_id",
"bonus_id",
"badge_id",
"task_group_id",
"miss_id",
"claim_id",
"transaction_id",
"type_id",
) )
@@ -363,9 +377,7 @@ async def _async_record_service_audit(hass: HomeAssistant, call: ServiceCall) ->
target = f"{key}={call.data[key]}" target = f"{key}={call.data[key]}"
break break
try: try:
await coordinator.async_record_audit( await coordinator.async_record_audit(user_id, user_name, f"service.{call.service}", target)
user_id, user_name, f"service.{call.service}", target
)
except Exception: # noqa: BLE001 - audit must never break the action except Exception: # noqa: BLE001 - audit must never break the action
_LOGGER.debug("Failed to record service audit for %s", call.service, exc_info=True) _LOGGER.debug("Failed to record service audit for %s", call.service, exc_info=True)
@@ -382,18 +394,18 @@ def _safe(handler):
``ServiceValidationError`` is not a ``ValueError``, so a handler that already ``ServiceValidationError`` is not a ``ValueError``, so a handler that already
raises it (e.g. complete_chore) passes through untouched. raises it (e.g. complete_chore) passes through untouched.
""" """
@wraps(handler) @wraps(handler)
async def wrapped(call: ServiceCall) -> None: async def wrapped(call: ServiceCall) -> None:
try: try:
await handler(call) await handler(call)
except ValueError as err: except ValueError as err:
raise ServiceValidationError(str(err)) from err raise ServiceValidationError(str(err)) from err
return wrapped return wrapped
async def _async_require_linked_child( async def _async_require_linked_child(hass: HomeAssistant, call: ServiceCall, coordinator, child_id: str) -> None:
hass: HomeAssistant, call: ServiceCall, coordinator, child_id: str
) -> None:
"""Restrict a child's self-service call to that child's linked HA user. """Restrict a child's self-service call to that child's linked HA user.
Opt-in: only enforced when the child has a ``linked_user_id`` set. Children Opt-in: only enforced when the child has a ``linked_user_id`` set. Children
@@ -426,11 +438,13 @@ async def _async_register_services(hass: HomeAssistant) -> None:
def _admin(handler): def _admin(handler):
"""Wrap a service handler so only admins (or context-less calls) run it.""" """Wrap a service handler so only admins (or context-less calls) run it."""
@wraps(handler) @wraps(handler)
async def wrapped(call: ServiceCall) -> None: async def wrapped(call: ServiceCall) -> None:
await _async_require_admin(hass, call) await _async_require_admin(hass, call)
await handler(call) await handler(call)
await _async_record_service_audit(hass, call) await _async_record_service_audit(hass, call)
# Compose with _safe so admin handlers also convert coordinator # Compose with _safe so admin handlers also convert coordinator
# ValueErrors into clean validation errors. The admin gate raises # ValueErrors into clean validation errors. The admin gate raises
# Unauthorized (not ValueError), so it is unaffected and still 401s. # Unauthorized (not ValueError), so it is unaffected and still 401s.
@@ -441,11 +455,13 @@ async def _async_register_services(hass: HomeAssistant) -> None:
Used for day-to-day parent actions. Structural config keeps _admin. Used for day-to-day parent actions. Structural config keeps _admin.
""" """
@wraps(handler) @wraps(handler)
async def wrapped(call: ServiceCall) -> None: async def wrapped(call: ServiceCall) -> None:
await _async_require_parent(hass, call) await _async_require_parent(hass, call)
await handler(call) await handler(call)
await _async_record_service_audit(hass, call) await _async_record_service_audit(hass, call)
return _safe(wrapped) return _safe(wrapped)
async def handle_complete_chore(call: ServiceCall) -> None: async def handle_complete_chore(call: ServiceCall) -> None:
@@ -468,7 +484,9 @@ async def _async_register_services(hass: HomeAssistant) -> None:
await _async_require_linked_child(hass, call, coordinator, child_id) await _async_require_linked_child(hass, call, coordinator, child_id)
try: try:
await coordinator.async_complete_chore( await coordinator.async_complete_chore(
chore_id, child_id, as_parent=as_parent, chore_id,
child_id,
as_parent=as_parent,
photo_url=call.data.get("photo_url", ""), photo_url=call.data.get("photo_url", ""),
) )
except ValueError as err: except ValueError as err:
@@ -497,9 +515,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
_LOGGER.error("No TaskMate coordinator available") _LOGGER.error("No TaskMate coordinator available")
return return
await _async_require_linked_child(hass, call, coordinator, call.data[ATTR_CHILD_ID]) await _async_require_linked_child(hass, call, coordinator, call.data[ATTR_CHILD_ID])
await coordinator.async_start_timed_task( await coordinator.async_start_timed_task(call.data[ATTR_CHORE_ID], call.data[ATTR_CHILD_ID])
call.data[ATTR_CHORE_ID], call.data[ATTR_CHILD_ID]
)
async def handle_pause_timed_task(call: ServiceCall) -> None: async def handle_pause_timed_task(call: ServiceCall) -> None:
"""Handle the pause_timed_task service call.""" """Handle the pause_timed_task service call."""
@@ -508,9 +524,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
_LOGGER.error("No TaskMate coordinator available") _LOGGER.error("No TaskMate coordinator available")
return return
await _async_require_linked_child(hass, call, coordinator, call.data[ATTR_CHILD_ID]) await _async_require_linked_child(hass, call, coordinator, call.data[ATTR_CHILD_ID])
await coordinator.async_pause_timed_task( await coordinator.async_pause_timed_task(call.data[ATTR_CHORE_ID], call.data[ATTR_CHILD_ID])
call.data[ATTR_CHORE_ID], call.data[ATTR_CHILD_ID]
)
async def handle_stop_timed_task(call: ServiceCall) -> None: async def handle_stop_timed_task(call: ServiceCall) -> None:
"""Handle the stop_timed_task service call.""" """Handle the stop_timed_task service call."""
@@ -519,9 +533,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
_LOGGER.error("No TaskMate coordinator available") _LOGGER.error("No TaskMate coordinator available")
return return
await _async_require_linked_child(hass, call, coordinator, call.data[ATTR_CHILD_ID]) await _async_require_linked_child(hass, call, coordinator, call.data[ATTR_CHILD_ID])
await coordinator.async_stop_timed_task( await coordinator.async_stop_timed_task(call.data[ATTR_CHORE_ID], call.data[ATTR_CHILD_ID])
call.data[ATTR_CHORE_ID], call.data[ATTR_CHILD_ID]
)
async def handle_approve_chore(call: ServiceCall) -> None: async def handle_approve_chore(call: ServiceCall) -> None:
"""Handle the approve_chore service call.""" """Handle the approve_chore service call."""
@@ -604,7 +616,9 @@ async def _async_register_services(hass: HomeAssistant) -> None:
_LOGGER.error("No TaskMate coordinator available") _LOGGER.error("No TaskMate coordinator available")
return return
await coordinator.async_gift_points( await coordinator.async_gift_points(
call.data["from_child_id"], call.data["to_child_id"], call.data["points"], call.data["from_child_id"],
call.data["to_child_id"],
call.data["points"],
) )
async def handle_record_allowance_payout(call: ServiceCall) -> None: async def handle_record_allowance_payout(call: ServiceCall) -> None:
@@ -614,7 +628,8 @@ async def _async_register_services(hass: HomeAssistant) -> None:
_LOGGER.error("No TaskMate coordinator available") _LOGGER.error("No TaskMate coordinator available")
return return
await coordinator.async_record_allowance_payout( await coordinator.async_record_allowance_payout(
call.data["child_id"], call.data["points"], call.data["child_id"],
call.data["points"],
) )
async def handle_request_swap(call: ServiceCall) -> None: async def handle_request_swap(call: ServiceCall) -> None:
@@ -867,9 +882,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
_LOGGER.error("No TaskMate coordinator available") _LOGGER.error("No TaskMate coordinator available")
return return
try: try:
await coordinator.async_set_chore_manual_start( await coordinator.async_set_chore_manual_start(call.data[ATTR_CHORE_ID], call.data[ATTR_CHILD_ID])
call.data[ATTR_CHORE_ID], call.data[ATTR_CHILD_ID]
)
except ValueError as err: except ValueError as err:
_LOGGER.warning("set_chore_manual_start rejected: %s", err) _LOGGER.warning("set_chore_manual_start rejected: %s", err)
raise raise
@@ -1154,16 +1167,22 @@ async def _async_register_services(hass: HomeAssistant) -> None:
_miss_schema = vol.Schema({vol.Required("miss_id"): cv.string}) _miss_schema = vol.Schema({vol.Required("miss_id"): cv.string})
hass.services.async_register( hass.services.async_register(
DOMAIN, SERVICE_APPLY_MANDATORY_PENALTY, DOMAIN,
_parent(handle_apply_mandatory_penalty), schema=_miss_schema, SERVICE_APPLY_MANDATORY_PENALTY,
_parent(handle_apply_mandatory_penalty),
schema=_miss_schema,
) )
hass.services.async_register( hass.services.async_register(
DOMAIN, SERVICE_POSTPONE_MANDATORY_CHORE, DOMAIN,
_parent(handle_postpone_mandatory_chore), schema=_miss_schema, SERVICE_POSTPONE_MANDATORY_CHORE,
_parent(handle_postpone_mandatory_chore),
schema=_miss_schema,
) )
hass.services.async_register( hass.services.async_register(
DOMAIN, SERVICE_DISMISS_MANDATORY_CHORE, DOMAIN,
_parent(handle_dismiss_mandatory_chore), schema=_miss_schema, SERVICE_DISMISS_MANDATORY_CHORE,
_parent(handle_dismiss_mandatory_chore),
schema=_miss_schema,
) )
hass.services.async_register( hass.services.async_register(
@@ -1240,12 +1259,14 @@ async def _async_register_services(hass: HomeAssistant) -> None:
DOMAIN, DOMAIN,
SERVICE_READ_ALOUD, SERVICE_READ_ALOUD,
_safe(handle_read_aloud), _safe(handle_read_aloud),
schema=vol.Schema({ schema=vol.Schema(
vol.Required(ATTR_CHILD_ID): cv.string, {
vol.Optional("media_player", default=""): cv.string, vol.Required(ATTR_CHILD_ID): cv.string,
vol.Optional("tts_entity", default=""): cv.string, vol.Optional("media_player", default=""): cv.string,
vol.Optional("message", default=""): cv.string, vol.Optional("tts_entity", default=""): cv.string,
}), vol.Optional("message", default=""): cv.string,
}
),
) )
hass.services.async_register( hass.services.async_register(
@@ -1283,7 +1304,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
DOMAIN, DOMAIN,
SERVICE_REJECT_REWARD, SERVICE_REJECT_REWARD,
_parent(handle_reject_reward), _parent(handle_reject_reward),
schema=vol.Schema({ vol.Required("claim_id"): cv.string }), schema=vol.Schema({vol.Required("claim_id"): cv.string}),
) )
hass.services.async_register( hass.services.async_register(
@@ -1342,11 +1363,28 @@ async def _async_register_services(hass: HomeAssistant) -> None:
_safe(handle_preview_sound), _safe(handle_preview_sound),
schema=vol.Schema( schema=vol.Schema(
{ {
vol.Required(ATTR_SOUND): vol.In([ vol.Required(ATTR_SOUND): vol.In(
"none", "coin", "levelup", "fanfare", "chime", "powerup", "undo", [
"fart1", "fart2", "fart3", "fart4", "fart5", "fart6", "fart7", "none",
"fart8", "fart9", "fart10", "fart_random", "coin",
]), "levelup",
"fanfare",
"chime",
"powerup",
"undo",
"fart1",
"fart2",
"fart3",
"fart4",
"fart5",
"fart6",
"fart7",
"fart8",
"fart9",
"fart10",
"fart_random",
]
),
} }
), ),
) )
@@ -1363,32 +1401,35 @@ async def _async_register_services(hass: HomeAssistant) -> None:
), ),
) )
hass.services.async_register( hass.services.async_register(
DOMAIN, DOMAIN,
SERVICE_ADD_PENALTY, SERVICE_ADD_PENALTY,
_admin(handle_add_penalty), _admin(handle_add_penalty),
schema=vol.Schema({ schema=vol.Schema(
vol.Required(ATTR_PENALTY_NAME): cv.string, {
vol.Required(ATTR_PENALTY_POINTS): cv.positive_int, vol.Required(ATTR_PENALTY_NAME): cv.string,
vol.Optional(ATTR_PENALTY_DESCRIPTION, default=""): cv.string, vol.Required(ATTR_PENALTY_POINTS): cv.positive_int,
vol.Optional(ATTR_PENALTY_ICON, default="mdi:alert-circle-outline"): cv.string, vol.Optional(ATTR_PENALTY_DESCRIPTION, default=""): cv.string,
vol.Optional(ATTR_PENALTY_ASSIGNED_TO, default=[]): vol.All(cv.ensure_list, [cv.string]), vol.Optional(ATTR_PENALTY_ICON, default="mdi:alert-circle-outline"): cv.string,
}), vol.Optional(ATTR_PENALTY_ASSIGNED_TO, default=[]): vol.All(cv.ensure_list, [cv.string]),
}
),
) )
hass.services.async_register( hass.services.async_register(
DOMAIN, DOMAIN,
SERVICE_UPDATE_PENALTY, SERVICE_UPDATE_PENALTY,
_admin(handle_update_penalty), _admin(handle_update_penalty),
schema=vol.Schema({ schema=vol.Schema(
vol.Required(ATTR_PENALTY_ID): cv.string, {
vol.Optional(ATTR_PENALTY_NAME): cv.string, vol.Required(ATTR_PENALTY_ID): cv.string,
vol.Optional(ATTR_PENALTY_POINTS): cv.positive_int, vol.Optional(ATTR_PENALTY_NAME): cv.string,
vol.Optional(ATTR_PENALTY_DESCRIPTION): cv.string, vol.Optional(ATTR_PENALTY_POINTS): cv.positive_int,
vol.Optional(ATTR_PENALTY_ICON): cv.string, vol.Optional(ATTR_PENALTY_DESCRIPTION): cv.string,
vol.Optional(ATTR_PENALTY_ASSIGNED_TO): vol.All(cv.ensure_list, [cv.string]), vol.Optional(ATTR_PENALTY_ICON): cv.string,
}), vol.Optional(ATTR_PENALTY_ASSIGNED_TO): vol.All(cv.ensure_list, [cv.string]),
}
),
) )
hass.services.async_register( hass.services.async_register(
@@ -1402,37 +1443,43 @@ async def _async_register_services(hass: HomeAssistant) -> None:
DOMAIN, DOMAIN,
SERVICE_APPLY_PENALTY, SERVICE_APPLY_PENALTY,
_parent(handle_apply_penalty), _parent(handle_apply_penalty),
schema=vol.Schema({ schema=vol.Schema(
vol.Required(ATTR_PENALTY_ID): cv.string, {
vol.Required(ATTR_CHILD_ID): cv.string, vol.Required(ATTR_PENALTY_ID): cv.string,
}), vol.Required(ATTR_CHILD_ID): cv.string,
}
),
) )
hass.services.async_register( hass.services.async_register(
DOMAIN, DOMAIN,
SERVICE_ADD_BONUS, SERVICE_ADD_BONUS,
_admin(handle_add_bonus), _admin(handle_add_bonus),
schema=vol.Schema({ schema=vol.Schema(
vol.Required(ATTR_BONUS_NAME): cv.string, {
vol.Required(ATTR_BONUS_POINTS): cv.positive_int, vol.Required(ATTR_BONUS_NAME): cv.string,
vol.Optional(ATTR_BONUS_DESCRIPTION, default=""): cv.string, vol.Required(ATTR_BONUS_POINTS): cv.positive_int,
vol.Optional(ATTR_BONUS_ICON, default="mdi:star-circle-outline"): cv.string, vol.Optional(ATTR_BONUS_DESCRIPTION, default=""): cv.string,
vol.Optional(ATTR_BONUS_ASSIGNED_TO, default=[]): vol.All(cv.ensure_list, [cv.string]), vol.Optional(ATTR_BONUS_ICON, default="mdi:star-circle-outline"): cv.string,
}), vol.Optional(ATTR_BONUS_ASSIGNED_TO, default=[]): vol.All(cv.ensure_list, [cv.string]),
}
),
) )
hass.services.async_register( hass.services.async_register(
DOMAIN, DOMAIN,
SERVICE_UPDATE_BONUS, SERVICE_UPDATE_BONUS,
_admin(handle_update_bonus), _admin(handle_update_bonus),
schema=vol.Schema({ schema=vol.Schema(
vol.Required(ATTR_BONUS_ID): cv.string, {
vol.Optional(ATTR_BONUS_NAME): cv.string, vol.Required(ATTR_BONUS_ID): cv.string,
vol.Optional(ATTR_BONUS_POINTS): cv.positive_int, vol.Optional(ATTR_BONUS_NAME): cv.string,
vol.Optional(ATTR_BONUS_DESCRIPTION): cv.string, vol.Optional(ATTR_BONUS_POINTS): cv.positive_int,
vol.Optional(ATTR_BONUS_ICON): cv.string, vol.Optional(ATTR_BONUS_DESCRIPTION): cv.string,
vol.Optional(ATTR_BONUS_ASSIGNED_TO): vol.All(cv.ensure_list, [cv.string]), vol.Optional(ATTR_BONUS_ICON): cv.string,
}), vol.Optional(ATTR_BONUS_ASSIGNED_TO): vol.All(cv.ensure_list, [cv.string]),
}
),
) )
hass.services.async_register( hass.services.async_register(
@@ -1446,30 +1493,32 @@ async def _async_register_services(hass: HomeAssistant) -> None:
DOMAIN, DOMAIN,
SERVICE_APPLY_BONUS, SERVICE_APPLY_BONUS,
_parent(handle_apply_bonus), _parent(handle_apply_bonus),
schema=vol.Schema({ schema=vol.Schema(
vol.Required(ATTR_BONUS_ID): cv.string, {
vol.Required(ATTR_CHILD_ID): cv.string, vol.Required(ATTR_BONUS_ID): cv.string,
}), vol.Required(ATTR_CHILD_ID): cv.string,
}
),
) )
hass.services.async_register( hass.services.async_register(
DOMAIN, DOMAIN,
SERVICE_ADD_CHORE, SERVICE_ADD_CHORE,
_admin(handle_add_chore), _admin(handle_add_chore),
schema=vol.Schema({ schema=vol.Schema(
vol.Required(ATTR_CHORE_NAME): cv.string, {
vol.Optional(ATTR_CHORE_DESCRIPTION, default=""): cv.string, vol.Required(ATTR_CHORE_NAME): cv.string,
vol.Optional(ATTR_CHORE_POINTS, default=10): cv.positive_int, vol.Optional(ATTR_CHORE_DESCRIPTION, default=""): cv.string,
vol.Optional(ATTR_CHORE_ASSIGNED_TO, default=[]): vol.All(cv.ensure_list, [cv.string]), vol.Optional(ATTR_CHORE_POINTS, default=10): cv.positive_int,
vol.Optional(ATTR_CHORE_TIME_CATEGORY, default="anytime"): vol.In(TIME_CATEGORIES), vol.Optional(ATTR_CHORE_ASSIGNED_TO, default=[]): vol.All(cv.ensure_list, [cv.string]),
vol.Optional("difficulty", default=DEFAULT_DIFFICULTY): vol.In(DIFFICULTY_TIERS), vol.Optional(ATTR_CHORE_TIME_CATEGORY, default="anytime"): vol.In(TIME_CATEGORIES),
vol.Optional(ATTR_CHORE_ONE_SHOT, default=False): cv.boolean, vol.Optional("difficulty", default=DEFAULT_DIFFICULTY): vol.In(DIFFICULTY_TIERS),
vol.Optional(ATTR_CHORE_REQUIRES_APPROVAL, default=True): cv.boolean, vol.Optional(ATTR_CHORE_ONE_SHOT, default=False): cv.boolean,
vol.Optional(ATTR_CHORE_EXPIRES_IN_MINUTES, default=0): vol.All( vol.Optional(ATTR_CHORE_REQUIRES_APPROVAL, default=True): cv.boolean,
cv.positive_int, vol.Range(max=10080) 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,
vol.Optional(ATTR_CHORE_SPEED_BONUS_POINTS, default=0): cv.positive_int, }
}), ),
) )
hass.services.async_register( hass.services.async_register(
@@ -1483,33 +1532,39 @@ async def _async_register_services(hass: HomeAssistant) -> None:
DOMAIN, DOMAIN,
SERVICE_SET_CHORE_MANUAL_START, SERVICE_SET_CHORE_MANUAL_START,
_admin(handle_set_chore_manual_start), _admin(handle_set_chore_manual_start),
schema=vol.Schema({ schema=vol.Schema(
vol.Required(ATTR_CHORE_ID): cv.string, {
vol.Required(ATTR_CHILD_ID): cv.string, vol.Required(ATTR_CHORE_ID): cv.string,
}), vol.Required(ATTR_CHILD_ID): cv.string,
}
),
) )
hass.services.async_register( hass.services.async_register(
DOMAIN, DOMAIN,
SERVICE_ADD_TASK_GROUP, SERVICE_ADD_TASK_GROUP,
_admin(handle_add_task_group), _admin(handle_add_task_group),
schema=vol.Schema({ schema=vol.Schema(
vol.Required(CONF_TASK_GROUP_NAME): cv.string, {
vol.Required(CONF_TASK_GROUP_POLICY): vol.In(TASK_GROUP_POLICIES), vol.Required(CONF_TASK_GROUP_NAME): cv.string,
vol.Optional(CONF_TASK_GROUP_CHORE_IDS, default=[]): vol.All(cv.ensure_list, [cv.string]), vol.Required(CONF_TASK_GROUP_POLICY): vol.In(TASK_GROUP_POLICIES),
}), vol.Optional(CONF_TASK_GROUP_CHORE_IDS, default=[]): vol.All(cv.ensure_list, [cv.string]),
}
),
) )
hass.services.async_register( hass.services.async_register(
DOMAIN, DOMAIN,
SERVICE_UPDATE_TASK_GROUP, SERVICE_UPDATE_TASK_GROUP,
_admin(handle_update_task_group), _admin(handle_update_task_group),
schema=vol.Schema({ schema=vol.Schema(
vol.Required(CONF_TASK_GROUP_ID): cv.string, {
vol.Optional(CONF_TASK_GROUP_NAME): cv.string, vol.Required(CONF_TASK_GROUP_ID): cv.string,
vol.Optional(CONF_TASK_GROUP_POLICY): vol.In(TASK_GROUP_POLICIES), vol.Optional(CONF_TASK_GROUP_NAME): cv.string,
vol.Optional(CONF_TASK_GROUP_CHORE_IDS): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_TASK_GROUP_POLICY): vol.In(TASK_GROUP_POLICIES),
}), vol.Optional(CONF_TASK_GROUP_CHORE_IDS): vol.All(cv.ensure_list, [cv.string]),
}
),
) )
hass.services.async_register( hass.services.async_register(
@@ -1523,36 +1578,40 @@ async def _async_register_services(hass: HomeAssistant) -> None:
DOMAIN, DOMAIN,
"add_badge", "add_badge",
_admin(handle_add_badge), _admin(handle_add_badge),
schema=vol.Schema({ schema=vol.Schema(
vol.Required(ATTR_BADGE_NAME): cv.string, {
vol.Optional(ATTR_BADGE_DESCRIPTION, default=""): cv.string, vol.Required(ATTR_BADGE_NAME): cv.string,
vol.Optional(ATTR_BADGE_ICON, default="mdi:trophy"): cv.string, vol.Optional(ATTR_BADGE_DESCRIPTION, default=""): cv.string,
vol.Optional(ATTR_BADGE_TIER, default="bronze"): vol.In(["bronze", "silver", "gold", "platinum"]), vol.Optional(ATTR_BADGE_ICON, default="mdi:trophy"): cv.string,
vol.Optional(ATTR_BADGE_POINT_BONUS, default=0): vol.Coerce(int), vol.Optional(ATTR_BADGE_TIER, default="bronze"): vol.In(["bronze", "silver", "gold", "platinum"]),
vol.Optional(ATTR_BADGE_CRITERIA, default=[]): list, vol.Optional(ATTR_BADGE_POINT_BONUS, default=0): vol.Coerce(int),
vol.Optional(ATTR_BADGE_COMBINATOR, default="AND"): cv.string, vol.Optional(ATTR_BADGE_CRITERIA, default=[]): list,
vol.Optional(ATTR_BADGE_ASSIGNED_TO, default=[]): vol.All(cv.ensure_list, [cv.string]), vol.Optional(ATTR_BADGE_COMBINATOR, default="AND"): cv.string,
vol.Optional(ATTR_BADGE_NOTIFY_ON_EARN, default=True): cv.boolean, vol.Optional(ATTR_BADGE_ASSIGNED_TO, default=[]): vol.All(cv.ensure_list, [cv.string]),
}), vol.Optional(ATTR_BADGE_NOTIFY_ON_EARN, default=True): cv.boolean,
}
),
) )
hass.services.async_register( hass.services.async_register(
DOMAIN, DOMAIN,
"update_badge", "update_badge",
_admin(handle_update_badge), _admin(handle_update_badge),
schema=vol.Schema({ schema=vol.Schema(
vol.Required(ATTR_BADGE_ID): cv.string, {
vol.Optional(ATTR_BADGE_NAME): cv.string, vol.Required(ATTR_BADGE_ID): cv.string,
vol.Optional(ATTR_BADGE_DESCRIPTION): cv.string, vol.Optional(ATTR_BADGE_NAME): cv.string,
vol.Optional(ATTR_BADGE_ICON): cv.string, vol.Optional(ATTR_BADGE_DESCRIPTION): cv.string,
vol.Optional(ATTR_BADGE_TIER): vol.In(["bronze", "silver", "gold", "platinum"]), vol.Optional(ATTR_BADGE_ICON): cv.string,
vol.Optional(ATTR_BADGE_POINT_BONUS): vol.Coerce(int), vol.Optional(ATTR_BADGE_TIER): vol.In(["bronze", "silver", "gold", "platinum"]),
vol.Optional(ATTR_BADGE_CRITERIA): list, vol.Optional(ATTR_BADGE_POINT_BONUS): vol.Coerce(int),
vol.Optional(ATTR_BADGE_COMBINATOR): cv.string, vol.Optional(ATTR_BADGE_CRITERIA): list,
vol.Optional(ATTR_BADGE_ASSIGNED_TO): vol.All(cv.ensure_list, [cv.string]), vol.Optional(ATTR_BADGE_COMBINATOR): cv.string,
vol.Optional(ATTR_BADGE_ENABLED): cv.boolean, vol.Optional(ATTR_BADGE_ASSIGNED_TO): vol.All(cv.ensure_list, [cv.string]),
vol.Optional(ATTR_BADGE_NOTIFY_ON_EARN): cv.boolean, vol.Optional(ATTR_BADGE_ENABLED): cv.boolean,
}), vol.Optional(ATTR_BADGE_NOTIFY_ON_EARN): cv.boolean,
}
),
) )
hass.services.async_register( hass.services.async_register(
@@ -1566,10 +1625,12 @@ async def _async_register_services(hass: HomeAssistant) -> None:
DOMAIN, DOMAIN,
"award_badge_manually", "award_badge_manually",
_parent(handle_award_badge_manually), _parent(handle_award_badge_manually),
schema=vol.Schema({ schema=vol.Schema(
vol.Required(ATTR_BADGE_ID): cv.string, {
vol.Required(ATTR_CHILD_ID): cv.string, vol.Required(ATTR_BADGE_ID): cv.string,
}), vol.Required(ATTR_CHILD_ID): cv.string,
}
),
) )
hass.services.async_register( hass.services.async_register(
@@ -1,4 +1,5 @@
"""Binary sensor platform for TaskMate integration.""" """Binary sensor platform for TaskMate integration."""
from __future__ import annotations from __future__ import annotations
from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.components.binary_sensor import BinarySensorEntity
+6 -13
View File
@@ -1,4 +1,5 @@
"""Button platform for TaskMate integration.""" """Button platform for TaskMate integration."""
from __future__ import annotations from __future__ import annotations
import logging import logging
@@ -38,15 +39,11 @@ async def async_setup_entry(
if getattr(chore, "assignment_mode", "everyone") == "unassigned": if getattr(chore, "assignment_mode", "everyone") == "unassigned":
continue continue
if not chore.assigned_to or child.id in chore.assigned_to: if not chore.assigned_to or child.id in chore.assigned_to:
entities.append( entities.append(CompleteChoreButton(coordinator, entry, child, chore))
CompleteChoreButton(coordinator, entry, child, chore)
)
# Reward claim buttons # Reward claim buttons
for reward in rewards: for reward in rewards:
entities.append( entities.append(ClaimRewardButton(coordinator, entry, child, reward))
ClaimRewardButton(coordinator, entry, child, reward)
)
# Track which entity combos already exist # Track which entity combos already exist
tracked_combos: set[str] = set() tracked_combos: set[str] = set()
@@ -77,16 +74,12 @@ async def async_setup_entry(
if not chore.assigned_to or child.id in chore.assigned_to: if not chore.assigned_to or child.id in chore.assigned_to:
key = f"{child.id}_{chore.id}_complete" key = f"{child.id}_{chore.id}_complete"
if key not in tracked_combos: if key not in tracked_combos:
new_entities.append( new_entities.append(CompleteChoreButton(coordinator, entry, child, chore))
CompleteChoreButton(coordinator, entry, child, chore)
)
tracked_combos.add(key) tracked_combos.add(key)
for reward in current_rewards: for reward in current_rewards:
key = f"{child.id}_{reward.id}_claim" key = f"{child.id}_{reward.id}_claim"
if key not in tracked_combos: if key not in tracked_combos:
new_entities.append( new_entities.append(ClaimRewardButton(coordinator, entry, child, reward))
ClaimRewardButton(coordinator, entry, child, reward)
)
tracked_combos.add(key) tracked_combos.add(key)
if new_entities: if new_entities:
@@ -142,7 +135,7 @@ class CompleteChoreButton(TaskMateBaseButton):
# Chores gained an optional icon in #683, defaulting to "". Fall back on # Chores gained an optional icon in #683, defaulting to "". Fall back on
# falsiness, not on the attribute being absent — otherwise every chore # falsiness, not on the attribute being absent — otherwise every chore
# without a picture gets a blank button icon. # without a picture gets a blank button icon.
return (getattr(chore, 'icon', "") or "mdi:check-circle") if chore else "mdi:check-circle" return (getattr(chore, "icon", "") or "mdi:check-circle") if chore else "mdi:check-circle"
@property @property
def extra_state_attributes(self) -> dict: def extra_state_attributes(self) -> dict:
+20 -21
View File
@@ -15,6 +15,7 @@ to keep in sync:
Read-only for now: completing a chore from the calendar is intentionally not Read-only for now: completing a chore from the calendar is intentionally not
supported. supported.
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
@@ -67,9 +68,7 @@ async def async_setup_entry(
coordinator.async_add_listener(_async_add_new) coordinator.async_add_listener(_async_add_new)
def _chore_applies_to_child( def _chore_applies_to_child(coordinator: TaskMateCoordinator, chore: Chore, child_id: str, day: date) -> bool:
coordinator: TaskMateCoordinator, chore: Chore, child_id: str, day: date
) -> bool:
"""True if ``chore`` is scheduled for ``child_id`` on ``day``. """True if ``chore`` is scheduled for ``child_id`` on ``day``.
Combines the recurrence schedule with the assignment engine so the calendar Combines the recurrence schedule with the assignment engine so the calendar
@@ -165,9 +164,7 @@ class TaskMateCalendar(CoordinatorEntity, CalendarEntity):
return [] return []
return self._build_events(child, start_date.date(), end_date.date()) return self._build_events(child, start_date.date(), end_date.date())
def _build_events( def _build_events(self, child: Child, start_day: date, end_day: date) -> list[CalendarEvent]:
self, child: Child, start_day: date, end_day: date
) -> list[CalendarEvent]:
coord = self.coordinator coord = self.coordinator
events: list[CalendarEvent] = [] events: list[CalendarEvent] = []
@@ -197,25 +194,27 @@ class TaskMateCalendar(CoordinatorEntity, CalendarEntity):
for chore in chores: for chore in chores:
if not _chore_applies_to_child(coord, chore, child.id, day): if not _chore_applies_to_child(coord, chore, child.id, day):
continue continue
window = coord._time_category_window( window = coord._time_category_window(getattr(chore, "time_category", "anytime"), day)
getattr(chore, "time_category", "anytime"), day
)
desc = _chore_description(chore) desc = _chore_description(chore)
if window is None: if window is None:
events.append(CalendarEvent( events.append(
start=day, CalendarEvent(
end=day + timedelta(days=1), start=day,
summary=chore.name, end=day + timedelta(days=1),
description=desc, summary=chore.name,
)) description=desc,
)
)
else: else:
start_dt, end_dt = window start_dt, end_dt = window
events.append(CalendarEvent( events.append(
start=start_dt.replace(tzinfo=tz), CalendarEvent(
end=end_dt.replace(tzinfo=tz), start=start_dt.replace(tzinfo=tz),
summary=chore.name, end=end_dt.replace(tzinfo=tz),
description=desc, summary=chore.name,
)) description=desc,
)
)
day += timedelta(days=1) day += timedelta(days=1)
return events return events
+2 -3
View File
@@ -7,6 +7,7 @@ is handled entirely by the dedicated TaskMate admin panel at
all of its functionality is available in the panel, and all data is stored in all of its functionality is available in the panel, and all data is stored in
the integration's own ``Store`` rather than in ``config_entry.options``. the integration's own ``Store`` rather than in ``config_entry.options``.
""" """
from __future__ import annotations from __future__ import annotations
from typing import Any from typing import Any
@@ -24,9 +25,7 @@ class TaskMateConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
VERSION = 1 VERSION = 1
async def async_step_user( async def async_step_user(self, user_input: dict[str, Any] | None = None) -> FlowResult:
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the initial step.""" """Handle the initial step."""
errors: dict[str, str] = {} errors: dict[str, str] = {}
+38 -37
View File
@@ -1,4 +1,5 @@
"""Constants for TaskMate integration.""" """Constants for TaskMate integration."""
from typing import Final from typing import Final
DOMAIN: Final = "taskmate" DOMAIN: Final = "taskmate"
@@ -131,10 +132,10 @@ TIME_CATEGORY_ICONS: Final = {
# always available) and never appears in this list. An empty label means # always available) and never appears in this list. An empty label means
# "use the translated built-in name for this id". # "use the translated built-in name for this id".
DEFAULT_TIME_PERIODS: Final = [ DEFAULT_TIME_PERIODS: Final = [
{"id": "morning", "label": "", "start": "06:00", "end": "12:00", "icon": "mdi:weather-sunny"}, {"id": "morning", "label": "", "start": "06:00", "end": "12:00", "icon": "mdi:weather-sunny"},
{"id": "afternoon", "label": "", "start": "12:00", "end": "17:00", "icon": "mdi:white-balance-sunny"}, {"id": "afternoon", "label": "", "start": "12:00", "end": "17:00", "icon": "mdi:white-balance-sunny"},
{"id": "evening", "label": "", "start": "17:00", "end": "21:00", "icon": "mdi:weather-sunset"}, {"id": "evening", "label": "", "start": "17:00", "end": "21:00", "icon": "mdi:weather-sunset"},
{"id": "night", "label": "", "start": "21:00", "end": "23:59", "icon": "mdi:weather-night"}, {"id": "night", "label": "", "start": "21:00", "end": "23:59", "icon": "mdi:weather-night"},
] ]
MAX_TIME_PERIODS: Final = 24 MAX_TIME_PERIODS: Final = 24
@@ -340,24 +341,24 @@ STATE_CLAIMED: Final = "claimed"
# Most sounds are synthesized via Web Audio API # Most sounds are synthesized via Web Audio API
# Fart sounds are CC0 audio files from BigSoundBank.com and GfxSounds.com # Fart sounds are CC0 audio files from BigSoundBank.com and GfxSounds.com
COMPLETION_SOUND_OPTIONS: Final = [ COMPLETION_SOUND_OPTIONS: Final = [
"none", # No sound "none", # No sound
"coin", # Coin collect sound "coin", # Coin collect sound
"levelup", # Level up / success sound "levelup", # Level up / success sound
"fanfare", # Celebratory fanfare "fanfare", # Celebratory fanfare
"chime", # Simple chime "chime", # Simple chime
"powerup", # Power up sound "powerup", # Power up sound
"undo", # Sad/descending "womp womp" for undo actions "undo", # Sad/descending "womp womp" for undo actions
"fart1", # Flatulence 1 (short) "fart1", # Flatulence 1 (short)
"fart2", # Flatulence 2 (short) "fart2", # Flatulence 2 (short)
"fart3", # Flatulence 3 (short) "fart3", # Flatulence 3 (short)
"fart4", # Pony flatulence 2 (~3 sec) "fart4", # Pony flatulence 2 (~3 sec)
"fart5", # Flatulence 4 - discreet (short) "fart5", # Flatulence 4 - discreet (short)
"fart6", # Prout'cochons 1 - pig game sound (short) "fart6", # Prout'cochons 1 - pig game sound (short)
"fart7", # Prout'cochons 2 - pig game sound (short) "fart7", # Prout'cochons 2 - pig game sound (short)
"fart8", # Prout'cochons 3 - pig game sound (short) "fart8", # Prout'cochons 3 - pig game sound (short)
"fart9", # Pony flatulence 1 (short) "fart9", # Pony flatulence 1 (short)
"fart10", # Baby fart (short) "fart10", # Baby fart (short)
"fart_random", # Random fart - picks a random fart sound each time! "fart_random", # Random fart - picks a random fart sound each time!
] ]
# Default completion sound # Default completion sound
@@ -377,22 +378,22 @@ DEFAULT_DIFFICULTY: Final = "medium"
DEFAULT_DIFFICULTY_MULTIPLIERS: Final = {"easy": 0.5, "medium": 1.0, "hard": 2.0} DEFAULT_DIFFICULTY_MULTIPLIERS: Final = {"easy": 0.5, "medium": 1.0, "hard": 2.0}
# --- Notification type IDs (v3.9.0) --- # --- Notification type IDs (v3.9.0) ---
NOTIF_TYPE_BEDTIME_REMINDER: Final = "bedtime_reminder" NOTIF_TYPE_BEDTIME_REMINDER: Final = "bedtime_reminder"
NOTIF_TYPE_STREAK_AT_RISK: Final = "streak_at_risk" NOTIF_TYPE_STREAK_AT_RISK: Final = "streak_at_risk"
NOTIF_TYPE_ALL_CHORES_DONE: Final = "all_chores_done" NOTIF_TYPE_ALL_CHORES_DONE: Final = "all_chores_done"
NOTIF_TYPE_BADGE_EARNED: Final = "badge_earned" NOTIF_TYPE_BADGE_EARNED: Final = "badge_earned"
NOTIF_TYPE_PENDING_CHORE_APPROVAL: Final = "pending_chore_approval" NOTIF_TYPE_PENDING_CHORE_APPROVAL: Final = "pending_chore_approval"
NOTIF_TYPE_PENDING_REWARD_CLAIM: Final = "pending_reward_claim" NOTIF_TYPE_PENDING_REWARD_CLAIM: Final = "pending_reward_claim"
NOTIF_TYPE_STREAK_MILESTONE: Final = "streak_milestone" NOTIF_TYPE_STREAK_MILESTONE: Final = "streak_milestone"
NOTIF_TYPE_LEVEL_UP: Final = "level_up" NOTIF_TYPE_LEVEL_UP: Final = "level_up"
NOTIF_TYPE_WEEKLY_DIGEST: Final = "weekly_digest" NOTIF_TYPE_WEEKLY_DIGEST: Final = "weekly_digest"
NOTIF_TYPE_CELEBRATION: Final = "celebration" NOTIF_TYPE_CELEBRATION: Final = "celebration"
NOTIF_TYPE_MANDATORY_REMINDER: Final = "mandatory_reminder" NOTIF_TYPE_MANDATORY_REMINDER: Final = "mandatory_reminder"
NOTIF_TYPE_MANDATORY_PARENT_ALERT: Final = "mandatory_parent_alert" NOTIF_TYPE_MANDATORY_PARENT_ALERT: Final = "mandatory_parent_alert"
NOTIF_TYPE_MONTHLY_REPORT: Final = "monthly_report" NOTIF_TYPE_MONTHLY_REPORT: Final = "monthly_report"
NOTIF_TYPE_SEASON_CHAMPION: Final = "season_champion" NOTIF_TYPE_SEASON_CHAMPION: Final = "season_champion"
NOTIF_TYPE_FAMILY_GOAL_REACHED: Final = "family_goal_reached" NOTIF_TYPE_FAMILY_GOAL_REACHED: Final = "family_goal_reached"
# Default notification tap target. Must match PANEL_URL_PATH in panel.py — # 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. # a bare /taskmate is the static-files prefix and returns 403, not the panel.
DEFAULT_NOTIFICATION_NAV_URL: Final = "/taskmate-admin" DEFAULT_NOTIFICATION_NAV_URL: Final = "/taskmate-admin"
+73 -27
View File
@@ -1,4 +1,5 @@
"""Assignment operations mixin for TaskMateCoordinator.""" """Assignment operations mixin for TaskMateCoordinator."""
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
@@ -118,9 +119,15 @@ class AssignmentsMixin:
await self.storage.async_save() await self.storage.async_save()
await self.async_refresh() await self.async_refresh()
_AVAILABLE_STATES: frozenset[str] = frozenset({ _AVAILABLE_STATES: frozenset[str] = frozenset(
"on", "home", "available", "present", "true", {
}) "on",
"home",
"available",
"present",
"true",
}
)
def _is_visibility_entity_active( def _is_visibility_entity_active(
self, visibility_entity: str, visibility_state: str, visibility_operator: str = "equals" self, visibility_entity: str, visibility_state: str, visibility_operator: str = "equals"
@@ -201,7 +208,7 @@ class AssignmentsMixin:
return True return True
# Check attributes for a matching value # Check attributes for a matching value
if hasattr(state_obj, 'attributes') and state_obj.attributes: if hasattr(state_obj, "attributes") and state_obj.attributes:
for attr_value in state_obj.attributes.values(): for attr_value in state_obj.attributes.values():
if str(attr_value).lower() == parsed_state.lower(): if str(attr_value).lower() == parsed_state.lower():
return True return True
@@ -232,7 +239,8 @@ class AssignmentsMixin:
if state_obj is None or state_obj.state in ("unavailable", "unknown", None, ""): if state_obj is None or state_obj.state in ("unavailable", "unknown", None, ""):
_LOGGER.debug( _LOGGER.debug(
"Weather entity '%s' unavailable, not blocking chore '%s'", "Weather entity '%s' unavailable, not blocking chore '%s'",
entity_id, getattr(chore, "name", ""), entity_id,
getattr(chore, "name", ""),
) )
return None return None
@@ -352,6 +360,26 @@ class AssignmentsMixin:
return cached return cached
return self._compute_active_children_uncached(chore, today) return self._compute_active_children_uncached(chore, today)
def _swap_override(self, chore: Chore, today: date | None = None) -> str:
"""The child an approved sibling swap moved this chore to for ``today``.
Returns "" when there is no swap for that date, or when the swapped-to
child is no longer in the chore's pool (removed from `assigned_to`, or
deleted). Stamped with a date so it expires on its own — a swap is a
one-day arrangement, and probes of other days must stay pure rotation.
"""
swap_date = getattr(chore, "assignment_swap_date", "") or ""
swapped_to = getattr(chore, "assignment_swap_child_id", "") or ""
if not swap_date or not swapped_to:
return ""
if today is None:
today = dt_util.as_local(dt_util.now()).date()
if swap_date != today.isoformat():
return ""
if swapped_to not in self._chore_assignment_pool(chore):
return ""
return swapped_to
def _compute_active_children_uncached(self, chore: Chore, today: date | None = None) -> list[str]: def _compute_active_children_uncached(self, chore: Chore, today: date | None = None) -> list[str]:
mode = getattr(chore, "assignment_mode", "everyone") mode = getattr(chore, "assignment_mode", "everyone")
require_availability = getattr(chore, "require_availability", False) require_availability = getattr(chore, "require_availability", False)
@@ -359,6 +387,16 @@ class AssignmentsMixin:
if mode == "unassigned": if mode == "unassigned":
return [] return []
# 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
# outranks an availability entity. "everyone" chores have no single
# assignee to move, and async_request_swap already refuses them.
if mode != "everyone":
swapped_to = self._swap_override(chore, today)
if swapped_to:
return [swapped_to]
if mode == "first_come": if mode == "first_come":
# Competitive: every child in the resolved pool sees it until the # Competitive: every child in the resolved pool sees it until the
# first completion fills the shared quota (see _is_rotation_done_today). # first completion fills the shared quota (see _is_rotation_done_today).
@@ -458,9 +496,7 @@ class AssignmentsMixin:
return result return result
def _apply_sticky_policy( def _apply_sticky_policy(self, group, chore_by_id: dict[str, Chore], result: dict[str, str]) -> None:
self, group, chore_by_id: dict[str, Chore], result: dict[str, str]
) -> None:
"""Force followers onto the leader chore's assignee (when in pool).""" """Force followers onto the leader chore's assignee (when in pool)."""
leader_id = group.chore_ids[0] leader_id = group.chore_ids[0]
leader_child = result.get(leader_id) leader_child = result.get(leader_id)
@@ -478,12 +514,12 @@ class AssignmentsMixin:
else: else:
_LOGGER.debug( _LOGGER.debug(
"STICKY fallback: leader %s assigned to %s not in follower %s pool", "STICKY fallback: leader %s assigned to %s not in follower %s pool",
leader_id, leader_child, follower_id, leader_id,
leader_child,
follower_id,
) )
def _apply_spread_policy( def _apply_spread_policy(self, group, chore_by_id: dict[str, Chore], result: dict[str, str]) -> None:
self, group, chore_by_id: dict[str, Chore], result: dict[str, str]
) -> None:
"""Assign group members to distinct children; wraps when pool < group size.""" """Assign group members to distinct children; wraps when pool < group size."""
used: set[str] = set() used: set[str] = set()
for chore_id in group.chore_ids: for chore_id in group.chore_ids:
@@ -529,17 +565,18 @@ class AssignmentsMixin:
size = len(pool) size = len(pool)
# Cache per-call so the same child isn't queried twice in a scan. # Cache per-call so the same child isn't queried twice in a scan.
cache: dict[str, bool] = {} cache: dict[str, bool] = {}
def available(cid: str) -> bool: def available(cid: str) -> bool:
if cid not in cache: if cid not in cache:
cache[cid] = self._is_child_available(cid) cache[cid] = self._is_child_available(cid)
return cache[cid] return cache[cid]
for step in range(size): for step in range(size):
cid = pool[(start_idx + step) % size] cid = pool[(start_idx + step) % size]
if available(cid): if available(cid):
return cid return cid
_LOGGER.debug( _LOGGER.debug(
"Availability skip: no available child in pool %s for chore, " "Availability skip: no available child in pool %s for chore, hiding chore (all children unavailable)",
"hiding chore (all children unavailable)",
pool, pool,
) )
return "" return ""
@@ -557,7 +594,7 @@ class AssignmentsMixin:
active child still has uncompleted bonus sub-tasks for today, keep active child still has uncompleted bonus sub-tasks for today, keep
the chore visible (return False) so they remain reachable. the chore visible (return False) so they remain reachable.
""" """
if getattr(chore, 'assignment_mode', 'everyone') == 'everyone': if getattr(chore, "assignment_mode", "everyone") == "everyone":
return False return False
# PERF-1: result depends only on the chore; memoize per availability build. # PERF-1: result depends only on the chore; memoize per availability build.
cache = getattr(self, "_avail_cache", None) cache = getattr(self, "_avail_cache", None)
@@ -573,7 +610,7 @@ class AssignmentsMixin:
if not pool: if not pool:
return False return False
today = dt_util.as_local(dt_util.now()).date() today = dt_util.as_local(dt_util.now()).date()
active_child_id = getattr(chore, 'assignment_current_child_id', '') or '' active_child_id = getattr(chore, "assignment_current_child_id", "") or ""
completions_today = 0 completions_today = 0
completed_bonus_ids_today: set[str] = set() completed_bonus_ids_today: set[str] = set()
for comp in self._cached_completions(): for comp in self._cached_completions():
@@ -581,14 +618,14 @@ class AssignmentsMixin:
continue continue
comp_dt = comp.completed_at comp_dt = comp.completed_at
try: try:
if hasattr(comp_dt, 'astimezone'): if hasattr(comp_dt, "astimezone"):
comp_dt = dt_util.as_local(comp_dt) comp_dt = dt_util.as_local(comp_dt)
comp_date = comp_dt.date() if hasattr(comp_dt, 'date') else None comp_date = comp_dt.date() if hasattr(comp_dt, "date") else None
except (AttributeError, TypeError, ValueError): except (AttributeError, TypeError, ValueError):
continue continue
if comp_date != today: if comp_date != today:
continue continue
bonus_id = getattr(comp, 'bonus_subtask_id', None) bonus_id = getattr(comp, "bonus_subtask_id", None)
if bonus_id: if bonus_id:
# Bonus completions don't count toward the parent's daily # Bonus completions don't count toward the parent's daily
# quota; track them only to decide whether the active child # quota; track them only to decide whether the active child
@@ -601,16 +638,16 @@ class AssignmentsMixin:
if comp.child_id in pool or comp.child_id == "__parent__": if comp.child_id in pool or comp.child_id == "__parent__":
completions_today += 1 completions_today += 1
# first_come is a single-winner race: clamp any mis-configured quota to 1. # first_come is a single-winner race: clamp any mis-configured quota to 1.
if getattr(chore, 'assignment_mode', 'everyone') == 'first_come': if getattr(chore, "assignment_mode", "everyone") == "first_come":
daily_limit = 1 daily_limit = 1
else: else:
daily_limit = getattr(chore, 'daily_limit', 1) or 1 daily_limit = getattr(chore, "daily_limit", 1) or 1
if completions_today < daily_limit: if completions_today < daily_limit:
return False return False
bonus_subtasks = getattr(chore, 'bonus_subtasks', None) or [] bonus_subtasks = getattr(chore, "bonus_subtasks", None) or []
if bonus_subtasks and active_child_id: if bonus_subtasks and active_child_id:
for bst in bonus_subtasks: for bst in bonus_subtasks:
bst_id = getattr(bst, 'id', None) bst_id = getattr(bst, "id", None)
if bst_id and bst_id not in completed_bonus_ids_today: if bst_id and bst_id not in completed_bonus_ids_today:
return False return False
return True return True
@@ -621,8 +658,8 @@ class AssignmentsMixin:
Runs at midnight. All chores are processed concurrently so the runtime Runs at midnight. All chores are processed concurrently so the runtime
is bounded by the slowest single publish, not the sum across chores. is bounded by the slowest single publish, not the sum across chores.
Also clears stale skip state (skip_date != today) so yesterday's skip Also clears stale skip and swap state (dated != today) so yesterday's
doesn't bleed into the new day. skip or approved sibling swap doesn't bleed into the new day.
""" """
today = dt_util.as_local(dt_util.now()).date() today = dt_util.as_local(dt_util.now()).date()
today_iso = today.isoformat() today_iso = today.isoformat()
@@ -630,11 +667,16 @@ class AssignmentsMixin:
if not chores: if not chores:
return return
# Clear stale skip state in-memory (persisted via update_chore below). # Clear stale skip/swap state in-memory (persisted via update_chore
# below). Both are read-time-guarded by their date too, so this is
# housekeeping rather than a correctness requirement.
for chore in chores: for chore in chores:
if getattr(chore, "skip_date", "") and chore.skip_date != today_iso: if getattr(chore, "skip_date", "") and chore.skip_date != today_iso:
chore.skip_date = "" chore.skip_date = ""
chore.skip_count = 0 chore.skip_count = 0
if getattr(chore, "assignment_swap_date", "") and chore.assignment_swap_date != today_iso:
chore.assignment_swap_date = ""
chore.assignment_swap_child_id = ""
# Group-aware daily assignment map. # Group-aware daily assignment map.
daily = self._compute_daily_assignments(today) daily = self._compute_daily_assignments(today)
@@ -650,11 +692,15 @@ class AssignmentsMixin:
await self._publish_chore_to_calendars(chore, today) await self._publish_chore_to_calendars(chore, today)
if list(getattr(chore, "publish_calendar_published_dates", []) or []) != before: if list(getattr(chore, "publish_calendar_published_dates", []) or []) != before:
dirty = True dirty = True
# Always persist if skip state was cleared above. # Always persist if skip/swap state was cleared above.
if getattr(chore, "skip_date", "") == "" and getattr(chore, "skip_count", 0) == 0: if getattr(chore, "skip_date", "") == "" and getattr(chore, "skip_count", 0) == 0:
stored = self.storage.get_chore(chore.id) stored = self.storage.get_chore(chore.id)
if stored and (stored.skip_date or stored.skip_count): if stored and (stored.skip_date or stored.skip_count):
dirty = True dirty = True
if getattr(chore, "assignment_swap_date", "") == "":
stored = self.storage.get_chore(chore.id)
if stored and getattr(stored, "assignment_swap_date", ""):
dirty = True
if dirty: if dirty:
self.storage.update_chore(chore) self.storage.update_chore(chore)
return dirty return dirty
+27 -22
View File
@@ -5,6 +5,7 @@ a lifetime-points total, or a best-streak length — or ``free`` for always
available). Children unlock avatars by hitting those milestones and can switch available). Children unlock avatars by hitting those milestones and can switch
to any avatar they've unlocked; parents can set any catalogue avatar. to any avatar they've unlocked; parents can set any catalogue avatar.
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
@@ -14,14 +15,14 @@ _LOGGER = logging.getLogger(__name__)
# Shipped defaults so the feature is useful out of the box. Parents can replace # Shipped defaults so the feature is useful out of the box. Parents can replace
# the whole list from the panel. # the whole list from the panel.
DEFAULT_AVATAR_CATALOG: list[dict] = [ DEFAULT_AVATAR_CATALOG: list[dict] = [
{"id": "starter", "label": "Starter", "icon": "mdi:account-circle", "unlock_type": "free", "unlock_value": 0}, {"id": "starter", "label": "Starter", "icon": "mdi:account-circle", "unlock_type": "free", "unlock_value": 0},
{"id": "rocket", "label": "Rocket", "icon": "mdi:rocket-launch", "unlock_type": "level", "unlock_value": 3}, {"id": "rocket", "label": "Rocket", "icon": "mdi:rocket-launch", "unlock_type": "level", "unlock_value": 3},
{"id": "robot", "label": "Robot", "icon": "mdi:robot-happy", "unlock_type": "level", "unlock_value": 5}, {"id": "robot", "label": "Robot", "icon": "mdi:robot-happy", "unlock_type": "level", "unlock_value": 5},
{"id": "ninja", "label": "Ninja", "icon": "mdi:ninja", "unlock_type": "level", "unlock_value": 10}, {"id": "ninja", "label": "Ninja", "icon": "mdi:ninja", "unlock_type": "level", "unlock_value": 10},
{"id": "crown", "label": "Royalty", "icon": "mdi:crown", "unlock_type": "points", "unlock_value": 500}, {"id": "crown", "label": "Royalty", "icon": "mdi:crown", "unlock_type": "points", "unlock_value": 500},
{"id": "trophy", "label": "Champion", "icon": "mdi:trophy", "unlock_type": "points", "unlock_value": 1000}, {"id": "trophy", "label": "Champion", "icon": "mdi:trophy", "unlock_type": "points", "unlock_value": 1000},
{"id": "fire", "label": "On Fire", "icon": "mdi:fire", "unlock_type": "streak", "unlock_value": 7}, {"id": "fire", "label": "On Fire", "icon": "mdi:fire", "unlock_type": "streak", "unlock_value": 7},
{"id": "diamond", "label": "Diamond", "icon": "mdi:diamond-stone", "unlock_type": "streak", "unlock_value": 30}, {"id": "diamond", "label": "Diamond", "icon": "mdi:diamond-stone", "unlock_type": "streak", "unlock_value": 30},
] ]
@@ -67,13 +68,15 @@ class AvatarsMixin:
req = f"{value}-day streak" req = f"{value}-day streak"
else: else:
req = "" req = ""
out.append({ out.append(
"id": entry.get("id", entry.get("icon")), {
"label": entry.get("label", ""), "id": entry.get("id", entry.get("icon")),
"icon": entry.get("icon"), "label": entry.get("label", ""),
"unlocked": self._avatar_unlocked(entry, child), "icon": entry.get("icon"),
"requirement": req, "unlocked": self._avatar_unlocked(entry, child),
}) "requirement": req,
}
)
return out return out
async def async_update_avatar_catalog(self, catalog: list[dict]) -> None: async def async_update_avatar_catalog(self, catalog: list[dict]) -> None:
@@ -83,13 +86,15 @@ class AvatarsMixin:
icon = (a.get("icon") or "").strip() icon = (a.get("icon") or "").strip()
if not icon: if not icon:
continue continue
cleaned.append({ cleaned.append(
"id": (a.get("id") or icon).strip(), {
"label": (a.get("label") or "").strip(), "id": (a.get("id") or icon).strip(),
"icon": icon, "label": (a.get("label") or "").strip(),
"unlock_type": a.get("unlock_type", "free"), "icon": icon,
"unlock_value": int(a.get("unlock_value", 0) or 0), "unlock_type": a.get("unlock_type", "free"),
}) "unlock_value": int(a.get("unlock_value", 0) or 0),
}
)
self.storage.set_setting("avatar_catalog", cleaned) self.storage.set_setting("avatar_catalog", cleaned)
await self.storage.async_save() await self.storage.async_save()
await self.async_refresh() await self.async_refresh()
+119 -41
View File
@@ -1,4 +1,5 @@
"""Badge evaluation engine and built-in catalogue.""" """Badge evaluation engine and built-in catalogue."""
from __future__ import annotations from __future__ import annotations
import logging import logging
@@ -8,8 +9,9 @@ from .models import Badge, BadgeCriterion, Child
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
def _b(id_suffix: str, name: str, description: str, icon: str, tier: str, def _b(
point_bonus: int, metric: str, value: int) -> Badge: id_suffix: str, name: str, description: str, icon: str, tier: str, point_bonus: int, metric: str, value: int
) -> Badge:
"""Helper to build a built-in badge.""" """Helper to build a built-in badge."""
criteria = [BadgeCriterion(metric=metric, operator=">=", value=value)] if metric else [] criteria = [BadgeCriterion(metric=metric, operator=">=", value=value)] if metric else []
badge = Badge( badge = Badge(
@@ -29,39 +31,116 @@ def _b(id_suffix: str, name: str, description: str, icon: str, tier: str,
BUILTIN_CATALOGUE: list[Badge] = [ BUILTIN_CATALOGUE: list[Badge] = [
# Bronze # Bronze
_b("first_chore", "First Chore", "Complete your very first chore", _b(
"mdi:check-circle", "bronze", 0, "first_chore", 1), "first_chore",
_b("first_reward", "First Reward", "Claim your first reward", "First Chore",
"mdi:gift", "bronze", 0, "first_reward", 1), "Complete your very first chore",
_b("100_points", "100 Points", "Earn 100 lifetime points", "mdi:check-circle",
"mdi:star", "bronze", 0, "total_points", 100), "bronze",
_b("10_chores", "10 Chores Completed", "Complete 10 chores", 0,
"mdi:checkbox-marked-circle", "bronze", 0, "total_chores", 10), "first_chore",
1,
),
_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),
_b(
"10_chores",
"10 Chores Completed",
"Complete 10 chores",
"mdi:checkbox-marked-circle",
"bronze",
0,
"total_chores",
10,
),
# Silver # Silver
_b("500_points", "500 Points", "Earn 500 lifetime points", _b("500_points", "500 Points", "Earn 500 lifetime points", "mdi:star-circle", "silver", 25, "total_points", 500),
"mdi:star-circle", "silver", 25, "total_points", 500), _b(
_b("50_chores", "50 Chores Completed", "Complete 50 chores", "50_chores",
"mdi:checkbox-multiple-marked-circle", "silver", 25, "total_chores", 50), "50 Chores Completed",
_b("3_day_streak", "3-Day Streak", "Complete chores 3 days in a row", "Complete 50 chores",
"mdi:fire", "silver", 25, "current_streak", 3), "mdi:checkbox-multiple-marked-circle",
_b("first_perfect_week", "First Perfect Week", "Complete a perfect week", "silver",
"mdi:calendar-star", "silver", 50, "perfect_weeks", 1), 25,
"total_chores",
50,
),
_b(
"3_day_streak", "3-Day Streak", "Complete chores 3 days in a row", "mdi:fire", "silver", 25, "current_streak", 3
),
_b(
"first_perfect_week",
"First Perfect Week",
"Complete a perfect week",
"mdi:calendar-star",
"silver",
50,
"perfect_weeks",
1,
),
# Gold # Gold
_b("1000_points", "1000 Points", "Earn 1000 lifetime points", _b("1000_points", "1000 Points", "Earn 1000 lifetime points", "mdi:trophy", "gold", 100, "total_points", 1000),
"mdi:trophy", "gold", 100, "total_points", 1000), _b(
_b("100_chores", "100 Chores Completed", "Complete 100 chores", "100_chores",
"mdi:trophy-variant", "gold", 100, "total_chores", 100), "100 Chores Completed",
_b("7_day_streak", "7-Day Streak", "Complete chores 7 days in a row", "Complete 100 chores",
"mdi:lightning-bolt", "gold", 50, "current_streak", 7), "mdi:trophy-variant",
_b("5_perfect_weeks", "5 Perfect Weeks", "Achieve 5 perfect weeks", "gold",
"mdi:calendar-multiple-check", "gold", 100, "perfect_weeks", 5), 100,
"total_chores",
100,
),
_b(
"7_day_streak",
"7-Day Streak",
"Complete chores 7 days in a row",
"mdi:lightning-bolt",
"gold",
50,
"current_streak",
7,
),
_b(
"5_perfect_weeks",
"5 Perfect Weeks",
"Achieve 5 perfect weeks",
"mdi:calendar-multiple-check",
"gold",
100,
"perfect_weeks",
5,
),
# Platinum # Platinum
_b("5000_points", "5000 Points", "Earn 5000 lifetime points", _b(
"mdi:diamond-stone", "platinum", 250, "total_points", 5000), "5000_points",
_b("30_day_streak", "30-Day Streak", "Complete chores 30 days in a row", "5000 Points",
"mdi:crown", "platinum", 250, "current_streak", 30), "Earn 5000 lifetime points",
_b("10_perfect_weeks", "10 Perfect Weeks", "Achieve 10 perfect weeks", "mdi:diamond-stone",
"mdi:rainbow", "platinum", 250, "perfect_weeks", 10), "platinum",
250,
"total_points",
5000,
),
_b(
"30_day_streak",
"30-Day Streak",
"Complete chores 30 days in a row",
"mdi:crown",
"platinum",
250,
"current_streak",
30,
),
_b(
"10_perfect_weeks",
"10 Perfect Weeks",
"Achieve 10 perfect weeks",
"mdi:rainbow",
"platinum",
250,
"perfect_weeks",
10,
),
] ]
@@ -80,10 +159,7 @@ def resolve_metric(metric: str, child: Child, storage) -> int:
if metric == "first_chore": if metric == "first_chore":
return 1 if (child.total_chores_completed or 0) >= 1 else 0 return 1 if (child.total_chores_completed or 0) >= 1 else 0
if metric in ("total_rewards", "first_reward"): if metric in ("total_rewards", "first_reward"):
approved_count = sum( approved_count = sum(1 for c in storage.get_reward_claims() if c.child_id == child.id and c.approved)
1 for c in storage.get_reward_claims()
if c.child_id == child.id and c.approved
)
if metric == "first_reward": if metric == "first_reward":
return 1 if approved_count >= 1 else 0 return 1 if approved_count >= 1 else 0
return approved_count return approved_count
@@ -240,7 +316,9 @@ class BadgeCoordinator:
self.storage.add_awarded_badge(award) self.storage.add_awarded_badge(award)
if bonus > 0: if bonus > 0:
await self.points_coord.async_add_points( await self.points_coord.async_add_points(
child_id, bonus, reason=f"Badge: {badge.name}", child_id,
bonus,
reason=f"Badge: {badge.name}",
) )
self.hass.bus.async_fire( self.hass.bus.async_fire(
"taskmate_badge_earned", "taskmate_badge_earned",
@@ -260,9 +338,7 @@ class BadgeCoordinator:
async def revoke(self, awarded_id: str) -> bool: async def revoke(self, awarded_id: str) -> bool:
"""Revoke an awarded badge; reverse bonus_credited if > 0.""" """Revoke an awarded badge; reverse bonus_credited if > 0."""
matching = [ matching = [a for a in self.storage.get_awarded_badges() if a.id == awarded_id]
a for a in self.storage.get_awarded_badges() if a.id == awarded_id
]
if not matching: if not matching:
return False return False
award = matching[0] award = matching[0]
@@ -288,7 +364,9 @@ class BadgeCoordinator:
total = 0 total = 0
for child in self.storage.get_children(): for child in self.storage.get_children():
new_awards = await self.evaluate_for_child( new_awards = await self.evaluate_for_child(
child.id, "manual", silent=True, child.id,
"manual",
silent=True,
) )
total += len(new_awards) total += len(new_awards)
return total return total
+23 -20
View File
@@ -1,4 +1,5 @@
"""Calendar operations mixin for TaskMateCoordinator.""" """Calendar operations mixin for TaskMateCoordinator."""
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
@@ -55,13 +56,15 @@ class CalendarMixin:
pid = str(entry.get("id") or "").strip() pid = str(entry.get("id") or "").strip()
if not pid or pid == "anytime" or start is None or end is None: if not pid or pid == "anytime" or start is None or end is None:
continue continue
periods.append({ periods.append(
"id": pid, {
"label": str(entry.get("label") or "").strip(), "id": pid,
"start": start.strftime("%H:%M"), "label": str(entry.get("label") or "").strip(),
"end": end.strftime("%H:%M"), "start": start.strftime("%H:%M"),
"icon": str(entry.get("icon") or "") or TIME_CATEGORY_ICONS.get(pid, "mdi:clock-outline"), "end": end.strftime("%H:%M"),
}) "icon": str(entry.get("icon") or "") or TIME_CATEGORY_ICONS.get(pid, "mdi:clock-outline"),
}
)
if periods: if periods:
return sorted(periods, key=lambda p: p["start"]) return sorted(periods, key=lambda p: p["start"])
@@ -74,13 +77,15 @@ class CalendarMixin:
end_str = self.storage.get_setting(f"time_{pid}_end", default["end"]) end_str = self.storage.get_setting(f"time_{pid}_end", default["end"])
start = self._parse_hhmm(start_str) or self._parse_hhmm(default["start"]) start = self._parse_hhmm(start_str) or self._parse_hhmm(default["start"])
end = self._parse_hhmm(end_str) or self._parse_hhmm(default["end"]) end = self._parse_hhmm(end_str) or self._parse_hhmm(default["end"])
periods.append({ periods.append(
"id": pid, {
"label": "", "id": pid,
"start": start.strftime("%H:%M"), "label": "",
"end": end.strftime("%H:%M"), "start": start.strftime("%H:%M"),
"icon": default["icon"], "end": end.strftime("%H:%M"),
}) "icon": default["icon"],
}
)
return sorted(periods, key=lambda p: p["start"]) return sorted(periods, key=lambda p: p["start"])
def _get_time_boundaries(self) -> dict[str, tuple[time, time] | None]: def _get_time_boundaries(self) -> dict[str, tuple[time, time] | None]:
@@ -109,9 +114,9 @@ class CalendarMixin:
def _calendar_projection_days(self) -> int: def _calendar_projection_days(self) -> int:
"""Return the configured projection horizon, clamped to the allowed range.""" """Return the configured projection horizon, clamped to the allowed range."""
try: try:
raw = int(float(self.storage.get_setting( raw = int(
"calendar_projection_days", str(DEFAULT_CALENDAR_PROJECTION_DAYS) float(self.storage.get_setting("calendar_projection_days", str(DEFAULT_CALENDAR_PROJECTION_DAYS)))
))) )
except (TypeError, ValueError): except (TypeError, ValueError):
raw = DEFAULT_CALENDAR_PROJECTION_DAYS raw = DEFAULT_CALENDAR_PROJECTION_DAYS
return max(MIN_CALENDAR_PROJECTION_DAYS, min(MAX_CALENDAR_PROJECTION_DAYS, raw)) return max(MIN_CALENDAR_PROJECTION_DAYS, min(MAX_CALENDAR_PROJECTION_DAYS, raw))
@@ -204,9 +209,7 @@ class CalendarMixin:
def _build_event_payload(self, chore: Chore, day: date, summary: str) -> dict: def _build_event_payload(self, chore: Chore, day: date, summary: str) -> dict:
"""Build the calendar.create_event payload for one (chore, day).""" """Build the calendar.create_event payload for one (chore, day)."""
description = self._chore_event_marker(chore) description = self._chore_event_marker(chore)
window = self._time_category_window( window = self._time_category_window(getattr(chore, "time_category", "anytime"), day)
getattr(chore, "time_category", "anytime"), day
)
if window is None: if window is None:
return { return {
"summary": summary, "summary": summary,
+40 -25
View File
@@ -5,6 +5,7 @@ points this week — and awards a one-off bonus when a child hits it. Progress
and the award reset automatically when the period rolls over (a new day or a and the award reset automatically when the period rolls over (a new day or a
new Monday-anchored week). new Monday-anchored week).
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
@@ -89,19 +90,21 @@ class ChallengesMixin:
_, period_key = self._period_start_key(ch.scope) _, period_key = self._period_start_key(ch.scope)
prog = self.storage.get_challenge_child_progress(ch.id, child_id) prog = self.storage.get_challenge_child_progress(ch.id, child_id)
awarded = bool(prog.get("awarded")) and prog.get("period") == period_key awarded = bool(prog.get("awarded")) and prog.get("period") == period_key
out.append({ out.append(
"challenge_id": ch.id, {
"name": ch.name, "challenge_id": ch.id,
"icon": ch.icon, "name": ch.name,
"scope": ch.scope, "icon": ch.icon,
"metric": ch.metric, "scope": ch.scope,
"target": ch.target, "metric": ch.metric,
"progress": min(value, ch.target), "target": ch.target,
"value": value, "progress": min(value, ch.target),
"bonus_points": ch.bonus_points, "value": value,
"complete": value >= ch.target, "bonus_points": ch.bonus_points,
"awarded": awarded, "complete": value >= ch.target,
}) "awarded": awarded,
}
)
return out return out
# ── Evaluation ─────────────────────────────────────────────────────── # ── Evaluation ───────────────────────────────────────────────────────
@@ -142,24 +145,36 @@ class ChallengesMixin:
child.points += bonus child.points += bonus
child.total_points_earned += bonus child.total_points_earned += bonus
child.career_score = child.total_points_earned - child.total_penalties_received child.career_score = child.total_points_earned - child.total_penalties_received
self.storage.add_points_transaction(PointsTransaction( self.storage.add_points_transaction(
child_id=child.id, points=bonus, PointsTransaction(
reason=f"Challenge complete: {challenge.name}", created_at=dt_util.now(), child_id=child.id,
)) points=bonus,
reason=f"Challenge complete: {challenge.name}",
created_at=dt_util.now(),
)
)
if hasattr(self, "_maybe_level_up"): if hasattr(self, "_maybe_level_up"):
await self._maybe_level_up(child) await self._maybe_level_up(child)
self.storage.update_child(child) self.storage.update_child(child)
self.hass.bus.async_fire("taskmate_challenge_completed", { self.hass.bus.async_fire(
"child_id": child.id, "child_name": child.name, "taskmate_challenge_completed",
"challenge_id": challenge.id, "challenge_name": challenge.name, {
"scope": challenge.scope, "bonus": bonus, "child_id": child.id,
"timestamp": dt_util.now().isoformat(), "child_name": child.name,
}) "challenge_id": challenge.id,
"challenge_name": challenge.name,
"scope": challenge.scope,
"bonus": bonus,
"timestamp": dt_util.now().isoformat(),
},
)
if hasattr(self, "_celebrate"): if hasattr(self, "_celebrate"):
await self._celebrate( await self._celebrate(
child, "challenge_completed", child,
"challenge_completed",
f"{child.name} completed the challenge '{challenge.name}'!", f"{child.name} completed the challenge '{challenge.name}'!",
tier=2, extra={"challenge_id": challenge.id, "bonus": bonus}, tier=2,
extra={"challenge_id": challenge.id, "bonus": bonus},
) )
_LOGGER.info("Challenge '%s' completed by %s (+%d)", challenge.name, child.name, bonus) _LOGGER.info("Challenge '%s' completed by %s (+%d)", challenge.name, child.name, bonus)
+188 -148
View File
@@ -1,4 +1,5 @@
"""Chore operations mixin for TaskMateCoordinator.""" """Chore operations mixin for TaskMateCoordinator."""
from __future__ import annotations from __future__ import annotations
import logging import logging
@@ -26,10 +27,15 @@ def _add_months(d: date, months: int) -> date:
_DOW_MAP = { _DOW_MAP = {
'monday': 0, 'tuesday': 1, 'wednesday': 2, 'thursday': 3, "monday": 0,
'friday': 4, 'saturday': 5, 'sunday': 6, "tuesday": 1,
"wednesday": 2,
"thursday": 3,
"friday": 4,
"saturday": 5,
"sunday": 6,
} }
_MONTH_STEPS = {'monthly': 1, 'every_3_months': 3, 'every_6_months': 6} _MONTH_STEPS = {"monthly": 1, "every_3_months": 3, "every_6_months": 6}
class ChoresMixin: class ChoresMixin:
@@ -124,7 +130,9 @@ class ChoresMixin:
# For random/balanced manual-start, override today's cached child so # For random/balanced manual-start, override today's cached child so
# the parent sees the chosen child immediately. # the parent sees the chosen child immediately.
if manual_start_child_id and resolved_mode in ("random", "balanced"): if manual_start_child_id and resolved_mode in ("random", "balanced"):
resolved_pool = self._chore_assignment_pool(chore) if chore.assigned_to else [c.id for c in self.storage.get_children()] resolved_pool = (
self._chore_assignment_pool(chore) if chore.assigned_to else [c.id for c in self.storage.get_children()]
)
if manual_start_child_id in resolved_pool: if manual_start_child_id in resolved_pool:
chore.assignment_current_child_id = manual_start_child_id chore.assignment_current_child_id = manual_start_child_id
self.storage.add_chore(chore) self.storage.add_chore(chore)
@@ -138,6 +146,7 @@ class ChoresMixin:
async def async_request_swap(self, chore_id: str, requester_id: str) -> str: async def async_request_swap(self, chore_id: str, requester_id: str) -> str:
"""A child requests to take over today's rotation assignment of a chore.""" """A child requests to take over today's rotation assignment of a chore."""
from .models import generate_id from .models import generate_id
chore = self.get_chore(chore_id) chore = self.get_chore(chore_id)
if not chore: if not chore:
raise ValueError(f"Chore {chore_id} not found") raise ValueError(f"Chore {chore_id} not found")
@@ -164,20 +173,37 @@ class ChoresMixin:
async def async_approve_swap(self, req_id: str) -> None: async def async_approve_swap(self, req_id: str) -> None:
"""Approve a swap — reassign today's chore to the requester.""" """Approve a swap — reassign today's chore to the requester."""
req = next((r for r in self.storage.get_swap_requests() req = next(
if r.get("id") == req_id and r.get("status") == "pending"), None) (r for r in self.storage.get_swap_requests() if r.get("id") == req_id and r.get("status") == "pending"),
None,
)
if not req: if not req:
raise ValueError(f"Swap request {req_id} not found") raise ValueError(f"Swap request {req_id} not found")
chore = self.get_chore(req["chore_id"]) chore = self.get_chore(req["chore_id"])
if chore: if chore:
# Stamp the dated override *and* the cached current child. The
# override is what every eligibility gate reads (#781); the cached
# field keeps the sensors/cards consistent without waiting for the
# next assignment pass.
chore.assignment_swap_child_id = req["requester_id"]
chore.assignment_swap_date = dt_util.as_local(dt_util.now()).date().isoformat()
chore.assignment_current_child_id = req["requester_id"] chore.assignment_current_child_id = req["requester_id"]
self.storage.update_chore(chore) self.storage.update_chore(chore)
self.storage.update_swap_request(req_id, status="approved") # Consume the request, exactly as rejection does (#783). Nothing reads a
self.hass.bus.async_fire("taskmate_swap_approved", { # request once it leaves "pending" — both readers filter on it — so
"chore_id": req["chore_id"], "requester_id": req["requester_id"], # keeping it would grow the store forever. The approval stays observable
"from_child_id": req.get("from_child_id", ""), # through the event below and the chore's own dated override. `req` is
"timestamp": dt_util.now().isoformat(), # already captured, so the payload survives the removal.
}) self.storage.remove_swap_request(req_id)
self.hass.bus.async_fire(
"taskmate_swap_approved",
{
"chore_id": req["chore_id"],
"requester_id": req["requester_id"],
"from_child_id": req.get("from_child_id", ""),
"timestamp": dt_util.now().isoformat(),
},
)
await self.storage.async_save() await self.storage.async_save()
await self.async_refresh() await self.async_refresh()
@@ -197,7 +223,8 @@ class ChoresMixin:
except (ValueError, TypeError): except (ValueError, TypeError):
_LOGGER.warning( _LOGGER.warning(
"Chore '%s' has an unparseable deadline_at %r — ignoring it", "Chore '%s' has an unparseable deadline_at %r — ignoring it",
getattr(chore, "name", ""), raw, getattr(chore, "name", ""),
raw,
) )
return None return None
# A naive value came from hand-edited storage; treat it as local time # A naive value came from hand-edited storage; treat it as local time
@@ -237,7 +264,8 @@ class ChoresMixin:
changed = True changed = True
_LOGGER.info( _LOGGER.info(
"Reactive chore '%s' expired (deadline %s)", "Reactive chore '%s' expired (deadline %s)",
chore.name, getattr(chore, "deadline_at", ""), chore.name,
getattr(chore, "deadline_at", ""),
) )
self.hass.bus.async_fire( self.hass.bus.async_fire(
"taskmate_chore_expired", "taskmate_chore_expired",
@@ -297,6 +325,8 @@ class ChoresMixin:
data["assignment_current_child_id"] = "" data["assignment_current_child_id"] = ""
data["skip_date"] = "" data["skip_date"] = ""
data["skip_count"] = 0 data["skip_count"] = 0
data["assignment_swap_child_id"] = ""
data["assignment_swap_date"] = ""
data["publish_calendar_published_dates"] = [] data["publish_calendar_published_dates"] = []
data["disabled_for"] = [] data["disabled_for"] = []
data["enabled"] = True data["enabled"] = True
@@ -352,9 +382,7 @@ class ChoresMixin:
await self.async_refresh() await self.async_refresh()
return count return count
async def async_approve_chores_bulk( async def async_approve_chores_bulk(self, completion_ids: list[str] | None = None) -> int:
self, completion_ids: list[str] | None = None
) -> int:
"""Approve several pending chore completions at once. Returns count approved. """Approve several pending chore completions at once. Returns count approved.
If completion_ids is given, only those (still-pending) completions are If completion_ids is given, only those (still-pending) completions are
@@ -390,7 +418,7 @@ class ChoresMixin:
visibility_entity: str = "", visibility_entity: str = "",
visibility_state: str = "on", visibility_state: str = "on",
visibility_operator: str = "equals", visibility_operator: str = "equals",
) -> list[Chore]: ) -> list[Chore]:
"""Add multiple chores at once with shared settings.""" """Add multiple chores at once with shared settings."""
chores = [] chores = []
for name in chore_names: for name in chore_names:
@@ -412,7 +440,7 @@ class ChoresMixin:
visibility_entity=visibility_entity, visibility_entity=visibility_entity,
visibility_state=visibility_state, visibility_state=visibility_state,
visibility_operator=visibility_operator, visibility_operator=visibility_operator,
) )
self.storage.add_chore(chore) self.storage.add_chore(chore)
chores.append(chore) chores.append(chore)
@@ -453,7 +481,10 @@ class ChoresMixin:
extra_prefixes.append(f"{prev_name}") extra_prefixes.append(f"{prev_name}")
extra_prefixes.append(f"{chore.name}") extra_prefixes.append(f"{chore.name}")
await self._cleanup_chore_from_calendars( await self._cleanup_chore_from_calendars(
chore, cleanup_entities, today, summary_prefixes=extra_prefixes, chore,
cleanup_entities,
today,
summary_prefixes=extra_prefixes,
) )
self.storage.update_chore(chore) self.storage.update_chore(chore)
# Replacing or clearing the picture orphans the old file; delete it — # Replacing or clearing the picture orphans the old file; delete it —
@@ -464,9 +495,7 @@ class ChoresMixin:
await self.storage.async_save() await self.storage.async_save()
await self.async_refresh() await self.async_refresh()
async def _async_release_image( async def _async_release_image(self, image_url: str, *, excluding_chore_id: str = "") -> None:
self, image_url: str, *, excluding_chore_id: str = ""
) -> None:
"""Delete a chore image file, but only if nothing else still shows it. """Delete a chore image file, but only if nothing else still shows it.
`async_clone_chore` copies `image_url` straight from the source, so two `async_clone_chore` copies `image_url` straight from the source, so two
@@ -493,9 +522,7 @@ class ChoresMixin:
# Nothing sweeps taskmate_images, so the file has to go with the chore — # Nothing sweeps taskmate_images, so the file has to go with the chore —
# unless a clone still shows it (#768). # unless a clone still shows it (#768).
if existing is not None and getattr(existing, "image_url", ""): if existing is not None and getattr(existing, "image_url", ""):
await self._async_release_image( await self._async_release_image(existing.image_url, excluding_chore_id=chore_id)
existing.image_url, excluding_chore_id=chore_id
)
self.storage.remove_chore(chore_id) self.storage.remove_chore(chore_id)
self.storage.remove_completions_for_chore(chore_id) self.storage.remove_completions_for_chore(chore_id)
self.storage.remove_last_completed_for_chore(chore_id) self.storage.remove_last_completed_for_chore(chore_id)
@@ -503,6 +530,9 @@ class ChoresMixin:
self.storage.remove_chore_from_task_groups(chore_id) self.storage.remove_chore_from_task_groups(chore_id)
# Drop queued scheduled changes (#675) — nothing left to apply them to. # Drop queued scheduled changes (#675) — nothing left to apply them to.
self.storage.remove_scheduled_changes_for_chore(chore_id) self.storage.remove_scheduled_changes_for_chore(chore_id)
# 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)
# Remove chore from children's chore_order lists # Remove chore from children's chore_order lists
for child in self.storage.get_children(): for child in self.storage.get_children():
if chore_id in child.chore_order: if chore_id in child.chore_order:
@@ -533,9 +563,7 @@ class ChoresMixin:
# Reject skipping a sticky group follower — the group would drift. # Reject skipping a sticky group follower — the group would drift.
group = self.storage.get_task_group_for_chore(chore_id) group = self.storage.get_task_group_for_chore(chore_id)
if group and group.policy == "sticky" and group.chore_ids and group.chore_ids[0] != chore_id: if group and group.policy == "sticky" and group.chore_ids and group.chore_ids[0] != chore_id:
raise ValueError( raise ValueError("Cannot skip a sticky group follower; skip the leader chore instead")
"Cannot skip a sticky group follower; skip the leader chore instead"
)
pool = self._chore_assignment_pool(chore) pool = self._chore_assignment_pool(chore)
if len(pool) <= 1: if len(pool) <= 1:
@@ -549,6 +577,12 @@ class ChoresMixin:
chore.skip_date = today_iso chore.skip_date = today_iso
chore.skip_count = 0 chore.skip_count = 0
# A skip is a deliberate move of today's assignee, so it supersedes any
# approved swap — otherwise the swap override would keep winning and the
# skip would silently do nothing.
chore.assignment_swap_child_id = ""
chore.assignment_swap_date = ""
# Cyclical: A → B → … → unassigned → back to A. # Cyclical: A → B → … → unassigned → back to A.
# skip_count < pool_size → advance to next child # skip_count < pool_size → advance to next child
# skip_count == pool_size → unassigned (no child today) # skip_count == pool_size → unassigned (no child today)
@@ -612,9 +646,13 @@ class ChoresMixin:
chore.assigned_to = [child_id] + [c for c in pool if c != child_id] chore.assigned_to = [child_id] + [c for c in pool if c != child_id]
chore.assignment_rotation_anchor = today.isoformat() chore.assignment_rotation_anchor = today.isoformat()
# Any previous skip is wiped — manual start is an explicit reset. # Any previous skip or approved swap is wiped — manual start is an
# explicit reset, and a lingering swap override would outrank the
# child the parent just picked.
chore.skip_date = "" chore.skip_date = ""
chore.skip_count = 0 chore.skip_count = 0
chore.assignment_swap_child_id = ""
chore.assignment_swap_date = ""
chore.assignment_current_child_id = child_id chore.assignment_current_child_id = child_id
@@ -623,7 +661,9 @@ class ChoresMixin:
await self.async_refresh() await self.async_refresh()
return chore return chore
async def async_complete_chore(self, chore_id: str, child_id: str, as_parent: bool = False, photo_url: str = "") -> ChoreCompletion | None: async def async_complete_chore(
self, chore_id: str, child_id: str, as_parent: bool = False, photo_url: str = ""
) -> ChoreCompletion | None:
"""Mark a chore as completed by a child. """Mark a chore as completed by a child.
When ``as_parent`` is True the completion auto-approves (the parent is the When ``as_parent`` is True the completion auto-approves (the parent is the
@@ -664,8 +704,8 @@ class ChoresMixin:
# SINGLE daily quota across the whole pool. Enforce it here at completion # SINGLE daily quota across the whole pool. Enforce it here at completion
# time, not just in the card UI — otherwise a caller can award every pool # time, not just in the card UI — otherwise a caller can award every pool
# member by completing the chore once per child_id (one call each). # member by completing the chore once per child_id (one call each).
assignment_mode = getattr(chore, 'assignment_mode', 'everyone') assignment_mode = getattr(chore, "assignment_mode", "everyone")
if assignment_mode != 'everyone': if assignment_mode != "everyone":
if self._is_rotation_done_today(chore): if self._is_rotation_done_today(chore):
_LOGGER.debug( _LOGGER.debug(
"complete_chore no-op: '%s' already completed today (rotation quota filled)", "complete_chore no-op: '%s' already completed today (rotation quota filled)",
@@ -676,16 +716,17 @@ class ChoresMixin:
# assignee. Parents (as_parent) may complete on behalf of any pool # assignee. Parents (as_parent) may complete on behalf of any pool
# member — e.g. ticking it off for the off-rotation child. first_come # member — e.g. ticking it off for the off-rotation child. first_come
# keeps its competitive semantics (every pool member may race). # keeps its competitive semantics (every pool member may race).
if not as_parent and assignment_mode != 'first_come': if not as_parent and assignment_mode != "first_come":
if child_id not in self._compute_active_children(chore): if child_id not in self._compute_active_children(chore):
_LOGGER.debug( _LOGGER.debug(
"complete_chore no-op: '%s' not assigned to %s today", "complete_chore no-op: '%s' not assigned to %s today",
chore.name, child.name, chore.name,
child.name,
) )
return None return None
# Check recurrence window for Mode B chores # Check recurrence window for Mode B chores
if getattr(chore, 'schedule_mode', 'specific_days') == 'recurring': if getattr(chore, "schedule_mode", "specific_days") == "recurring":
if not self.is_chore_available_for_child(chore, child_id): if not self.is_chore_available_for_child(chore, child_id):
_LOGGER.debug( _LOGGER.debug(
"complete_chore no-op: '%s' not available yet (recurrence window)", "complete_chore no-op: '%s' not available yet (recurrence window)",
@@ -694,7 +735,7 @@ class ChoresMixin:
return None return None
# Check availability for one-shot chores # Check availability for one-shot chores
if getattr(chore, 'schedule_mode', 'specific_days') == 'one_shot': if getattr(chore, "schedule_mode", "specific_days") == "one_shot":
if not self.is_chore_available_for_child(chore, child_id): if not self.is_chore_available_for_child(chore, child_id):
_LOGGER.debug( _LOGGER.debug(
"complete_chore no-op: '%s' not available (one-shot done or expired)", "complete_chore no-op: '%s' not available (one-shot done or expired)",
@@ -718,11 +759,13 @@ class ChoresMixin:
if comp_dt.date() == today: if comp_dt.date() == today:
todays_completions_count += 1 todays_completions_count += 1
daily_limit = getattr(chore, 'daily_limit', 1) daily_limit = getattr(chore, "daily_limit", 1)
if todays_completions_count >= daily_limit: if todays_completions_count >= daily_limit:
_LOGGER.debug( _LOGGER.debug(
"complete_chore no-op: daily limit reached for '%s' (%d/%d today)", "complete_chore no-op: daily limit reached for '%s' (%d/%d today)",
chore.name, todays_completions_count, daily_limit, chore.name,
todays_completions_count,
daily_limit,
) )
return None return None
@@ -781,7 +824,7 @@ class ChoresMixin:
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 # One-shot: if auto-approved, disable for this child immediately
if getattr(chore, 'schedule_mode', 'specific_days') == 'one_shot' and auto_approve: if getattr(chore, "schedule_mode", "specific_days") == "one_shot" and auto_approve:
if child_id not in chore.disabled_for: if child_id not in chore.disabled_for:
chore.disabled_for.append(child_id) chore.disabled_for.append(child_id)
self._check_one_shot_fully_disabled(chore) self._check_one_shot_fully_disabled(chore)
@@ -792,8 +835,11 @@ class ChoresMixin:
# Fire approval notification only if it stays pending # Fire approval notification only if it stays pending
if not auto_approve: if not auto_approve:
await self._async_notify_pending_approval( await self._async_notify_pending_approval(
child.name, chore.name, chore.points, child.name,
completion_id=completion.id, photo_url=completion.photo_url, chore.name,
chore.points,
completion_id=completion.id,
photo_url=completion.photo_url,
) )
await self.async_refresh() await self.async_refresh()
@@ -819,19 +865,17 @@ class ChoresMixin:
if not chore: if not chore:
raise ValueError(f"Chore {chore_id} not found") raise ValueError(f"Chore {chore_id} not found")
if not getattr(chore, 'enabled', True): if not getattr(chore, "enabled", True):
raise ValueError(f"Chore '{chore.name}' is disabled") raise ValueError(f"Chore '{chore.name}' is disabled")
schedule_mode = getattr(chore, 'schedule_mode', 'specific_days') schedule_mode = getattr(chore, "schedule_mode", "specific_days")
if schedule_mode == 'one_shot': if schedule_mode == "one_shot":
raise ValueError( raise ValueError(f"Chore '{chore.name}' is a one-shot chore and cannot be parent-completed")
f"Chore '{chore.name}' is a one-shot chore and cannot be parent-completed"
)
now = dt_util.now() now = dt_util.now()
# Determine child pool — empty assigned_to means all children # Determine child pool — empty assigned_to means all children
assigned = getattr(chore, 'assigned_to', []) or [] assigned = getattr(chore, "assigned_to", []) or []
if assigned: if assigned:
child_ids = list(assigned) child_ids = list(assigned)
else: else:
@@ -860,8 +904,12 @@ class ChoresMixin:
# the "Current" column / child-stats card stops pointing at the # the "Current" column / child-stats card stops pointing at the
# original child. The pointer recomputes at the next midnight refresh. # original child. The pointer recomputes at the next midnight refresh.
if getattr(chore, "assignment_mode", "everyone") != "everyone": if getattr(chore, "assignment_mode", "everyone") != "everyone":
if getattr(chore, "assignment_current_child_id", ""): if getattr(chore, "assignment_current_child_id", "") or getattr(chore, "assignment_swap_date", ""):
chore.assignment_current_child_id = "" chore.assignment_current_child_id = ""
# Drop the swap override too, or it would re-point the column
# at the swapped-to child on the next assignment pass.
chore.assignment_swap_child_id = ""
chore.assignment_swap_date = ""
self.storage.update_chore(chore) self.storage.update_chore(chore)
await self.storage.async_save() await self.storage.async_save()
@@ -912,9 +960,7 @@ class ChoresMixin:
for c in all_completions for c in all_completions
) )
if already_done: if already_done:
raise ValueError( raise ValueError(f"Bonus sub-task '{subtask.name}' already completed today.")
f"Bonus sub-task '{subtask.name}' already completed today."
)
completion = ChoreCompletion( completion = ChoreCompletion(
chore_id=chore_id, chore_id=chore_id,
@@ -960,19 +1006,24 @@ class ChoresMixin:
comp_date = dt_util.as_local(completion.completed_at).date() comp_date = dt_util.as_local(completion.completed_at).date()
is_bonus = bool(completion.bonus_subtask_id) is_bonus = bool(completion.bonus_subtask_id)
if is_bonus: if is_bonus:
subtask = next( subtask = next((b for b in chore.bonus_subtasks if b.id == completion.bonus_subtask_id), None)
(b for b in chore.bonus_subtasks if b.id == completion.bonus_subtask_id), None
)
pts = subtask.points if subtask else 0 pts = subtask.points if subtask else 0
elif completion.timed_duration_seconds > 0 and chore.task_type == "timed": elif completion.timed_duration_seconds > 0 and chore.task_type == "timed":
rate_seconds = chore.timed_rate_minutes * 60 rate_seconds = chore.timed_rate_minutes * 60
pts = (completion.timed_duration_seconds // rate_seconds) * chore.timed_rate_points if rate_seconds > 0 else 0 pts = (
(completion.timed_duration_seconds // rate_seconds) * chore.timed_rate_points
if rate_seconds > 0
else 0
)
else: else:
pts = self._apply_time_adjustment( pts = self._apply_time_adjustment(
chore, self.effective_chore_points(chore), completion.completed_at chore, self.effective_chore_points(chore), completion.completed_at
) )
total_awarded = await self._award_points( total_awarded = await self._award_points(
child, pts, completion_date=comp_date, skip_streak=is_bonus, child,
pts,
completion_date=comp_date,
skip_streak=is_bonus,
chore_id=completion.chore_id, chore_id=completion.chore_id,
) )
completion.approved = True completion.approved = True
@@ -984,9 +1035,7 @@ class ChoresMixin:
# reviewed (covers single approve AND "approve all", which # reviewed (covers single approve AND "approve all", which
# reuses this method per completion). # reuses this method per completion).
if getattr(self, "notifications", None): if getattr(self, "notifications", None):
await self.notifications.clear_approval( await self.notifications.clear_approval("pending_chore_approval", completion_id)
"pending_chore_approval", completion_id
)
self.hass.bus.async_fire( self.hass.bus.async_fire(
"taskmate_chore_approved", "taskmate_chore_approved",
@@ -999,7 +1048,7 @@ class ChoresMixin:
) )
# One-shot: disable for this child on approval (parent completions only) # One-shot: disable for this child on approval (parent completions only)
if not is_bonus and getattr(chore, 'schedule_mode', 'specific_days') == 'one_shot': if not is_bonus and getattr(chore, "schedule_mode", "specific_days") == "one_shot":
if completion.child_id not in chore.disabled_for: if completion.child_id not in chore.disabled_for:
chore.disabled_for.append(completion.child_id) chore.disabled_for.append(completion.child_id)
self._check_one_shot_fully_disabled(chore) self._check_one_shot_fully_disabled(chore)
@@ -1031,13 +1080,17 @@ class ChoresMixin:
{"child_name": child.name, "child_id": child.id}, {"child_name": child.name, "child_id": child.id},
) )
await self._celebrate( await self._celebrate(
child, "all_chores_done", child,
f"{child.name} finished every chore today!", tier=1, "all_chores_done",
f"{child.name} finished every chore today!",
tier=1,
) )
else: else:
_LOGGER.warning( _LOGGER.warning(
"Cannot approve completion %s: chore (%s) or child (%s) not found", "Cannot approve completion %s: chore (%s) or child (%s) not found",
completion_id, completion.chore_id, completion.child_id, completion_id,
completion.chore_id,
completion.child_id,
) )
return return
_LOGGER.warning("Completion %s not found for approval", completion_id) _LOGGER.warning("Completion %s not found for approval", completion_id)
@@ -1082,9 +1135,7 @@ class ChoresMixin:
and c.child_id == completion.child_id and c.child_id == completion.child_id
and not c.bonus_subtask_id and not c.bonus_subtask_id
] ]
child.last_completion_date = ( child.last_completion_date = max(remaining).isoformat() if remaining else None
max(remaining).isoformat() if remaining else None
)
# Reverse any streak milestones this completion unlocked. # Reverse any streak milestones this completion unlocked.
# Milestone bonuses are logged as separate transactions # Milestone bonuses are logged as separate transactions
# (not part of points_awarded), so dropping the streak # (not part of points_awarded), so dropping the streak
@@ -1094,24 +1145,15 @@ class ChoresMixin:
if lost: if lost:
try: try:
milestones = self.parse_milestone_setting( milestones = self.parse_milestone_setting(
self.storage.get_setting( self.storage.get_setting("streak_milestones", self.DEFAULT_STREAK_MILESTONES)
"streak_milestones", self.DEFAULT_STREAK_MILESTONES
)
) )
except ValueError: except ValueError:
milestones = self.parse_milestone_setting( milestones = self.parse_milestone_setting(self.DEFAULT_STREAK_MILESTONES)
self.DEFAULT_STREAK_MILESTONES
)
refund = sum(milestones.get(d, 0) for d in lost) refund = sum(milestones.get(d, 0) for d in lost)
if refund > 0: if refund > 0:
child.points = max(0, child.points - refund) child.points = max(0, child.points - refund)
child.total_points_earned = max( child.total_points_earned = max(0, child.total_points_earned - refund)
0, child.total_points_earned - refund child.career_score = child.total_points_earned - child.total_penalties_received
)
child.career_score = (
child.total_points_earned
- child.total_penalties_received
)
self.storage.add_points_transaction( self.storage.add_points_transaction(
PointsTransaction( PointsTransaction(
child_id=child.id, child_id=child.id,
@@ -1120,9 +1162,7 @@ class ChoresMixin:
created_at=dt_util.now(), created_at=dt_util.now(),
) )
) )
child.streak_milestones_achieved = sorted( child.streak_milestones_achieved = sorted(d for d in achieved if d <= child.current_streak)
d for d in achieved if d <= child.current_streak
)
self.storage.update_child(child) self.storage.update_child(child)
@@ -1133,7 +1173,8 @@ class ChoresMixin:
# chore/child on the same day (caller disposes of the records). # chore/child on the same day (caller disposes of the records).
comp_date = dt_util.as_local(target_completion.completed_at).date() comp_date = dt_util.as_local(target_completion.completed_at).date()
bonus_completions = [ bonus_completions = [
c for c in completions c
for c in completions
if c.chore_id == target_completion.chore_id if c.chore_id == target_completion.chore_id
and c.child_id == target_completion.child_id and c.child_id == target_completion.child_id
and c.bonus_subtask_id and c.bonus_subtask_id
@@ -1151,13 +1192,11 @@ class ChoresMixin:
self.storage.update_child(child) self.storage.update_child(child)
# Undo last_completed store so recurrence window resets correctly # Undo last_completed store so recurrence window resets correctly
self.storage.undo_last_completed( self.storage.undo_last_completed(target_completion.chore_id, target_completion.child_id)
target_completion.chore_id, target_completion.child_id
)
# One-shot: re-enable for this child # One-shot: re-enable for this child
chore = self.get_chore(target_completion.chore_id) chore = self.get_chore(target_completion.chore_id)
if chore and getattr(chore, 'schedule_mode', 'specific_days') == 'one_shot': if chore and getattr(chore, "schedule_mode", "specific_days") == "one_shot":
if target_completion.child_id in chore.disabled_for: if target_completion.child_id in chore.disabled_for:
chore.disabled_for.remove(target_completion.child_id) chore.disabled_for.remove(target_completion.child_id)
chore.enabled = True chore.enabled = True
@@ -1168,14 +1207,10 @@ class ChoresMixin:
async def async_reject_chore(self, completion_id: str) -> None: async def async_reject_chore(self, completion_id: str) -> None:
"""Reject a chore completion and fully reverse all awards if already granted.""" """Reject a chore completion and fully reverse all awards if already granted."""
completions = self.storage.get_completions() completions = self.storage.get_completions()
target_completion = next( target_completion = next((c for c in completions if c.id == completion_id), None)
(c for c in completions if c.id == completion_id), None
)
if target_completion: if target_completion:
bonus_completions = self._reverse_completion_awards( bonus_completions = self._reverse_completion_awards(target_completion, completions)
target_completion, completions
)
for bc in bonus_completions: for bc in bonus_completions:
self.storage.remove_completion(bc.id) self.storage.remove_completion(bc.id)
@@ -1190,21 +1225,22 @@ class ChoresMixin:
if target_completion: if target_completion:
child = self.get_child(target_completion.child_id) child = self.get_child(target_completion.child_id)
chore = self.get_chore(target_completion.chore_id) chore = self.get_chore(target_completion.chore_id)
self.hass.bus.async_fire("taskmate_chore_rejected", { self.hass.bus.async_fire(
"child_id": target_completion.child_id, "taskmate_chore_rejected",
"child_name": getattr(child, "name", ""), {
"chore_id": target_completion.chore_id, "child_id": target_completion.child_id,
"chore_name": getattr(chore, "name", ""), "child_name": getattr(child, "name", ""),
"completion_id": completion_id, "chore_id": target_completion.chore_id,
"timestamp": dt_util.now().isoformat(), "chore_name": getattr(chore, "name", ""),
}) "completion_id": completion_id,
"timestamp": dt_util.now().isoformat(),
},
)
# Dismiss the mobile approval push for this reviewed completion. Also # Dismiss the mobile approval push for this reviewed completion. Also
# covers undoing an already-approved chore (whose push was cleared at # covers undoing an already-approved chore (whose push was cleared at
# approval): re-clearing a stale tag is a harmless no-op. # approval): re-clearing a stale tag is a harmless no-op.
if getattr(self, "notifications", None): if getattr(self, "notifications", None):
await self.notifications.clear_approval( await self.notifications.clear_approval("pending_chore_approval", completion_id)
"pending_chore_approval", completion_id
)
async def async_undo_chore_approval(self, completion_id: str) -> None: async def async_undo_chore_approval(self, completion_id: str) -> None:
"""Undo an accidental approval: reverse the awards and return the """Undo an accidental approval: reverse the awards and return the
@@ -1240,14 +1276,17 @@ class ChoresMixin:
child = self.get_child(target.child_id) child = self.get_child(target.child_id)
chore = self.get_chore(target.chore_id) chore = self.get_chore(target.chore_id)
self.hass.bus.async_fire("taskmate_chore_approval_undone", { self.hass.bus.async_fire(
"child_id": target.child_id, "taskmate_chore_approval_undone",
"child_name": getattr(child, "name", ""), {
"chore_id": target.chore_id, "child_id": target.child_id,
"chore_name": getattr(chore, "name", ""), "child_name": getattr(child, "name", ""),
"completion_id": completion_id, "chore_id": target.chore_id,
"timestamp": dt_util.now().isoformat(), "chore_name": getattr(chore, "name", ""),
}) "completion_id": completion_id,
"timestamp": dt_util.now().isoformat(),
},
)
def _check_one_shot_fully_disabled(self, chore) -> None: def _check_one_shot_fully_disabled(self, chore) -> None:
"""Check if a one-shot chore should be fully disabled (all children done).""" """Check if a one-shot chore should be fully disabled (all children done)."""
@@ -1278,16 +1317,16 @@ class ChoresMixin:
return False return False
# Check if chore is globally disabled (soft-disabled one-shot chores) # Check if chore is globally disabled (soft-disabled one-shot chores)
if not getattr(chore, 'enabled', True): if not getattr(chore, "enabled", True):
return False return False
# Check per-child disabling (one-shot chores completed by this child) # Check per-child disabling (one-shot chores completed by this child)
disabled_for = getattr(chore, 'disabled_for', []) disabled_for = getattr(chore, "disabled_for", [])
if child_id in disabled_for: if child_id in disabled_for:
return False return False
# Dynamic assignment — only the active child(ren) see alternating/random chores # Dynamic assignment — only the active child(ren) see alternating/random chores
if getattr(chore, 'assignment_mode', 'everyone') != 'everyone': if getattr(chore, "assignment_mode", "everyone") != "everyone":
active = self._compute_active_children(chore) active = self._compute_active_children(chore)
if child_id not in active: if child_id not in active:
return False return False
@@ -1298,9 +1337,9 @@ class ChoresMixin:
return False return False
# Check visibility entity first — if not visible, chore is not available # Check visibility entity first — if not visible, chore is not available
visibility_entity = getattr(chore, 'visibility_entity', '') visibility_entity = getattr(chore, "visibility_entity", "")
visibility_state = getattr(chore, 'visibility_state', 'on') visibility_state = getattr(chore, "visibility_state", "on")
visibility_operator = getattr(chore, 'visibility_operator', 'equals') visibility_operator = getattr(chore, "visibility_operator", "equals")
if not self._is_visibility_entity_active(visibility_entity, visibility_state, visibility_operator): if not self._is_visibility_entity_active(visibility_entity, visibility_state, visibility_operator):
return False return False
@@ -1318,7 +1357,7 @@ class ChoresMixin:
# Chore dependencies (FEAT-1): this chore unlocks only once every chore # Chore dependencies (FEAT-1): this chore unlocks only once every chore
# it depends on has an approved completion today by this same child. # it depends on has an approved completion today by this same child.
depends_on = getattr(chore, 'depends_on', []) or [] depends_on = getattr(chore, "depends_on", []) or []
if depends_on: if depends_on:
dep_today = dt_util.as_local(dt_util.now()).date() dep_today = dt_util.as_local(dt_util.now()).date()
completions = self._cached_completions() completions = self._cached_completions()
@@ -1327,18 +1366,18 @@ class ChoresMixin:
c.chore_id == dep_id c.chore_id == dep_id
and c.child_id == child_id and c.child_id == child_id
and c.approved and c.approved
and not getattr(c, 'bonus_subtask_id', '') and not getattr(c, "bonus_subtask_id", "")
and dt_util.as_local(c.completed_at).date() == dep_today and dt_util.as_local(c.completed_at).date() == dep_today
for c in completions for c in completions
) )
if not satisfied: if not satisfied:
return False return False
schedule_mode = getattr(chore, 'schedule_mode', 'specific_days') schedule_mode = getattr(chore, "schedule_mode", "specific_days")
# One-shot chores: only available on the day they were created # One-shot chores: only available on the day they were created
if schedule_mode == 'one_shot': if schedule_mode == "one_shot":
created_date = getattr(chore, 'created_date', '') created_date = getattr(chore, "created_date", "")
if created_date: if created_date:
today = dt_util.as_local(dt_util.now()).date() today = dt_util.as_local(dt_util.now()).date()
try: try:
@@ -1348,25 +1387,25 @@ class ChoresMixin:
pass pass
return True return True
if schedule_mode != 'recurring': if schedule_mode != "recurring":
return True return True
recurrence = getattr(chore, 'recurrence', 'weekly') recurrence = getattr(chore, "recurrence", "weekly")
first_occurrence_mode = getattr(chore, 'first_occurrence_mode', 'available_immediately') first_occurrence_mode = getattr(chore, "first_occurrence_mode", "available_immediately")
recurrence_day = getattr(chore, 'recurrence_day', '') recurrence_day = getattr(chore, "recurrence_day", "")
recurrence_start = getattr(chore, 'recurrence_start', '') recurrence_start = getattr(chore, "recurrence_start", "")
now = dt_util.now() now = dt_util.now()
today = dt_util.as_local(now).date() today = dt_util.as_local(now).date()
window_days = { window_days = {
'every_2_days': 2, "every_2_days": 2,
'weekly': 7, "weekly": 7,
'every_2_weeks': 14, "every_2_weeks": 14,
}.get(recurrence, 7) }.get(recurrence, 7)
record = self.storage.get_last_completed(chore.id, child_id) record = self.storage.get_last_completed(chore.id, child_id)
current_iso = record.get('current') current_iso = record.get("current")
if not current_iso: if not current_iso:
# Never completed — a future recurrence anchor always defers # Never completed — a future recurrence anchor always defers
@@ -1377,7 +1416,7 @@ class ChoresMixin:
return False return False
except ValueError: except ValueError:
pass pass
if first_occurrence_mode == 'wait_for_first_occurrence' and recurrence_day: if first_occurrence_mode == "wait_for_first_occurrence" and recurrence_day:
target_dow = _DOW_MAP.get(recurrence_day.lower()) target_dow = _DOW_MAP.get(recurrence_day.lower())
if target_dow is not None and today.weekday() != target_dow: if target_dow is not None and today.weekday() != target_dow:
return False return False
@@ -1389,7 +1428,7 @@ class ChoresMixin:
return True return True
# every_2_days with anchor — check alignment # every_2_days with anchor — check alignment
if recurrence == 'every_2_days' and recurrence_start: if recurrence == "every_2_days" and recurrence_start:
try: try:
anchor = date.fromisoformat(recurrence_start) anchor = date.fromisoformat(recurrence_start)
days_since_anchor = (today - anchor).days days_since_anchor = (today - anchor).days
@@ -1402,7 +1441,7 @@ class ChoresMixin:
pass pass
# weekly/every_2_weeks with specific day — only available on that day # weekly/every_2_weeks with specific day — only available on that day
if recurrence_day and recurrence in ('weekly', 'every_2_weeks'): if recurrence_day and recurrence in ("weekly", "every_2_weeks"):
target_dow = _DOW_MAP.get(recurrence_day.lower()) target_dow = _DOW_MAP.get(recurrence_day.lower())
if target_dow is not None and today.weekday() != target_dow: if target_dow is not None and today.weekday() != target_dow:
return False return False
@@ -1477,7 +1516,9 @@ class ChoresMixin:
changed = True changed = True
_LOGGER.info( _LOGGER.info(
"Chore '%s' expired (expires_on %s, today %s)", "Chore '%s' expired (expires_on %s, today %s)",
chore.name, expires_on, today.isoformat(), chore.name,
expires_on,
today.isoformat(),
) )
except ValueError: except ValueError:
continue continue
@@ -1492,11 +1533,11 @@ class ChoresMixin:
changed = False changed = False
for chore in self.storage.get_chores(): for chore in self.storage.get_chores():
if getattr(chore, 'schedule_mode', 'specific_days') != 'one_shot': if getattr(chore, "schedule_mode", "specific_days") != "one_shot":
continue continue
if not getattr(chore, 'enabled', True): if not getattr(chore, "enabled", True):
continue continue
created_date = getattr(chore, 'created_date', '') created_date = getattr(chore, "created_date", "")
if not created_date: if not created_date:
continue continue
try: try:
@@ -1506,7 +1547,9 @@ class ChoresMixin:
changed = True changed = True
_LOGGER.info( _LOGGER.info(
"One-shot chore '%s' expired (created %s, today %s)", "One-shot chore '%s' expired (created %s, today %s)",
chore.name, created_date, today.isoformat(), chore.name,
created_date,
today.isoformat(),
) )
except ValueError: except ValueError:
continue continue
@@ -1563,18 +1606,15 @@ class ChoresMixin:
if not chore: if not chore:
raise ValueError(f"Unknown chore: {chore_id}") raise ValueError(f"Unknown chore: {chore_id}")
if getattr(chore, "assignment_mode", "everyone") == "everyone": if getattr(chore, "assignment_mode", "everyone") == "everyone":
raise ValueError( raise ValueError(f"Chore '{chore.name}' uses 'everyone' mode and cannot join a group")
f"Chore '{chore.name}' uses 'everyone' mode and cannot join a group"
)
existing_group = self.storage.get_task_group_for_chore(chore_id) existing_group = self.storage.get_task_group_for_chore(chore_id)
if existing_group and existing_group.id != exclude_group_id: if existing_group and existing_group.id != exclude_group_id:
raise ValueError( raise ValueError(f"Chore '{chore.name}' already belongs to group '{existing_group.name}'")
f"Chore '{chore.name}' already belongs to group '{existing_group.name}'"
)
async def async_add_task_group(self, name: str, policy: str, chore_ids: list[str] | None = None): async def async_add_task_group(self, name: str, policy: str, chore_ids: list[str] | None = None):
"""Create a task group.""" """Create a task group."""
from .models import TaskGroup from .models import TaskGroup
if policy not in ("sticky", "spread"): if policy not in ("sticky", "spread"):
raise ValueError(f"Unknown task group policy: {policy}") raise ValueError(f"Unknown task group policy: {policy}")
chore_ids = list(chore_ids or []) chore_ids = list(chore_ids or [])
+56 -29
View File
@@ -1,4 +1,5 @@
"""Mandatory-chore detection, scheduling, and resolution (#532).""" """Mandatory-chore detection, scheduling, and resolution (#532)."""
from __future__ import annotations from __future__ import annotations
import logging import logging
@@ -64,10 +65,7 @@ class MandatoryMixin:
async def async_detect_mandatory_misses(self, period_id: str, day: date) -> int: async def async_detect_mandatory_misses(self, period_id: str, day: date) -> int:
"""Create misses for due+incomplete mandatory chores in `period_id`.""" """Create misses for due+incomplete mandatory chores in `period_id`."""
existing = { existing = {(m.chore_id, m.child_id, m.due_date) for m in self.storage.get_mandatory_misses()}
(m.chore_id, m.child_id, m.due_date)
for m in self.storage.get_mandatory_misses()
}
created = 0 created = 0
for chore in self.storage.get_chores(): for chore in self.storage.get_chores():
if not getattr(chore, "mandatory", False): if not getattr(chore, "mandatory", False):
@@ -94,11 +92,17 @@ class MandatoryMixin:
) )
self.storage.add_mandatory_miss(miss) self.storage.add_mandatory_miss(miss)
created += 1 created += 1
self.hass.bus.async_fire("taskmate_mandatory_missed", { self.hass.bus.async_fire(
"miss_id": miss.id, "chore_id": chore.id, "child_id": child_id, "taskmate_mandatory_missed",
"period_id": period_id, "penalty_points": miss.penalty_points, {
"timestamp": dt_util.now().isoformat(), "miss_id": miss.id,
}) "chore_id": chore.id,
"child_id": child_id,
"period_id": period_id,
"penalty_points": miss.penalty_points,
"timestamp": dt_util.now().isoformat(),
},
)
if created: if created:
await self.storage.async_save() await self.storage.async_save()
await self.async_refresh() await self.async_refresh()
@@ -132,15 +136,22 @@ class MandatoryMixin:
name = getattr(chore, "name", "chore") name = getattr(chore, "name", "chore")
if miss.penalty_points > 0: if miss.penalty_points > 0:
await self.async_remove_points( await self.async_remove_points(
miss.child_id, miss.penalty_points, miss.child_id,
miss.penalty_points,
reason=f"Penalty: {name} (missed mandatory)", reason=f"Penalty: {name} (missed mandatory)",
) )
self.storage.remove_mandatory_miss(miss_id) self.storage.remove_mandatory_miss(miss_id)
await self.storage.async_save() await self.storage.async_save()
self.hass.bus.async_fire("taskmate_mandatory_penalty_applied", { self.hass.bus.async_fire(
"miss_id": miss_id, "chore_id": miss.chore_id, "child_id": miss.child_id, "taskmate_mandatory_penalty_applied",
"points": miss.penalty_points, "timestamp": dt_util.now().isoformat(), {
}) "miss_id": miss_id,
"chore_id": miss.chore_id,
"child_id": miss.child_id,
"points": miss.penalty_points,
"timestamp": dt_util.now().isoformat(),
},
)
await self.async_refresh() await self.async_refresh()
async def async_postpone_mandatory_chore(self, miss_id: str) -> None: async def async_postpone_mandatory_chore(self, miss_id: str) -> None:
@@ -156,10 +167,16 @@ class MandatoryMixin:
# else: no window left today -> let normal scheduling resurface tomorrow # else: no window left today -> let normal scheduling resurface tomorrow
self.storage.remove_mandatory_miss(miss_id) self.storage.remove_mandatory_miss(miss_id)
await self.storage.async_save() await self.storage.async_save()
self.hass.bus.async_fire("taskmate_mandatory_postponed", { self.hass.bus.async_fire(
"miss_id": miss_id, "chore_id": miss.chore_id, "child_id": miss.child_id, "taskmate_mandatory_postponed",
"next_period": nxt or "", "timestamp": dt_util.now().isoformat(), {
}) "miss_id": miss_id,
"chore_id": miss.chore_id,
"child_id": miss.child_id,
"next_period": nxt or "",
"timestamp": dt_util.now().isoformat(),
},
)
await self.async_refresh() await self.async_refresh()
async def async_dismiss_mandatory_chore(self, miss_id: str) -> None: async def async_dismiss_mandatory_chore(self, miss_id: str) -> None:
@@ -168,10 +185,15 @@ class MandatoryMixin:
return return
self.storage.remove_mandatory_miss(miss_id) self.storage.remove_mandatory_miss(miss_id)
await self.storage.async_save() await self.storage.async_save()
self.hass.bus.async_fire("taskmate_mandatory_dismissed", { self.hass.bus.async_fire(
"miss_id": miss_id, "chore_id": miss.chore_id, "child_id": miss.child_id, "taskmate_mandatory_dismissed",
"timestamp": dt_util.now().isoformat(), {
}) "miss_id": miss_id,
"chore_id": miss.chore_id,
"child_id": miss.child_id,
"timestamp": dt_util.now().isoformat(),
},
)
await self.async_refresh() await self.async_refresh()
# ---- escalation (FEAT-6) ---------------------------------------------- # ---- escalation (FEAT-6) ----------------------------------------------
@@ -225,12 +247,14 @@ class MandatoryMixin:
for stage in range(miss.escalation_stage + 1, target + 1): for stage in range(miss.escalation_stage + 1, target + 1):
if stage in (1, 2): if stage in (1, 2):
await self.notifications.fire( await self.notifications.fire(
NOTIF_TYPE_MANDATORY_REMINDER, ctx, NOTIF_TYPE_MANDATORY_REMINDER,
ctx,
only_recipients={f"child:{miss.child_id}"}, only_recipients={f"child:{miss.child_id}"},
) )
elif stage == 3: elif stage == 3:
await self.notifications.fire( await self.notifications.fire(
NOTIF_TYPE_MANDATORY_PARENT_ALERT, ctx, NOTIF_TYPE_MANDATORY_PARENT_ALERT,
ctx,
) )
miss.escalation_stage = target miss.escalation_stage = target
self.storage.update_mandatory_miss(miss) self.storage.update_mandatory_miss(miss)
@@ -261,13 +285,17 @@ class MandatoryMixin:
unsub = async_track_time_change( unsub = async_track_time_change(
self.hass, self.hass,
self._make_mandatory_period_cb(period_id), self._make_mandatory_period_cb(period_id),
hour=hour, minute=minute, second=10, hour=hour,
minute=minute,
second=10,
) )
self._unsub_mandatory.append(unsub) self._unsub_mandatory.append(unsub)
# Reminder escalation ladder (FEAT-6) — re-evaluate open misses on a tick. # Reminder escalation ladder (FEAT-6) — re-evaluate open misses on a tick.
self._unsub_mandatory.append( self._unsub_mandatory.append(
async_track_time_interval( async_track_time_interval(
self.hass, self._escalation_tick, _ESCALATION_INTERVAL, self.hass,
self._escalation_tick,
_ESCALATION_INTERVAL,
) )
) )
@@ -278,9 +306,8 @@ class MandatoryMixin:
def _make_mandatory_period_cb(self, period_id: str): def _make_mandatory_period_cb(self, period_id: str):
@callback @callback
def _cb(now: datetime) -> None: def _cb(now: datetime) -> None:
self.hass.async_create_task( self.hass.async_create_task(self.async_detect_mandatory_misses(period_id, dt_util.now().date()))
self.async_detect_mandatory_misses(period_id, dt_util.now().date())
)
return _cb return _cb
def disarm_mandatory_schedules(self) -> None: def disarm_mandatory_schedules(self) -> None:
@@ -9,6 +9,7 @@ All TaskMate notifications flow through this module. It owns:
Other coordinators MUST NOT call notify.* / persistent_notification directly Other coordinators MUST NOT call notify.* / persistent_notification directly
once this module is in place. They call self.notifications.fire(...). once this module is in place. They call self.notifications.fire(...).
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
@@ -59,34 +60,32 @@ def _approval_tag(entry_id: str) -> str:
@dataclass(frozen=True) @dataclass(frozen=True)
class NotificationTypeMeta: class NotificationTypeMeta:
id: str id: str
audience: str # "child" | "parent" | "both" audience: str # "child" | "parent" | "both"
time_gated: bool # has its own scheduled callback time_gated: bool # has its own scheduled callback
per_recipient_time: bool # if True, route.time controls the schedule per recipient per_recipient_time: bool # if True, route.time controls the schedule per recipient
actionable: bool # carries Approve/Reject mobile actions actionable: bool # carries Approve/Reject mobile actions
default_enabled: bool # default master_enabled state at install default_enabled: bool # default master_enabled state at install
NOTIFICATION_TYPES: list[NotificationTypeMeta] = [ NOTIFICATION_TYPES: list[NotificationTypeMeta] = [
NotificationTypeMeta(NOTIF_TYPE_BEDTIME_REMINDER, "child", True, True, False, False), NotificationTypeMeta(NOTIF_TYPE_BEDTIME_REMINDER, "child", True, True, False, False),
NotificationTypeMeta(NOTIF_TYPE_STREAK_AT_RISK, "child", True, False, False, False), NotificationTypeMeta(NOTIF_TYPE_STREAK_AT_RISK, "child", True, False, False, False),
NotificationTypeMeta(NOTIF_TYPE_ALL_CHORES_DONE, "both", False, False, False, False), NotificationTypeMeta(NOTIF_TYPE_ALL_CHORES_DONE, "both", False, False, False, False),
NotificationTypeMeta(NOTIF_TYPE_BADGE_EARNED, "both", False, False, False, True), NotificationTypeMeta(NOTIF_TYPE_BADGE_EARNED, "both", False, False, False, True),
NotificationTypeMeta(NOTIF_TYPE_PENDING_CHORE_APPROVAL, "parent", False, False, True, True), NotificationTypeMeta(NOTIF_TYPE_PENDING_CHORE_APPROVAL, "parent", False, False, True, True),
NotificationTypeMeta(NOTIF_TYPE_PENDING_REWARD_CLAIM, "parent", False, False, True, True), NotificationTypeMeta(NOTIF_TYPE_PENDING_REWARD_CLAIM, "parent", False, False, True, True),
NotificationTypeMeta(NOTIF_TYPE_STREAK_MILESTONE, "both", False, False, False, False), NotificationTypeMeta(NOTIF_TYPE_STREAK_MILESTONE, "both", False, False, False, False),
NotificationTypeMeta(NOTIF_TYPE_LEVEL_UP, "both", False, False, False, False), NotificationTypeMeta(NOTIF_TYPE_LEVEL_UP, "both", False, False, False, False),
NotificationTypeMeta(NOTIF_TYPE_WEEKLY_DIGEST, "parent", False, False, False, False), NotificationTypeMeta(NOTIF_TYPE_WEEKLY_DIGEST, "parent", False, False, False, False),
NotificationTypeMeta(NOTIF_TYPE_CELEBRATION, "both", False, False, False, False), NotificationTypeMeta(NOTIF_TYPE_CELEBRATION, "both", False, False, False, False),
NotificationTypeMeta(NOTIF_TYPE_MANDATORY_REMINDER, "child", False, False, False, False), NotificationTypeMeta(NOTIF_TYPE_MANDATORY_REMINDER, "child", False, False, False, False),
NotificationTypeMeta(NOTIF_TYPE_MANDATORY_PARENT_ALERT, "parent", False, False, False, False), NotificationTypeMeta(NOTIF_TYPE_MANDATORY_PARENT_ALERT, "parent", False, False, False, False),
NotificationTypeMeta(NOTIF_TYPE_MONTHLY_REPORT, "parent", False, False, False, False), NotificationTypeMeta(NOTIF_TYPE_MONTHLY_REPORT, "parent", False, False, False, False),
NotificationTypeMeta(NOTIF_TYPE_SEASON_CHAMPION, "both", 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), NotificationTypeMeta(NOTIF_TYPE_FAMILY_GOAL_REACHED, "both", False, False, False, False),
] ]
NOTIFICATION_TYPES_BY_ID: dict[str, NotificationTypeMeta] = { NOTIFICATION_TYPES_BY_ID: dict[str, NotificationTypeMeta] = {t.id: t for t in NOTIFICATION_TYPES}
t.id: t for t in NOTIFICATION_TYPES
}
def _validate_nav_url(value: str) -> str: def _validate_nav_url(value: str) -> str:
@@ -101,11 +100,7 @@ def _validate_nav_url(value: str) -> str:
return value return value
if value.lower().startswith(("http://", "https://")): if value.lower().startswith(("http://", "https://")):
return value return value
if ( if value.startswith("/") and not value.startswith("//") and not any(ord(c) <= 32 or ord(c) == 127 for c in value):
value.startswith("/")
and not value.startswith("//")
and not any(ord(c) <= 32 or ord(c) == 127 for c in value)
):
return value return value
raise ValueError("nav_url must be a /path, an http(s) URL, or noAction") raise ValueError("nav_url must be a /path, an http(s) URL, or noAction")
@@ -145,6 +140,7 @@ def _is_within_quiet_hours(start: str, end: str, now) -> bool:
class _SafeDict(dict): class _SafeDict(dict):
"""str.format_map dict that leaves missing keys as `{key}` literal.""" """str.format_map dict that leaves missing keys as `{key}` literal."""
def __missing__(self, key: str) -> str: def __missing__(self, key: str) -> str:
return "{" + key + "}" return "{" + key + "}"
@@ -155,11 +151,13 @@ class NotificationCoordinator:
def __init__(self, hass: HomeAssistant, storage) -> None: def __init__(self, hass: HomeAssistant, storage) -> None:
self.hass = hass self.hass = hass
self.storage = storage self.storage = storage
self._scheduled_unsubs: list = [] # cancellation handles for time triggers self._scheduled_unsubs: list = [] # cancellation handles for time triggers
self.coordinator: Any = None self.coordinator: Any = None
async def fire( async def fire(
self, type_id: str, context: dict[str, Any], self,
type_id: str,
context: dict[str, Any],
only_recipients: set[str] | None = None, only_recipients: set[str] | None = None,
) -> None: ) -> None:
"""Dispatch a notification of the given type with the given context. """Dispatch a notification of the given type with the given context.
@@ -229,7 +227,8 @@ class NotificationCoordinator:
set one up shouldn't be silently excluded from every approval. set one up shouldn't be silently excluded from every approval.
""" """
parent = next( parent = next(
(p for p in self.storage.get_parent_recipients() if p.id == recipient_id), None, (p for p in self.storage.get_parent_recipients() if p.id == recipient_id),
None,
) )
entity_id = (getattr(parent, "presence_entity", "") or "").strip() if parent else "" entity_id = (getattr(parent, "presence_entity", "") or "").strip() if parent else ""
if not entity_id: if not entity_id:
@@ -241,13 +240,16 @@ class NotificationCoordinator:
return str(state.state).lower() in ("home", "on", "true", "present") return str(state.state).lower() in ("home", "on", "true", "present")
def _route_parents( def _route_parents(
self, type_id: str, cfg, only_recipients: set[str] | None, self,
type_id: str,
cfg,
only_recipients: set[str] | None,
) -> set[str]: ) -> set[str]:
"""Which parent recipient ids should receive this notification.""" """Which parent recipient ids should receive this notification."""
candidates = [ candidates = [
rid for rid, route in cfg.routes.items() rid
if rid.startswith("parent:") and route.enabled for rid, route in cfg.routes.items()
and (only_recipients is None or rid in only_recipients) if rid.startswith("parent:") and route.enabled and (only_recipients is None or rid in only_recipients)
] ]
if not candidates: if not candidates:
return set() return set()
@@ -328,20 +330,15 @@ class NotificationCoordinator:
if child is None: if child is None:
return False return False
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
return _is_within_quiet_hours(
child.quiet_hours_start, child.quiet_hours_end, dt_util.now() return _is_within_quiet_hours(child.quiet_hours_start, child.quiet_hours_end, dt_util.now())
)
def _resolve_nav_url(self, cfg) -> str: def _resolve_nav_url(self, cfg) -> str:
"""Tap target for this notification: per-type override, else global default.""" """Tap target for this notification: per-type override, else global default."""
per_type = (getattr(cfg, "nav_url", "") or "").strip() per_type = (getattr(cfg, "nav_url", "") or "").strip()
if per_type: if per_type:
return per_type return per_type
return str( return str(self.storage.get_setting("notification_nav_url", DEFAULT_NOTIFICATION_NAV_URL) or "").strip()
self.storage.get_setting(
"notification_nav_url", DEFAULT_NOTIFICATION_NAV_URL
) or ""
).strip()
def _resolve_notify_service(self, recipient_id: str) -> str: def _resolve_notify_service(self, recipient_id: str) -> str:
if recipient_id.startswith("child:"): if recipient_id.startswith("child:"):
@@ -358,21 +355,21 @@ class NotificationCoordinator:
# Built-in types use a baked-in default; will be replaced by translations # Built-in types use a baked-in default; will be replaced by translations
# in a later task. For now use a safe English fallback so dispatch works. # in a later task. For now use a safe English fallback so dispatch works.
templates = { templates = {
NOTIF_TYPE_BEDTIME_REMINDER: "{child_name}, you still have chores to do before bedtime.", NOTIF_TYPE_BEDTIME_REMINDER: "{child_name}, you still have chores to do before bedtime.",
NOTIF_TYPE_STREAK_AT_RISK: "{child_name}, complete a chore today to keep your {streak}-day streak!", NOTIF_TYPE_STREAK_AT_RISK: "{child_name}, complete a chore today to keep your {streak}-day streak!",
NOTIF_TYPE_ALL_CHORES_DONE: "{child_name} finished every chore today!", NOTIF_TYPE_ALL_CHORES_DONE: "{child_name} finished every chore today!",
NOTIF_TYPE_BADGE_EARNED: "{child_name} earned the {badge_name} badge!", NOTIF_TYPE_BADGE_EARNED: "{child_name} earned the {badge_name} badge!",
NOTIF_TYPE_PENDING_CHORE_APPROVAL: "{child_name} completed '{chore_name}' (+{points} {points_name}) — awaiting approval.", NOTIF_TYPE_PENDING_CHORE_APPROVAL: "{child_name} completed '{chore_name}' (+{points} {points_name}) — awaiting approval.",
NOTIF_TYPE_PENDING_REWARD_CLAIM: "{child_name} claimed '{reward_name}' ({cost} {points_name}) — awaiting approval.", NOTIF_TYPE_PENDING_REWARD_CLAIM: "{child_name} claimed '{reward_name}' ({cost} {points_name}) — awaiting approval.",
NOTIF_TYPE_STREAK_MILESTONE: "{child_name} hit a {days}-day streak — +{points} {points_name}!", NOTIF_TYPE_STREAK_MILESTONE: "{child_name} hit a {days}-day streak — +{points} {points_name}!",
NOTIF_TYPE_LEVEL_UP: "{child_name} reached level {level}! 🎉", NOTIF_TYPE_LEVEL_UP: "{child_name} reached level {level}! 🎉",
NOTIF_TYPE_WEEKLY_DIGEST: "TaskMate weekly digest:\n{summary}", NOTIF_TYPE_WEEKLY_DIGEST: "TaskMate weekly digest:\n{summary}",
NOTIF_TYPE_CELEBRATION: "🎉 {message}", NOTIF_TYPE_CELEBRATION: "🎉 {message}",
NOTIF_TYPE_MANDATORY_REMINDER: "{child_name}, you still need to do '{chore_name}'.", NOTIF_TYPE_MANDATORY_REMINDER: "{child_name}, you still need to do '{chore_name}'.",
NOTIF_TYPE_MANDATORY_PARENT_ALERT: "{child_name} still hasn't done the mandatory chore '{chore_name}'.", NOTIF_TYPE_MANDATORY_PARENT_ALERT: "{child_name} still hasn't done the mandatory chore '{chore_name}'.",
NOTIF_TYPE_MONTHLY_REPORT: "TaskMate {month} report:\n{summary}", 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_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_FAMILY_GOAL_REACHED: "🎉 Family goal reached: {goal_name}! Time for {goal_reward}.",
} }
tpl = context.get("message_template") or templates.get(meta.id, "") tpl = context.get("message_template") or templates.get(meta.id, "")
try: try:
@@ -383,13 +380,14 @@ class NotificationCoordinator:
return tpl return tpl
async def _send_to( async def _send_to(
self, notify_service: str, message: str, self,
meta: "NotificationTypeMeta", context: dict[str, Any], nav_url: str = "", notify_service: str,
message: str,
meta: "NotificationTypeMeta",
context: dict[str, Any],
nav_url: str = "",
) -> None: ) -> None:
domain, service = ( domain, service = notify_service.split(".", 1) if "." in notify_service else ("notify", notify_service)
notify_service.split(".", 1) if "." in notify_service
else ("notify", notify_service)
)
if domain != "notify": if domain != "notify":
_LOGGER.warning("notify_service must be notify.*, got %s", notify_service) _LOGGER.warning("notify_service must be notify.*, got %s", notify_service)
return return
@@ -419,7 +417,7 @@ class NotificationCoordinator:
push["tag"] = _approval_tag(entry_id) push["tag"] = _approval_tag(entry_id)
push["actions"] = [ push["actions"] = [
{"action": f"TASKMATE_APPROVE_{entry_id}", "title": "Approve"}, {"action": f"TASKMATE_APPROVE_{entry_id}", "title": "Approve"},
{"action": f"TASKMATE_REJECT_{entry_id}", "title": "Reject"}, {"action": f"TASKMATE_REJECT_{entry_id}", "title": "Reject"},
] ]
else: else:
data["message"] = f"{message} {_APPROVE_IN_PANEL_HINT}" data["message"] = f"{message} {_APPROVE_IN_PANEL_HINT}"
@@ -472,10 +470,7 @@ class NotificationCoordinator:
notify_service = self._resolve_notify_service(recipient_id) notify_service = self._resolve_notify_service(recipient_id)
if not notify_service: if not notify_service:
continue continue
domain, service = ( domain, service = notify_service.split(".", 1) if "." in notify_service else ("notify", notify_service)
notify_service.split(".", 1) if "." in notify_service
else ("notify", notify_service)
)
if domain != "notify" or not service.startswith("mobile_app"): if domain != "notify" or not service.startswith("mobile_app"):
continue continue
if service in cleared_services: if service in cleared_services:
@@ -483,7 +478,8 @@ class NotificationCoordinator:
cleared_services.add(service) cleared_services.add(service)
try: try:
await self.hass.services.async_call( await self.hass.services.async_call(
"notify", service, "notify",
service,
{"message": "clear_notification", "data": {"tag": tag}}, {"message": "clear_notification", "data": {"tag": tag}},
blocking=False, blocking=False,
) )
@@ -492,7 +488,8 @@ class NotificationCoordinator:
async def _fire_persistent_notification(self, type_id: str, message: str) -> None: async def _fire_persistent_notification(self, type_id: str, message: str) -> None:
await self.hass.services.async_call( await self.hass.services.async_call(
"persistent_notification", "create", "persistent_notification",
"create",
{ {
"title": "TaskMate", "title": "TaskMate",
"message": message, "message": message,
@@ -516,7 +513,7 @@ class NotificationCoordinator:
return return
if action.startswith("TASKMATE_APPROVE_"): if action.startswith("TASKMATE_APPROVE_"):
entry_id = action[len("TASKMATE_APPROVE_"):] entry_id = action[len("TASKMATE_APPROVE_") :]
try: try:
await coordinator.async_approve_chore(entry_id) await coordinator.async_approve_chore(entry_id)
return return
@@ -527,7 +524,7 @@ class NotificationCoordinator:
except (ValueError, KeyError): except (ValueError, KeyError):
_LOGGER.info("Mobile action %s — entry not found", action) _LOGGER.info("Mobile action %s — entry not found", action)
elif action.startswith("TASKMATE_REJECT_"): elif action.startswith("TASKMATE_REJECT_"):
entry_id = action[len("TASKMATE_REJECT_"):] entry_id = action[len("TASKMATE_REJECT_") :]
try: try:
await coordinator.async_reject_chore(entry_id) await coordinator.async_reject_chore(entry_id)
return return
@@ -591,7 +588,11 @@ class NotificationCoordinator:
_LOGGER.warning("Invalid time %r — skipping schedule", hhmm) _LOGGER.warning("Invalid time %r — skipping schedule", hhmm)
return return
unsub = async_track_time_change( unsub = async_track_time_change(
self.hass, callback, hour=hour, minute=minute, second=0, self.hass,
callback,
hour=hour,
minute=minute,
second=0,
) )
self._scheduled_unsubs.append(unsub) self._scheduled_unsubs.append(unsub)
@@ -606,10 +607,12 @@ class NotificationCoordinator:
"bedtime_reminder", "bedtime_reminder",
{"child_name": child.name, "child_id": child_id}, {"child_name": child.name, "child_id": child_id},
) )
return _cb return _cb
async def _streak_at_risk_callback(self, now) -> None: async def _streak_at_risk_callback(self, now) -> None:
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
today = dt_util.now().date().isoformat() today = dt_util.now().date().isoformat()
for child in self.storage.get_children(): for child in self.storage.get_children():
if (child.current_streak or 0) < 2: if (child.current_streak or 0) < 2:
@@ -628,6 +631,7 @@ class NotificationCoordinator:
def _make_custom_callback(self, custom_id: str): def _make_custom_callback(self, custom_id: str):
async def _cb(now): async def _cb(now):
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
n = next( n = next(
(c for c in self.storage.get_custom_notifications() if c.id == custom_id), (c for c in self.storage.get_custom_notifications() if c.id == custom_id),
None, None,
@@ -659,7 +663,8 @@ class NotificationCoordinator:
message = n.message_template message = n.message_template
service_name = notify_service.split(".", 1)[1] if "." in notify_service else notify_service service_name = notify_service.split(".", 1)[1] if "." in notify_service else notify_service
await self.hass.services.async_call( await self.hass.services.async_call(
"notify", service_name, "notify",
service_name,
{"title": "TaskMate", "message": message}, {"title": "TaskMate", "message": message},
blocking=False, blocking=False,
) )
@@ -667,6 +672,7 @@ class NotificationCoordinator:
"taskmate_custom_notification", "taskmate_custom_notification",
{"id": n.id, "name": n.name, "recipients": n.recipient_ids}, {"id": n.id, "name": n.name, "recipients": n.recipient_ids},
) )
return _cb return _cb
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -702,9 +708,7 @@ class NotificationCoordinator:
if self.storage.get_notification_config(meta.id).routes: if self.storage.get_notification_config(meta.id).routes:
continue # already configured — leave it alone continue # already configured — leave it alone
for p in parents: for p in parents:
self.storage.set_notification_route( self.storage.set_notification_route(meta.id, p.id, NotificationRoute(enabled=True))
meta.id, p.id, NotificationRoute(enabled=True)
)
changed = True changed = True
return changed return changed
@@ -750,13 +754,14 @@ class NotificationCoordinator:
"""Returns True if the child has at least one chore assigned today """Returns True if the child has at least one chore assigned today
that has no approved/pending completion yet.""" that has no approved/pending completion yet."""
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
today = dt_util.now().date() today = dt_util.now().date()
chores = self.storage.get_chores() chores = self.storage.get_chores()
completions = self.storage.get_completions() completions = self.storage.get_completions()
completed_today = { completed_today = {
c.chore_id for c in completions c.chore_id
if c.child_id == child_id for c in completions
and dt_util.as_local(c.completed_at).date() == today if c.child_id == child_id and dt_util.as_local(c.completed_at).date() == today
} }
for chore in chores: for chore in chores:
if not chore.assigned_to or child_id not in chore.assigned_to: if not chore.assigned_to or child_id not in chore.assigned_to:
+149 -119
View File
@@ -1,4 +1,5 @@
"""Points operations mixin for TaskMateCoordinator.""" """Points operations mixin for TaskMateCoordinator."""
from __future__ import annotations from __future__ import annotations
import logging import logging
@@ -72,7 +73,7 @@ class PointsMixin:
return req return req
for child in children: for child in children:
awarded_weeks = list(getattr(child, 'awarded_perfect_weeks', None) or []) awarded_weeks = list(getattr(child, "awarded_perfect_weeks", None) or [])
# Skip if already awarded for this week # Skip if already awarded for this week
if week_key in awarded_weeks: if week_key in awarded_weeks:
@@ -100,9 +101,7 @@ class PointsMixin:
# counts only when EVERY chore due that day was done — not just one. # counts only when EVERY chore due that day was done — not just one.
if all_mode and derived_dates: if all_mode and derived_dates:
satisfied = all( satisfied = all(
self._all_due_chores_done( self._all_due_chores_done(child.id, date.fromisoformat(d), include_rotation=False)
child.id, date.fromisoformat(d), include_rotation=False
)
for d in derived_dates for d in derived_dates
) )
else: else:
@@ -113,9 +112,7 @@ class PointsMixin:
child.points += perfect_week_bonus child.points += perfect_week_bonus
child.total_points_earned += perfect_week_bonus child.total_points_earned += perfect_week_bonus
child.career_score = child.total_points_earned - child.total_penalties_received child.career_score = child.total_points_earned - child.total_penalties_received
self.storage.append_career_score_snapshot( self.storage.append_career_score_snapshot(child.id, today.isoformat(), child.career_score)
child.id, today.isoformat(), child.career_score
)
self.storage.update_child(child) self.storage.update_child(child)
transaction = PointsTransaction( transaction = PointsTransaction(
@@ -140,13 +137,17 @@ class PointsMixin:
if getattr(self, "badges", None): if getattr(self, "badges", None):
await self.badges.evaluate_for_child(child.id, "perfect_week") await self.badges.evaluate_for_child(child.id, "perfect_week")
await self._celebrate( await self._celebrate(
child, "perfect_week", child,
"perfect_week",
f"{child.name} earned a perfect week — +{perfect_week_bonus}!", f"{child.name} earned a perfect week — +{perfect_week_bonus}!",
tier=3, extra={"bonus": perfect_week_bonus}, tier=3,
extra={"bonus": perfect_week_bonus},
) )
_LOGGER.info( _LOGGER.info(
"Perfect week bonus (%d pts) awarded to %s for week of %s", "Perfect week bonus (%d pts) awarded to %s for week of %s",
perfect_week_bonus, child.name, week_key, perfect_week_bonus,
child.name,
week_key,
) )
if changed: if changed:
@@ -213,17 +214,13 @@ class PointsMixin:
if streak_mode == "pause" or getattr(child, "streak_paused", False): if streak_mode == "pause" or getattr(child, "streak_paused", False):
child.streak_paused = True child.streak_paused = True
_LOGGER.info( _LOGGER.info(
"Streak paused for %s (last completion: %s, mode=%s)", "Streak paused for %s (last completion: %s, mode=%s)", child.name, last_date_str, streak_mode
child.name, last_date_str, streak_mode
) )
else: else:
# Default: reset to 0 # Default: reset to 0
child.current_streak = 0 child.current_streak = 0
child.streak_paused = False child.streak_paused = False
_LOGGER.info( _LOGGER.info("Streak reset for %s (last completion: %s, mode=reset)", child.name, last_date_str)
"Streak reset for %s (last completion: %s, mode=reset)",
child.name, last_date_str
)
self.storage.update_child(child) self.storage.update_child(child)
changed = True changed = True
@@ -243,9 +240,7 @@ class PointsMixin:
child.career_score = child.total_points_earned - child.total_penalties_received child.career_score = child.total_points_earned - child.total_penalties_received
await self._maybe_level_up(child) await self._maybe_level_up(child)
self.storage.update_child(child) self.storage.update_child(child)
self.storage.append_career_score_snapshot( self.storage.append_career_score_snapshot(child_id, date.today().isoformat(), child.career_score)
child_id, date.today().isoformat(), child.career_score
)
# Log the manual transaction # Log the manual transaction
transaction = PointsTransaction( transaction = PointsTransaction(
child_id=child_id, child_id=child_id,
@@ -279,9 +274,7 @@ class PointsMixin:
if reason.startswith("Penalty: "): if reason.startswith("Penalty: "):
child.total_penalties_received += actual_deducted child.total_penalties_received += actual_deducted
child.career_score = child.total_points_earned - child.total_penalties_received child.career_score = child.total_points_earned - child.total_penalties_received
self.storage.append_career_score_snapshot( self.storage.append_career_score_snapshot(child_id, date.today().isoformat(), child.career_score)
child_id, date.today().isoformat(), child.career_score
)
self.storage.update_child(child) self.storage.update_child(child)
# Log the manual transaction (negative points) # Log the manual transaction (negative points)
transaction = PointsTransaction( transaction = PointsTransaction(
@@ -338,9 +331,7 @@ class PointsMixin:
if reason.startswith("Gift to ") or reason.startswith("Gift from "): if reason.startswith("Gift to ") or reason.startswith("Gift from "):
link_id = getattr(target, "link_id", "") or "" link_id = getattr(target, "link_id", "") or ""
if not link_id: if not link_id:
raise ValueError( raise ValueError("This gift predates undo support and can't be reversed automatically.")
"This gift predates undo support and can't be reversed automatically."
)
for leg in [t for t in txns if (getattr(t, "link_id", "") or "") == link_id]: for leg in [t for t in txns if (getattr(t, "link_id", "") or "") == link_id]:
leg_child = self.get_child(leg.child_id) leg_child = self.get_child(leg.child_id)
if leg_child: if leg_child:
@@ -368,9 +359,7 @@ class PointsMixin:
# points, so nothing else to reverse. # points, so nothing else to reverse.
child.career_score = child.total_points_earned - child.total_penalties_received child.career_score = child.total_points_earned - child.total_penalties_received
self.storage.update_child(child) self.storage.update_child(child)
self.storage.append_career_score_snapshot( self.storage.append_career_score_snapshot(child.id, dt_util.now().date().isoformat(), child.career_score)
child.id, dt_util.now().date().isoformat(), child.career_score
)
self.storage.remove_points_transaction(transaction_id) self.storage.remove_points_transaction(transaction_id)
await self.storage.async_save() await self.storage.async_save()
await self.async_refresh() await self.async_refresh()
@@ -394,9 +383,7 @@ class PointsMixin:
lvl = xp // step + 1 lvl = xp // step + 1
return {"level": lvl, "progress": xp - (lvl - 1) * step, "target": step} return {"level": lvl, "progress": xp - (lvl - 1) * step, "target": step}
async def _celebrate( async def _celebrate(self, child, kind: str, message: str, tier: int = 1, extra: dict | None = None) -> None:
self, child, kind: str, message: str, tier: int = 1, extra: dict | None = None
) -> None:
"""Central celebration funnel for notable moments. """Central celebration funnel for notable moments.
Always fires a single ``taskmate_celebration`` event carrying a ``tier`` Always fires a single ``taskmate_celebration`` event carrying a ``tier``
@@ -452,17 +439,30 @@ class PointsMixin:
if new < old: if new < old:
return # earned total dropped (e.g. undo); resync quietly return # earned total dropped (e.g. undo); resync quietly
for lvl in range(old + 1, new + 1): for lvl in range(old + 1, new + 1):
self.hass.bus.async_fire("taskmate_level_up", { self.hass.bus.async_fire(
"child_id": child.id, "child_name": child.name, "taskmate_level_up",
"level": lvl, "timestamp": dt_util.now().isoformat(), {
}) "child_id": child.id,
"child_name": child.name,
"level": lvl,
"timestamp": dt_util.now().isoformat(),
},
)
if getattr(self, "notifications", None): if getattr(self, "notifications", None):
await self.notifications.fire("level_up", { await self.notifications.fire(
"child_name": child.name, "child_id": child.id, "level": lvl, "level_up",
}) {
"child_name": child.name,
"child_id": child.id,
"level": lvl,
},
)
await self._celebrate( await self._celebrate(
child, "level_up", f"{child.name} reached level {lvl}!", child,
tier=3 if lvl % 5 == 0 else 2, extra={"level": lvl}, "level_up",
f"{child.name} reached level {lvl}!",
tier=3 if lvl % 5 == 0 else 2,
extra={"level": lvl},
) )
async def async_gift_points(self, from_child_id: str, to_child_id: str, points: int) -> None: async def async_gift_points(self, from_child_id: str, to_child_id: str, points: int) -> None:
@@ -481,9 +481,7 @@ class PointsMixin:
if not sender or not recipient: if not sender or not recipient:
raise ValueError("Sender or recipient not found") raise ValueError("Sender or recipient not found")
if (sender.points or 0) < points: if (sender.points or 0) < points:
raise ValueError( raise ValueError(f"Not enough points: {sender.name} has {sender.points}, gift {points}")
f"Not enough points: {sender.name} has {sender.points}, gift {points}"
)
now = dt_util.now() now = dt_util.now()
sender.points -= points sender.points -= points
recipient.points += points recipient.points += points
@@ -491,19 +489,35 @@ class PointsMixin:
self.storage.update_child(recipient) self.storage.update_child(recipient)
# Shared link_id so undo can reverse both legs together. # Shared link_id so undo can reverse both legs together.
gift_link = generate_id() gift_link = generate_id()
self.storage.add_points_transaction(PointsTransaction( self.storage.add_points_transaction(
child_id=sender.id, points=-points, PointsTransaction(
reason=f"Gift to {recipient.name}", created_at=now, link_id=gift_link, child_id=sender.id,
)) points=-points,
self.storage.add_points_transaction(PointsTransaction( reason=f"Gift to {recipient.name}",
child_id=recipient.id, points=points, created_at=now,
reason=f"Gift from {sender.name}", created_at=now, link_id=gift_link, link_id=gift_link,
)) )
self.hass.bus.async_fire("taskmate_points_gifted", { )
"from_child_id": sender.id, "from_child_name": sender.name, self.storage.add_points_transaction(
"to_child_id": recipient.id, "to_child_name": recipient.name, PointsTransaction(
"points": points, "timestamp": now.isoformat(), child_id=recipient.id,
}) points=points,
reason=f"Gift from {sender.name}",
created_at=now,
link_id=gift_link,
)
)
self.hass.bus.async_fire(
"taskmate_points_gifted",
{
"from_child_id": sender.id,
"from_child_name": sender.name,
"to_child_id": recipient.id,
"to_child_name": recipient.name,
"points": points,
"timestamp": now.isoformat(),
},
)
await self.storage.async_save() await self.storage.async_save()
await self.async_refresh() await self.async_refresh()
@@ -526,10 +540,7 @@ class PointsMixin:
return return
period = self.storage.get_setting("points_decay_period", "monthly") period = self.storage.get_setting("points_decay_period", "monthly")
today = dt_util.now().date() today = dt_util.now().date()
due = ( due = (period == "weekly" and today.weekday() == 0) or (period == "monthly" and today.day == 1)
(period == "weekly" and today.weekday() == 0)
or (period == "monthly" and today.day == 1)
)
if not due: if not due:
return return
if self.storage.get_setting("points_decay_last", "") == today.isoformat(): if self.storage.get_setting("points_decay_last", "") == today.isoformat():
@@ -544,14 +555,23 @@ class PointsMixin:
continue continue
child.points = max(0, child.points - loss) child.points = max(0, child.points - loss)
self.storage.update_child(child) self.storage.update_child(child)
self.storage.add_points_transaction(PointsTransaction( self.storage.add_points_transaction(
child_id=child.id, points=-loss, PointsTransaction(
reason=f"Points decay (-{pct:.0f}%)", created_at=now, child_id=child.id,
)) points=-loss,
self.hass.bus.async_fire("taskmate_points_decay", { reason=f"Points decay (-{pct:.0f}%)",
"child_id": child.id, "child_name": child.name, created_at=now,
"points": loss, "timestamp": now.isoformat(), )
}) )
self.hass.bus.async_fire(
"taskmate_points_decay",
{
"child_id": child.id,
"child_name": child.name,
"points": loss,
"timestamp": now.isoformat(),
},
)
changed = True changed = True
self.storage.set_setting("points_decay_last", today.isoformat()) self.storage.set_setting("points_decay_last", today.isoformat())
if changed: if changed:
@@ -576,10 +596,7 @@ class PointsMixin:
return return
period = self.storage.get_setting("interest_period", "weekly") period = self.storage.get_setting("interest_period", "weekly")
today = dt_util.now().date() today = dt_util.now().date()
due = ( due = (period == "weekly" and today.weekday() == 0) or (period == "monthly" and today.day == 1)
(period == "weekly" and today.weekday() == 0)
or (period == "monthly" and today.day == 1)
)
if not due: if not due:
return return
if self.storage.get_setting("interest_last", "") == today.isoformat(): if self.storage.get_setting("interest_last", "") == today.isoformat():
@@ -592,10 +609,15 @@ class PointsMixin:
if interest <= 0: if interest <= 0:
continue continue
await self.async_add_points(child.id, interest, reason=f"Savings interest (+{pct:.0f}%)") await self.async_add_points(child.id, interest, reason=f"Savings interest (+{pct:.0f}%)")
self.hass.bus.async_fire("taskmate_interest_paid", { self.hass.bus.async_fire(
"child_id": child.id, "child_name": child.name, "taskmate_interest_paid",
"points": interest, "timestamp": dt_util.now().isoformat(), {
}) "child_id": child.id,
"child_name": child.name,
"points": interest,
"timestamp": dt_util.now().isoformat(),
},
)
self.storage.set_setting("interest_last", today.isoformat()) self.storage.set_setting("interest_last", today.isoformat())
await self.storage.async_save() await self.storage.async_save()
@@ -613,17 +635,13 @@ class PointsMixin:
if not part: if not part:
continue continue
if ":" not in part: if ":" not in part:
raise ValueError( raise ValueError(f"Invalid format '{part}' — use 'days:points' pairs, e.g. '7:10, 14:20'")
f"Invalid format '{part}' — use 'days:points' pairs, e.g. '7:10, 14:20'"
)
days_str, points_str = part.split(":", 1) days_str, points_str = part.split(":", 1)
try: try:
days = int(days_str.strip()) days = int(days_str.strip())
points = int(points_str.strip()) points = int(points_str.strip())
except ValueError as err: except ValueError as err:
raise ValueError( raise ValueError(f"Invalid numbers in '{part}' — days and points must be whole numbers") from err
f"Invalid numbers in '{part}' — days and points must be whole numbers"
) from err
if days < 1: if days < 1:
raise ValueError(f"Days must be at least 1, got {days}") raise ValueError(f"Days must be at least 1, got {days}")
if points < 1: if points < 1:
@@ -718,7 +736,7 @@ class PointsMixin:
today = now.date() today = now.date()
effective_date = completion_date or today effective_date = completion_date or today
effective_date_str = effective_date.isoformat() effective_date_str = effective_date.isoformat()
last_date_str = getattr(child, 'last_completion_date', None) last_date_str = getattr(child, "last_completion_date", None)
# ── Weekend multiplier ────────────────────────────────────────────── # ── Weekend multiplier ──────────────────────────────────────────────
# Applied to base chore points only, based on completion date # Applied to base chore points only, based on completion date
@@ -740,7 +758,10 @@ class PointsMixin:
if weekend_bonus > 0: if weekend_bonus > 0:
_LOGGER.info( _LOGGER.info(
"Weekend multiplier (%.1fx) applied for %s: +%d bonus on top of %d", "Weekend multiplier (%.1fx) applied for %s: +%d bonus on top of %d",
multiplier, child.name, weekend_bonus, points, multiplier,
child.name,
weekend_bonus,
points,
) )
# Log weekend bonus as a separate transaction for activity history # Log weekend bonus as a separate transaction for activity history
transaction = PointsTransaction( transaction = PointsTransaction(
@@ -758,9 +779,7 @@ class PointsMixin:
# day is done. The in-flight completion (chore_id) is counted as done # day is done. The in-flight completion (chore_id) is counted as done
# since it may not be persisted yet at this point in the flow. # since it may not be persisted yet at this point in the flow.
if advance_streak and self._setting_enabled("streak_requires_all_chores"): if advance_streak and self._setting_enabled("streak_requires_all_chores"):
if not self._all_due_chores_done( if not self._all_due_chores_done(child.id, effective_date, include_rotation=True, extra_done=chore_id):
child.id, effective_date, include_rotation=True, extra_done=chore_id
):
advance_streak = False advance_streak = False
if advance_streak: if advance_streak:
streak_mode = self.storage.get_setting("streak_reset_mode", "reset") streak_mode = self.storage.get_setting("streak_reset_mode", "reset")
@@ -815,9 +834,7 @@ class PointsMixin:
milestones_enabled = self.storage.get_setting("streak_milestones_enabled", "true") == "true" milestones_enabled = self.storage.get_setting("streak_milestones_enabled", "true") == "true"
if advance_streak and milestones_enabled and child.current_streak > 0: if advance_streak and milestones_enabled and child.current_streak > 0:
# Parse custom milestone config # Parse custom milestone config
milestone_setting = self.storage.get_setting( milestone_setting = self.storage.get_setting("streak_milestones", self.DEFAULT_STREAK_MILESTONES)
"streak_milestones", self.DEFAULT_STREAK_MILESTONES
)
try: try:
milestones = self.parse_milestone_setting(milestone_setting) milestones = self.parse_milestone_setting(milestone_setting)
except ValueError: except ValueError:
@@ -836,7 +853,9 @@ class PointsMixin:
reached_milestones.append((days, bonus_pts)) reached_milestones.append((days, bonus_pts))
_LOGGER.info( _LOGGER.info(
"Streak milestone %d days reached for %s: +%d bonus", "Streak milestone %d days reached for %s: +%d bonus",
days, child.name, bonus_pts, days,
child.name,
bonus_pts,
) )
child.streak_milestones_achieved = sorted(achieved) child.streak_milestones_achieved = sorted(achieved)
@@ -853,9 +872,7 @@ class PointsMixin:
) )
self.storage.add_points_transaction(transaction) self.storage.add_points_transaction(transaction)
self.storage.append_career_score_snapshot( self.storage.append_career_score_snapshot(child.id, effective_date.isoformat(), child.career_score)
child.id, effective_date.isoformat(), child.career_score
)
await self._maybe_level_up(child) await self._maybe_level_up(child)
self.storage.update_child(child) self.storage.update_child(child)
@@ -877,9 +894,11 @@ class PointsMixin:
# A big streak is a celebration moment too — epic at 30+ days. # A big streak is a celebration moment too — epic at 30+ days.
for days, _bonus_pts in reached_milestones: for days, _bonus_pts in reached_milestones:
await self._celebrate( await self._celebrate(
child, "streak_milestone", child,
"streak_milestone",
f"{child.name} hit a {days}-day streak!", f"{child.name} hit a {days}-day streak!",
tier=3 if days >= 30 else 2, extra={"days": days}, tier=3 if days >= 30 else 2,
extra={"days": days},
) )
return total_points return total_points
@@ -890,10 +909,7 @@ class PointsMixin:
before = len(all_completions) before = len(all_completions)
# Keep completions newer than cutoff OR unapproved (pending) # Keep completions newer than cutoff OR unapproved (pending)
to_keep = [ to_keep = [c for c in all_completions if c.completed_at >= cutoff or not c.approved]
c for c in all_completions
if c.completed_at >= cutoff or not c.approved
]
if len(to_keep) < before: if len(to_keep) < before:
kept_ids = {c.id for c in to_keep} kept_ids = {c.id for c in to_keep}
@@ -906,10 +922,7 @@ class PointsMixin:
if c.id not in kept_ids and getattr(c, "photo_url", ""): if c.id not in kept_ids and getattr(c, "photo_url", ""):
await photos.async_delete_photo(self.hass, c.photo_url) await photos.async_delete_photo(self.hass, c.photo_url)
await self.async_refresh() await self.async_refresh()
_LOGGER.info( _LOGGER.info("Pruned %d completions older than %d days", before - len(to_keep), days)
"Pruned %d completions older than %d days",
before - len(to_keep), days
)
# Penalty operations # Penalty operations
async def async_add_penalty( async def async_add_penalty(
@@ -954,12 +967,17 @@ class PointsMixin:
if not child: if not child:
raise ValueError(f"Child {child_id} not found") raise ValueError(f"Child {child_id} not found")
await self.async_remove_points(child_id, penalty.points, reason=f"Penalty: {penalty.name}") await self.async_remove_points(child_id, penalty.points, reason=f"Penalty: {penalty.name}")
self.hass.bus.async_fire("taskmate_penalty_applied", { self.hass.bus.async_fire(
"child_id": child.id, "child_name": child.name, "taskmate_penalty_applied",
"penalty_id": penalty.id, "penalty_name": penalty.name, {
"points": penalty.points, "child_id": child.id,
"timestamp": dt_util.now().isoformat(), "child_name": child.name,
}) "penalty_id": penalty.id,
"penalty_name": penalty.name,
"points": penalty.points,
"timestamp": dt_util.now().isoformat(),
},
)
# Bonus operations # Bonus operations
async def async_add_bonus( async def async_add_bonus(
@@ -1004,16 +1022,25 @@ class PointsMixin:
if not child: if not child:
raise ValueError(f"Child {child_id} not found") raise ValueError(f"Child {child_id} not found")
await self.async_add_points(child_id, bonus.points, reason=f"Bonus: {bonus.name}") await self.async_add_points(child_id, bonus.points, reason=f"Bonus: {bonus.name}")
self.hass.bus.async_fire("taskmate_bonus_applied", { self.hass.bus.async_fire(
"child_id": child.id, "child_name": child.name, "taskmate_bonus_applied",
"bonus_id": bonus.id, "bonus_name": bonus.name, {
"points": bonus.points, "child_id": child.id,
"timestamp": dt_util.now().isoformat(), "child_name": child.name,
}) "bonus_id": bonus.id,
"bonus_name": bonus.name,
"points": bonus.points,
"timestamp": dt_util.now().isoformat(),
},
)
async def _async_notify_pending_approval( async def _async_notify_pending_approval(
self, child_name: str, chore_name: str, points: int, self,
completion_id: str | None = None, photo_url: str = "", child_name: str,
chore_name: str,
points: int,
completion_id: str | None = None,
photo_url: str = "",
) -> None: ) -> None:
await self.notifications.fire( await self.notifications.fire(
"pending_chore_approval", "pending_chore_approval",
@@ -1030,7 +1057,10 @@ class PointsMixin:
) )
async def _async_notify_pending_reward_claim( async def _async_notify_pending_reward_claim(
self, child_name: str, reward_name: str, cost: int, self,
child_name: str,
reward_name: str,
cost: int,
claim_id: str | None = None, claim_id: str | None = None,
) -> None: ) -> None:
await self.notifications.fire( await self.notifications.fire(
+37 -22
View File
@@ -6,6 +6,7 @@ current step advances them. Finishing the final step awards the quest's bonus
points, fires a ``taskmate_quest_completed`` event + celebration, and either points, fires a ``taskmate_quest_completed`` event + celebration, and either
resets progress (repeatable quests) or marks the quest complete for that child. resets progress (repeatable quests) or marks the quest complete for that child.
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
@@ -72,17 +73,19 @@ class QuestsMixin:
step = int(prog.get("step", 0)) step = int(prog.get("step", 0))
total = len(quest.steps) total = len(quest.steps)
done = step >= total done = step >= total
out.append({ out.append(
"quest_id": quest.id, {
"name": quest.name, "quest_id": quest.id,
"icon": quest.icon, "name": quest.name,
"total_steps": total, "icon": quest.icon,
"step": min(step, total), "total_steps": total,
"done": done, "step": min(step, total),
"times_completed": int(prog.get("completed_count", 0)), "done": done,
"bonus_points": quest.bonus_points, "times_completed": int(prog.get("completed_count", 0)),
"next_chore_id": quest.steps[step] if not done and step < total else "", "bonus_points": quest.bonus_points,
}) "next_chore_id": quest.steps[step] if not done and step < total else "",
}
)
return out return out
# ── Progression ────────────────────────────────────────────────────── # ── Progression ──────────────────────────────────────────────────────
@@ -129,24 +132,36 @@ class QuestsMixin:
child.points += bonus child.points += bonus
child.total_points_earned += bonus child.total_points_earned += bonus
child.career_score = child.total_points_earned - child.total_penalties_received child.career_score = child.total_points_earned - child.total_penalties_received
self.storage.add_points_transaction(PointsTransaction( self.storage.add_points_transaction(
child_id=child.id, points=bonus, PointsTransaction(
reason=f"Quest complete: {quest.name}", created_at=dt_util.now(), child_id=child.id,
)) points=bonus,
reason=f"Quest complete: {quest.name}",
created_at=dt_util.now(),
)
)
if hasattr(self, "_maybe_level_up"): if hasattr(self, "_maybe_level_up"):
await self._maybe_level_up(child) await self._maybe_level_up(child)
self.storage.update_child(child) self.storage.update_child(child)
self.hass.bus.async_fire("taskmate_quest_completed", { self.hass.bus.async_fire(
"child_id": child.id, "child_name": child.name, "taskmate_quest_completed",
"quest_id": quest.id, "quest_name": quest.name, {
"bonus": bonus, "timestamp": dt_util.now().isoformat(), "child_id": child.id,
}) "child_name": child.name,
"quest_id": quest.id,
"quest_name": quest.name,
"bonus": bonus,
"timestamp": dt_util.now().isoformat(),
},
)
if hasattr(self, "_celebrate"): if hasattr(self, "_celebrate"):
await self._celebrate( await self._celebrate(
child, "quest_completed", child,
"quest_completed",
f"{child.name} completed the quest '{quest.name}'!", f"{child.name} completed the quest '{quest.name}'!",
tier=3, extra={"quest_id": quest.id, "bonus": bonus}, tier=3,
extra={"quest_id": quest.id, "bonus": bonus},
) )
# Repeatable quests start over; one-shot quests stay complete. # Repeatable quests start over; one-shot quests stay complete.
+65 -69
View File
@@ -1,4 +1,5 @@
"""Reward operations mixin for TaskMateCoordinator.""" """Reward operations mixin for TaskMateCoordinator."""
from __future__ import annotations from __future__ import annotations
import logging import logging
@@ -82,15 +83,11 @@ class RewardsMixin:
if old and reward.cost < old.cost: if old and reward.cost < old.cost:
self._refund_pool_excess(reward, "Pool refund (reward cost reduced)") self._refund_pool_excess(reward, "Pool refund (reward cost reduced)")
became_unavailable = ( became_unavailable = (
self._reward_is_unavailable(reward) self._reward_is_unavailable(reward) and old is not None and not self._reward_is_unavailable(old)
and old is not None
and not self._reward_is_unavailable(old)
) )
if became_unavailable: if became_unavailable:
reason = ( reason = (
"Pool refund (reward expired)" "Pool refund (reward expired)" if self._reward_is_expired(reward) else "Pool refund (reward sold out)"
if self._reward_is_expired(reward)
else "Pool refund (reward sold out)"
) )
self._refund_all_pool_allocations(reward, reason) self._refund_all_pool_allocations(reward, reason)
await self.storage.async_save() await self.storage.async_save()
@@ -140,8 +137,7 @@ class RewardsMixin:
stays consistent with cost-reduction refunds. stays consistent with cost-reduction refunds.
""" """
allocations = [ allocations = [
a for a in self.storage.get_pool_allocations() a for a in self.storage.get_pool_allocations() if a.reward_id == reward.id and a.allocated_points > 0
if a.reward_id == reward.id and a.allocated_points > 0
] ]
for alloc in allocations: for alloc in allocations:
self._apply_pool_refund(alloc, alloc.allocated_points, reward, reason) self._apply_pool_refund(alloc, alloc.allocated_points, reward, reason)
@@ -154,8 +150,7 @@ class RewardsMixin:
until the combined total matches the cost. until the combined total matches the cost.
""" """
allocations = [ allocations = [
a for a in self.storage.get_pool_allocations() a for a in self.storage.get_pool_allocations() if a.reward_id == reward.id and a.allocated_points > 0
if a.reward_id == reward.id and a.allocated_points > 0
] ]
if not allocations: if not allocations:
return return
@@ -173,13 +168,9 @@ class RewardsMixin:
else: else:
for alloc in allocations: for alloc in allocations:
if alloc.allocated_points > reward.cost: if alloc.allocated_points > reward.cost:
self._apply_pool_refund( self._apply_pool_refund(alloc, alloc.allocated_points - reward.cost, reward, reason)
alloc, alloc.allocated_points - reward.cost, reward, reason
)
def _apply_pool_refund( def _apply_pool_refund(self, allocation: PoolAllocation, refund: int, reward: Reward, reason: str) -> None:
self, allocation: PoolAllocation, refund: int, reward: Reward, reason: str
) -> None:
"""Refund `refund` points from `allocation` back to the child's wallet. """Refund `refund` points from `allocation` back to the child's wallet.
Updates or removes the allocation record and writes an audit transaction. Updates or removes the allocation record and writes an audit transaction.
@@ -196,19 +187,23 @@ class RewardsMixin:
if remaining <= 0: if remaining <= 0:
self.storage.remove_pool_allocation(allocation.child_id, allocation.reward_id) self.storage.remove_pool_allocation(allocation.child_id, allocation.reward_id)
else: else:
self.storage.upsert_pool_allocation(PoolAllocation( self.storage.upsert_pool_allocation(
child_id=allocation.child_id, PoolAllocation(
reward_id=allocation.reward_id, child_id=allocation.child_id,
allocated_points=remaining, reward_id=allocation.reward_id,
id=allocation.id, allocated_points=remaining,
)) id=allocation.id,
)
)
self.storage.add_points_transaction(PointsTransaction( self.storage.add_points_transaction(
child_id=allocation.child_id, PointsTransaction(
points=refund, child_id=allocation.child_id,
reason=f"{reason}: {reward.name}", points=refund,
created_at=dt_util.now(), reason=f"{reason}: {reward.name}",
)) created_at=dt_util.now(),
)
)
async def async_claim_reward(self, reward_id: str, child_id: str) -> RewardClaim: async def async_claim_reward(self, reward_id: str, child_id: str) -> RewardClaim:
"""Child claims a reward — creates a pending claim awaiting parent approval. """Child claims a reward — creates a pending claim awaiting parent approval.
@@ -261,9 +256,7 @@ class RewardsMixin:
available_points = child.points - committed available_points = child.points - committed
if available_points < effective_cost: if available_points < effective_cost:
raise ValueError( raise ValueError(f"Not enough points. Need {effective_cost}, have {available_points} available")
f"Not enough points. Need {effective_cost}, have {available_points} available"
)
claim = RewardClaim( claim = RewardClaim(
reward_id=reward_id, reward_id=reward_id,
@@ -287,7 +280,10 @@ class RewardsMixin:
await self.storage.async_save() await self.storage.async_save()
await self.async_refresh() await self.async_refresh()
await self._async_notify_pending_reward_claim( await self._async_notify_pending_reward_claim(
child.name, reward.name, reward.cost, claim_id=claim.id, child.name,
reward.name,
reward.cost,
claim_id=claim.id,
) )
return claim return claim
@@ -297,6 +293,7 @@ class RewardsMixin:
if period == "monthly": if period == "monthly":
return today.replace(day=1) return today.replace(day=1)
from datetime import timedelta from datetime import timedelta
return today - timedelta(days=today.weekday()) # Monday of this week return today - timedelta(days=today.weekday()) # Monday of this week
def _spent_in_period(self, child_id: str) -> int: def _spent_in_period(self, child_id: str) -> int:
@@ -324,9 +321,7 @@ class RewardsMixin:
if cap <= 0: if cap <= 0:
return return
if self._spent_in_period(child_id) + cost > cap: if self._spent_in_period(child_id) + cost > cap:
raise ValueError( raise ValueError(f"Spending cap reached: {cap} per period already used")
f"Spending cap reached: {cap} per period already used"
)
async def async_approve_reward(self, claim_id: str) -> None: async def async_approve_reward(self, claim_id: str) -> None:
"""Approve a reward claim and deduct points from the child. """Approve a reward claim and deduct points from the child.
@@ -371,7 +366,8 @@ class RewardsMixin:
self._refund_pool_excess(reward, "Pool refund on redeem") self._refund_pool_excess(reward, "Pool refund on redeem")
if reward.is_jackpot: if reward.is_jackpot:
jackpot_allocs = [ jackpot_allocs = [
a for a in self.storage.get_pool_allocations() a
for a in self.storage.get_pool_allocations()
if a.reward_id == claim.reward_id and a.allocated_points > 0 if a.reward_id == claim.reward_id and a.allocated_points > 0
] ]
for alloc in jackpot_allocs: for alloc in jackpot_allocs:
@@ -381,9 +377,7 @@ class RewardsMixin:
else: else:
# Wallet mode: deduct directly from child.points # Wallet mode: deduct directly from child.points
if child.points < effective_cost: if child.points < effective_cost:
raise ValueError( raise ValueError(f"Not enough points to approve. Need {effective_cost}, have {child.points}")
f"Not enough points to approve. Need {effective_cost}, have {child.points}"
)
child.points -= effective_cost child.points -= effective_cost
self.storage.update_child(child) self.storage.update_child(child)
@@ -393,9 +387,7 @@ class RewardsMixin:
if reward.quantity == 0: if reward.quantity == 0:
# Last unit claimed — refund any points other children # Last unit claimed — refund any points other children
# still have earmarked for this reward's pool. # still have earmarked for this reward's pool.
self._refund_all_pool_allocations( self._refund_all_pool_allocations(reward, "Pool refund (reward sold out)")
reward, "Pool refund (reward sold out)"
)
claim.approved = True claim.approved = True
claim.approved_at = dt_util.now() claim.approved_at = dt_util.now()
@@ -405,20 +397,23 @@ class RewardsMixin:
# Dismiss the mobile approval push now this claim is reviewed. # Dismiss the mobile approval push now this claim is reviewed.
if getattr(self, "notifications", None): if getattr(self, "notifications", None):
await self.notifications.clear_approval( await self.notifications.clear_approval("pending_reward_claim", claim_id)
"pending_reward_claim", claim_id
)
# Timed unlock (#678): allowlisted entity on, auto-off later. # Timed unlock (#678): allowlisted entity on, auto-off later.
await self.async_start_unlock(reward, child) await self.async_start_unlock(reward, child)
self.hass.bus.async_fire("taskmate_reward_approved", { self.hass.bus.async_fire(
"child_id": child.id, "child_name": child.name, "taskmate_reward_approved",
"reward_id": reward.id, "reward_name": reward.name, {
"claim_id": claim.id, "child_id": child.id,
"cost": effective_cost, "child_name": child.name,
"timestamp": dt_util.now().isoformat(), "reward_id": reward.id,
}) "reward_name": reward.name,
"claim_id": claim.id,
"cost": effective_cost,
"timestamp": dt_util.now().isoformat(),
},
)
if getattr(self, "badges", None): if getattr(self, "badges", None):
await self.badges.evaluate_for_child(claim.child_id, "reward_redeemed") await self.badges.evaluate_for_child(claim.child_id, "reward_redeemed")
@@ -435,23 +430,22 @@ class RewardsMixin:
if claim: if claim:
reward = self.get_reward(claim.reward_id) reward = self.get_reward(claim.reward_id)
child = self.get_child(claim.child_id) child = self.get_child(claim.child_id)
self.hass.bus.async_fire("taskmate_reward_rejected", { self.hass.bus.async_fire(
"child_id": claim.child_id, "taskmate_reward_rejected",
"child_name": getattr(child, "name", ""), {
"reward_id": claim.reward_id, "child_id": claim.child_id,
"reward_name": getattr(reward, "name", ""), "child_name": getattr(child, "name", ""),
"claim_id": claim.id, "reward_id": claim.reward_id,
"timestamp": dt_util.now().isoformat(), "reward_name": getattr(reward, "name", ""),
}) "claim_id": claim.id,
"timestamp": dt_util.now().isoformat(),
},
)
# Dismiss the mobile approval push for this reviewed claim. # Dismiss the mobile approval push for this reviewed claim.
if getattr(self, "notifications", None): if getattr(self, "notifications", None):
await self.notifications.clear_approval( await self.notifications.clear_approval("pending_reward_claim", claim_id)
"pending_reward_claim", claim_id
)
async def async_allocate_points_to_pool( async def async_allocate_points_to_pool(self, child_id: str, reward_id: str, points: int) -> PoolAllocation:
self, child_id: str, reward_id: str, points: int
) -> PoolAllocation:
"""Move `points` from a child's spendable balance into a reward pool. """Move `points` from a child's spendable balance into a reward pool.
Deducts immediately from child.points so the visible balance reflects the Deducts immediately from child.points so the visible balance reflects the
@@ -539,6 +533,7 @@ class RewardsMixin:
``restock_last`` stamp guards against restocking twice in a day. ``restock_last`` stamp guards against restocking twice in a day.
""" """
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
today = dt_util.now().date() today = dt_util.now().date()
today_iso = today.isoformat() today_iso = today.isoformat()
changed = False changed = False
@@ -577,8 +572,7 @@ class RewardsMixin:
if not self._reward_is_expired(reward): if not self._reward_is_expired(reward):
continue continue
allocations_before = [ allocations_before = [
a for a in self.storage.get_pool_allocations() a for a in self.storage.get_pool_allocations() if a.reward_id == reward.id and a.allocated_points > 0
if a.reward_id == reward.id and a.allocated_points > 0
] ]
if not allocations_before: if not allocations_before:
continue continue
@@ -586,7 +580,9 @@ class RewardsMixin:
changed = True changed = True
_LOGGER.info( _LOGGER.info(
"Reward '%s' expired on %s — refunded %d pool allocation(s)", "Reward '%s' expired on %s — refunded %d pool allocation(s)",
reward.name, reward.expires_at, len(allocations_before), reward.name,
reward.expires_at,
len(allocations_before),
) )
if changed: if changed:
+22 -21
View File
@@ -1,4 +1,5 @@
"""Template operations mixin for TaskMateCoordinator.""" """Template operations mixin for TaskMateCoordinator."""
from __future__ import annotations from __future__ import annotations
import logging import logging
@@ -70,9 +71,7 @@ class TemplatesMixin:
await self.async_refresh() await self.async_refresh()
return created_ids return created_ids
async def async_save_template_from_chores( async def async_save_template_from_chores(self, chore_ids: list[str], name: str, icon: str) -> str:
self, chore_ids: list[str], name: str, icon: str
) -> str:
"""Save existing chores as a custom template pack.""" """Save existing chores as a custom template pack."""
if not chore_ids: if not chore_ids:
raise ValueError("At least one chore must be selected") raise ValueError("At least one chore must be selected")
@@ -97,9 +96,7 @@ class TemplatesMixin:
await self.storage.async_save() await self.storage.async_save()
return tpl_id return tpl_id
async def async_create_template( async def async_create_template(self, name: str, icon: str, chores: list[dict]) -> str:
self, name: str, icon: str, chores: list[dict]
) -> str:
"""Create a new custom template from scratch.""" """Create a new custom template from scratch."""
if not chores: if not chores:
raise ValueError("Template must have at least one chore") raise ValueError("Template must have at least one chore")
@@ -156,16 +153,19 @@ class TemplatesMixin:
for tpl in self.storage.get_custom_templates(): for tpl in self.storage.get_custom_templates():
if wanted and tpl.get("id") not in wanted: if wanted and tpl.get("id") not in wanted:
continue continue
packed.append({ packed.append(
"name": tpl.get("name", ""), {
"icon": tpl.get("icon", "mdi:clipboard-list-outline"), "name": tpl.get("name", ""),
"chores": [ "icon": tpl.get("icon", "mdi:clipboard-list-outline"),
{k: v for k, v in chore.items() if k in TEMPLATE_CHORE_FIELDS} "chores": [
for chore in tpl.get("chores", []) {k: v for k, v in chore.items() if k in TEMPLATE_CHORE_FIELDS}
], for chore in tpl.get("chores", [])
}) ],
}
)
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
return { return {
"format": self.PACK_FORMAT, "format": self.PACK_FORMAT,
"version": self.PACK_VERSION, "version": self.PACK_VERSION,
@@ -191,8 +191,7 @@ class TemplatesMixin:
raise ValueError("Pack version is not a number") from err raise ValueError("Pack version is not a number") from err
if version > self.PACK_VERSION: if version > self.PACK_VERSION:
raise ValueError( raise ValueError(
f"This pack needs a newer TaskMate (pack version {version}, " f"This pack needs a newer TaskMate (pack version {version}, this one understands {self.PACK_VERSION})"
f"this one understands {self.PACK_VERSION})"
) )
templates = pack.get("templates") templates = pack.get("templates")
@@ -227,11 +226,13 @@ class TemplatesMixin:
cleaned["name"] = chore_name[:200] cleaned["name"] = chore_name[:200]
chores.append(cleaned) chores.append(cleaned)
clean.append({ clean.append(
"name": name[:120], {
"icon": str(entry.get("icon", "") or "mdi:clipboard-list-outline"), "name": name[:120],
"chores": chores, "icon": str(entry.get("icon", "") or "mdi:clipboard-list-outline"),
}) "chores": chores,
}
)
return clean return clean
async def async_import_pack(self, pack: dict) -> dict: async def async_import_pack(self, pack: dict) -> dict:
+7 -7
View File
@@ -1,4 +1,5 @@
"""Timed task operations mixin for TaskMateCoordinator.""" """Timed task operations mixin for TaskMateCoordinator."""
from __future__ import annotations from __future__ import annotations
import logging import logging
@@ -41,9 +42,7 @@ class TimedMixin:
# Check daily cap before resuming # Check daily cap before resuming
if chore.timed_max_daily_minutes > 0: if chore.timed_max_daily_minutes > 0:
if existing.total_seconds_today >= chore.timed_max_daily_minutes * 60: if existing.total_seconds_today >= chore.timed_max_daily_minutes * 60:
raise ValueError( raise ValueError(f"Daily cap reached ({chore.timed_max_daily_minutes} min)")
f"Daily cap reached ({chore.timed_max_daily_minutes} min)"
)
existing.state = "running" existing.state = "running"
existing.segments.append({"start": now.isoformat(), "end": None}) existing.segments.append({"start": now.isoformat(), "end": None})
self.storage.save_timed_session(existing) self.storage.save_timed_session(existing)
@@ -52,9 +51,7 @@ class TimedMixin:
if chore.timed_max_daily_minutes > 0: if chore.timed_max_daily_minutes > 0:
old_session = self.storage.get_timed_session(chore_id, child_id, today) old_session = self.storage.get_timed_session(chore_id, child_id, today)
if old_session and old_session.total_seconds_today >= chore.timed_max_daily_minutes * 60: if old_session and old_session.total_seconds_today >= chore.timed_max_daily_minutes * 60:
raise ValueError( raise ValueError(f"Daily cap reached ({chore.timed_max_daily_minutes} min)")
f"Daily cap reached ({chore.timed_max_daily_minutes} min)"
)
session = TimedSession( session = TimedSession(
chore_id=chore_id, chore_id=chore_id,
child_id=child_id, child_id=child_id,
@@ -139,7 +136,10 @@ class TimedMixin:
if chore.requires_approval: if chore.requires_approval:
await self._async_notify_pending_approval( await self._async_notify_pending_approval(
child.name, chore.name, pts, completion_id=completion.id, child.name,
chore.name,
pts,
completion_id=completion.id,
) )
await self.async_refresh() await self.async_refresh()
+116 -72
View File
@@ -1,4 +1,5 @@
"""Data coordinator for TaskMate integration.""" """Data coordinator for TaskMate integration."""
from __future__ import annotations from __future__ import annotations
import logging import logging
@@ -107,11 +108,7 @@ class TaskMateCoordinator(
resolved = tier if tier in DEFAULT_DIFFICULTY_MULTIPLIERS else DEFAULT_DIFFICULTY resolved = tier if tier in DEFAULT_DIFFICULTY_MULTIPLIERS else DEFAULT_DIFFICULTY
default = DEFAULT_DIFFICULTY_MULTIPLIERS[resolved] default = DEFAULT_DIFFICULTY_MULTIPLIERS[resolved]
try: try:
return float( return float(self.storage.get_setting(f"difficulty_multiplier_{resolved}", str(default)))
self.storage.get_setting(
f"difficulty_multiplier_{resolved}", str(default)
)
)
except (ValueError, TypeError): except (ValueError, TypeError):
return default return default
@@ -145,12 +142,14 @@ class TaskMateCoordinator(
continue continue
if end < start: if end < start:
start, end = end, start start, end = end, start
periods.append({ periods.append(
"id": str(entry.get("id") or "").strip() or start.isoformat(), {
"name": str(entry.get("name") or "").strip(), "id": str(entry.get("id") or "").strip() or start.isoformat(),
"start": start.isoformat(), "name": str(entry.get("name") or "").strip(),
"end": end.isoformat(), "start": start.isoformat(),
}) "end": end.isoformat(),
}
)
return sorted(periods, key=lambda p: p["start"]) return sorted(periods, key=lambda p: p["start"])
def active_vacation(self, on: date | None = None) -> dict | None: def active_vacation(self, on: date | None = None) -> dict | None:
@@ -253,20 +252,20 @@ class TaskMateCoordinator(
await self.async_refresh() await self.async_refresh()
# ── Admin audit log ────────────────────────────────────────────────── # ── Admin audit log ──────────────────────────────────────────────────
async def async_record_audit( async def async_record_audit(self, user_id: str, user_name: str, action: str, target: str = "") -> None:
self, user_id: str, user_name: str, action: str, target: str = ""
) -> None:
"""Record an admin config action in the audit log and persist it.""" """Record an admin config action in the audit log and persist it."""
from .models import generate_id from .models import generate_id
self.storage.add_audit_entry({ self.storage.add_audit_entry(
"id": generate_id(), {
"ts": dt_util.now().isoformat(), "id": generate_id(),
"user_id": user_id or "", "ts": dt_util.now().isoformat(),
"user_name": user_name or "", "user_id": user_id or "",
"action": action, "user_name": user_name or "",
"target": target or "", "action": action,
}) "target": target or "",
}
)
await self.storage.async_save() await self.storage.async_save()
async def async_initialize(self) -> None: async def async_initialize(self) -> None:
@@ -296,16 +295,12 @@ class TaskMateCoordinator(
self.hass, self._async_midnight_streak_check, hour=0, minute=0, second=5 self.hass, self._async_midnight_streak_check, hour=0, minute=0, second=5
) )
# Schedule daily history pruning at 00:01:00 # Schedule daily history pruning at 00:01:00
self._unsub_prune = async_track_time_change( self._unsub_prune = async_track_time_change(self.hass, self._async_scheduled_prune, hour=0, minute=1, second=0)
self.hass, self._async_scheduled_prune, hour=0, minute=1, second=0
)
# Re-evaluate availability-aware chore assignments when any HA entity # Re-evaluate availability-aware chore assignments when any HA entity
# state changes. The callback filters cheaply on entity id so only # state changes. The callback filters cheaply on entity id so only
# relevant flips trigger a recompute. # relevant flips trigger a recompute.
self._refresh_tracked_availability_entities() self._refresh_tracked_availability_entities()
self._unsub_availability = self.hass.bus.async_listen( self._unsub_availability = self.hass.bus.async_listen("state_changed", self._availability_state_changed)
"state_changed", self._availability_state_changed
)
# Surprise-bonus daily roll at 16:00 (opt-in; no-op unless enabled) # Surprise-bonus daily roll at 16:00 (opt-in; no-op unless enabled)
self._unsub_surprise = async_track_time_change( self._unsub_surprise = async_track_time_change(
self.hass, self._async_surprise_bonus_check, hour=16, minute=0, second=0 self.hass, self._async_surprise_bonus_check, hour=16, minute=0, second=0
@@ -353,10 +348,7 @@ class TaskMateCoordinator(
done[comp.child_id] = done.get(comp.child_id, 0) + 1 done[comp.child_id] = done.get(comp.child_id, 0) + 1
earned[comp.child_id] = earned.get(comp.child_id, 0) + (comp.points_awarded or 0) earned[comp.child_id] = earned.get(comp.child_id, 0) + (comp.points_awarded or 0)
pts = self.storage.get_points_name() pts = self.storage.get_points_name()
lines = [ lines = [f"{c.name}: {done.get(c.id, 0)} chores, {earned.get(c.id, 0)} {pts} earned" for c in children]
f"{c.name}: {done.get(c.id, 0)} chores, {earned.get(c.id, 0)} {pts} earned"
for c in children
]
return "\n".join(lines) return "\n".join(lines)
async def _async_send_monthly_report(self) -> None: async def _async_send_monthly_report(self) -> None:
@@ -367,10 +359,13 @@ class TaskMateCoordinator(
summary = self._build_monthly_report(month_start, month_end) summary = self._build_monthly_report(month_start, month_end)
if not summary: if not summary:
return return
await self.notifications.fire("monthly_report", { await self.notifications.fire(
"summary": summary, "monthly_report",
"month": month_start.strftime("%B %Y"), {
}) "summary": summary,
"month": month_start.strftime("%B %Y"),
},
)
def _build_monthly_report(self, month_start: date, month_end: date) -> str: def _build_monthly_report(self, month_start: date, month_end: date) -> str:
"""Per-child recap for [month_start, month_end]: chores, points, level, best streak.""" """Per-child recap for [month_start, month_end]: chores, points, level, best streak."""
@@ -403,8 +398,7 @@ class TaskMateCoordinator(
ym = dt_util.now().strftime("%Y-%m") ym = dt_util.now().strftime("%Y-%m")
pts = self.storage.get_season_points(ym) pts = self.storage.get_season_points(ym)
rows = [ rows = [
{"child_id": c.id, "name": c.name, "points": int(pts.get(c.id, 0))} {"child_id": c.id, "name": c.name, "points": int(pts.get(c.id, 0))} for c in self.storage.get_children()
for c in self.storage.get_children()
] ]
rows.sort(key=lambda r: (-r["points"], r["name"].lower())) rows.sort(key=lambda r: (-r["points"], r["name"].lower()))
for i, r in enumerate(rows): for i, r in enumerate(rows):
@@ -434,13 +428,22 @@ class TaskMateCoordinator(
await self.storage.async_save() await self.storage.async_save()
name = str(self.storage.get_setting("family_goal_name", "") or "Family goal") name = str(self.storage.get_setting("family_goal_name", "") or "Family goal")
reward = str(self.storage.get_setting("family_goal_reward", "") or "a treat") reward = str(self.storage.get_setting("family_goal_reward", "") or "a treat")
self.hass.bus.async_fire("taskmate_family_goal_reached", { self.hass.bus.async_fire(
"goal_name": name, "goal_reward": reward, "target": target, "taskmate_family_goal_reached",
"timestamp": dt_util.now().isoformat(), {
}) "goal_name": name,
await self.notifications.fire("family_goal_reached", { "goal_reward": reward,
"goal_name": name, "goal_reward": reward, "target": target,
}) "timestamp": dt_util.now().isoformat(),
},
)
await self.notifications.fire(
"family_goal_reached",
{
"goal_name": name,
"goal_reward": reward,
},
)
# ── Allowance payout ledger (FEAT-3) ───────────────────────────────── # ── Allowance payout ledger (FEAT-3) ─────────────────────────────────
async def async_record_allowance_payout(self, child_id: str, points: int) -> dict: async def async_record_allowance_payout(self, child_id: str, points: int) -> dict:
@@ -469,6 +472,7 @@ class TaskMateCoordinator(
await self.async_remove_points(child_id, points, reason="Allowance payout") await self.async_remove_points(child_id, points, reason="Allowance payout")
from .models import generate_id from .models import generate_id
entry = { entry = {
"id": generate_id(), "id": generate_id(),
"child_id": child_id, "child_id": child_id,
@@ -490,6 +494,7 @@ class TaskMateCoordinator(
token = self.storage.get_setting("ics_token", "") token = self.storage.get_setting("ics_token", "")
if not token: if not token:
import secrets import secrets
token = secrets.token_urlsafe(24) token = secrets.token_urlsafe(24)
self.storage.set_setting("ics_token", token) self.storage.set_setting("ics_token", token)
await self.storage.async_save() await self.storage.async_save()
@@ -498,6 +503,7 @@ class TaskMateCoordinator(
async def async_regenerate_ics_token(self) -> str: async def async_regenerate_ics_token(self) -> str:
"""Rotate the ICS feed token (invalidates existing subscriptions).""" """Rotate the ICS feed token (invalidates existing subscriptions)."""
import secrets import secrets
token = secrets.token_urlsafe(24) token = secrets.token_urlsafe(24)
self.storage.set_setting("ics_token", token) self.storage.set_setting("ics_token", token)
await self.storage.async_save() await self.storage.async_save()
@@ -514,22 +520,34 @@ class TaskMateCoordinator(
if not winners: if not winners:
return return
top = winners[0] top = winners[0]
self.storage.add_season_champion({ self.storage.add_season_champion(
"month": ym, {
"child_id": top["child_id"], "month": ym,
"child_name": top["name"], "child_id": top["child_id"],
"points": top["points"], "child_name": top["name"],
}) "points": top["points"],
}
)
await self.storage.async_save() await self.storage.async_save()
self.hass.bus.async_fire("taskmate_season_champion", { self.hass.bus.async_fire(
"month": ym, "child_id": top["child_id"], "child_name": top["name"], "taskmate_season_champion",
"points": top["points"], "timestamp": now.isoformat(), {
}) "month": ym,
await self.notifications.fire("season_champion", { "child_id": top["child_id"],
"child_name": top["name"], "points": top["points"], "child_name": top["name"],
"month": prev_end.strftime("%B %Y"), "points": top["points"],
"points_name": self.storage.get_points_name(), "timestamp": now.isoformat(),
}) },
)
await self.notifications.fire(
"season_champion",
{
"child_name": top["name"],
"points": top["points"],
"month": prev_end.strftime("%B %Y"),
"points_name": self.storage.get_points_name(),
},
)
@callback @callback
def _async_surprise_bonus_check(self, now: datetime) -> None: def _async_surprise_bonus_check(self, now: datetime) -> None:
@@ -565,10 +583,15 @@ class TaskMateCoordinator(
if pts <= 0: if pts <= 0:
continue continue
await self.async_add_points(child.id, pts, reason="Surprise bonus 🎉") await self.async_add_points(child.id, pts, reason="Surprise bonus 🎉")
self.hass.bus.async_fire("taskmate_surprise_bonus", { self.hass.bus.async_fire(
"child_id": child.id, "child_name": child.name, "taskmate_surprise_bonus",
"points": pts, "timestamp": dt_util.now().isoformat(), {
}) "child_id": child.id,
"child_name": child.name,
"points": pts,
"timestamp": dt_util.now().isoformat(),
},
)
async def _async_backfill_career_history(self) -> None: async def _async_backfill_career_history(self) -> None:
"""Backfill career_score_history from completions and transactions. """Backfill career_score_history from completions and transactions.
@@ -618,13 +641,12 @@ class TaskMateCoordinator(
running = start_score running = start_score
for day in sorted_days: for day in sorted_days:
running += daily_net[day] running += daily_net[day]
self.storage.append_career_score_snapshot( self.storage.append_career_score_snapshot(child.id, day, running)
child.id, day, running
)
needs_save = True needs_save = True
_LOGGER.info( _LOGGER.info(
"Backfilled %d career history entries for %s", "Backfilled %d career history entries for %s",
len(sorted_days), child.name, len(sorted_days),
child.name,
) )
if needs_save: if needs_save:
@@ -701,9 +723,7 @@ class TaskMateCoordinator(
async def _async_sweep_orphan_photos(self) -> None: async def _async_sweep_orphan_photos(self) -> None:
"""Delete evidence photos not referenced by any completion (SEC-2).""" """Delete evidence photos not referenced by any completion (SEC-2)."""
referenced = [ referenced = [
getattr(c, "photo_url", "") getattr(c, "photo_url", "") for c in self.storage.get_completions() if getattr(c, "photo_url", "")
for c in self.storage.get_completions()
if getattr(c, "photo_url", "")
] ]
removed = await photos.async_sweep_orphan_photos(self.hass, referenced) removed = await photos.async_sweep_orphan_photos(self.hass, referenced)
if removed: if removed:
@@ -804,10 +824,34 @@ class TaskMateCoordinator(
self.storage.remove_career_score_history_for_child(child_id) self.storage.remove_career_score_history_for_child(child_id)
self.storage.remove_quest_progress_for_child(child_id) self.storage.remove_quest_progress_for_child(child_id)
self.storage.remove_challenge_progress_for_child(child_id) self.storage.remove_challenge_progress_for_child(child_id)
# Remove child from chore assigned_to lists # 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)
# 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.
for chore in self.storage.get_chores(): for chore in self.storage.get_chores():
dirty = False
if child_id in chore.assigned_to: if child_id in chore.assigned_to:
chore.assigned_to.remove(child_id) chore.assigned_to.remove(child_id)
dirty = True
if getattr(chore, "assignment_swap_child_id", "") == child_id:
chore.assignment_swap_child_id = ""
chore.assignment_swap_date = ""
dirty = True
if dirty:
self.storage.update_chore(chore)
# A rotation pointer left on the deleted child hides the chore from
# everyone (#787): the child sensor includes a non-everyone chore only
# when assignment_current_child_id matches, and a deleted id matches
# nobody. Repoint at today's live pool rather than just blanking it, so
# the chore reappears for the survivors now instead of at midnight.
stale = [c for c in self.storage.get_chores() if getattr(c, "assignment_current_child_id", "") == child_id]
if stale:
daily = self._compute_daily_assignments()
for chore in stale:
chore.assignment_current_child_id = daily.get(chore.id, "")
self.storage.update_chore(chore) self.storage.update_chore(chore)
await self.storage.async_save() await self.storage.async_save()
await self.async_refresh() await self.async_refresh()
+22 -17
View File
@@ -1,4 +1,5 @@
"""Frontend registration for TaskMate custom cards.""" """Frontend registration for TaskMate custom cards."""
from __future__ import annotations from __future__ import annotations
import json import json
@@ -56,8 +57,8 @@ CARDS: Final = [
# why blanket stale-cleanup was removed from async_register_cards). # why blanket stale-cleanup was removed from async_register_cards).
RETIRED_CARDS: Final = [ RETIRED_CARDS: Final = [
"taskmate-task-groups-card.js", # removed #452 "taskmate-task-groups-card.js", # removed #452
"taskmate-templates-card.js", # removed #448 "taskmate-templates-card.js", # removed #448
"taskmate-reminders-card.js", # removed #450 "taskmate-reminders-card.js", # removed #450
] ]
# JS modules loaded on every HA frontend page (config flow sound preview). # JS modules loaded on every HA frontend page (config flow sound preview).
@@ -86,9 +87,7 @@ async def _async_get_version(hass: HomeAssistant) -> str:
"""Get version from manifest.json for cache busting (async-safe).""" """Get version from manifest.json for cache busting (async-safe)."""
manifest_path = Path(__file__).parent / "manifest.json" manifest_path = Path(__file__).parent / "manifest.json"
try: try:
content = await hass.async_add_executor_job( content = await hass.async_add_executor_job(manifest_path.read_text, "utf-8")
manifest_path.read_text, "utf-8"
)
return json.loads(content).get("version", "1.0.0") return json.loads(content).get("version", "1.0.0")
except (OSError, json.JSONDecodeError, AttributeError): except (OSError, json.JSONDecodeError, AttributeError):
return "1.0.0" return "1.0.0"
@@ -107,23 +106,31 @@ async def async_register_frontend(hass: HomeAssistant) -> None:
_LOGGER.warning("www directory not found at %s", www_path) _LOGGER.warning("www directory not found at %s", www_path)
return return
# Register the www folder as a static path # Register the www folder as a static path.
await hass.http.async_register_static_paths( #
[StaticPathConfig(URL_BASE, str(www_path), False)] # This call is what sets our minimum Home Assistant version. Both
) # async_register_static_paths and StaticPathConfig landed in 2024.7.0 and do
# not exist in 2024.6.0, so on anything older setup dies here with an
# AttributeError rather than degrading — hence the 2024.7.0 floor in
# hacs.json and README.md. Lowering that floor means adding a fallback to
# the old (since-removed) hass.http.register_static_path.
await hass.http.async_register_static_paths([StaticPathConfig(URL_BASE, str(www_path), False)])
_LOGGER.debug("Registered static path: %s -> %s", URL_BASE, www_path) _LOGGER.debug("Registered static path: %s -> %s", URL_BASE, www_path)
# Authenticated upload/serve endpoints for chore evidence photos. # Authenticated upload/serve endpoints for chore evidence photos.
from .http_photos import async_register_photo_views from .http_photos import async_register_photo_views
async_register_photo_views(hass) async_register_photo_views(hass)
# Admin-gated upload / authenticated serve for chore pictures (#750). # Admin-gated upload / authenticated serve for chore pictures (#750).
from .http_images import async_register_image_views from .http_images import async_register_image_views
async_register_image_views(hass) async_register_image_views(hass)
# Token-gated ICS calendar feed (FEAT-10). # Token-gated ICS calendar feed (FEAT-10).
from .http_calendar import async_register_calendar_view from .http_calendar import async_register_calendar_view
async_register_calendar_view(hass) async_register_calendar_view(hass)
# Register global JS modules (loaded on all pages, including config flow) # Register global JS modules (loaded on all pages, including config flow)
@@ -205,9 +212,7 @@ async def async_register_cards(hass: HomeAssistant) -> None:
if card_url not in existing: if card_url not in existing:
# Card not registered yet — add it # Card not registered yet — add it
await resources.async_create_item( await resources.async_create_item({"url": versioned_url, "res_type": "module"})
{"url": versioned_url, "res_type": "module"}
)
_LOGGER.info("TaskMate: added resource: %s", versioned_url) _LOGGER.info("TaskMate: added resource: %s", versioned_url)
else: else:
item = existing[card_url] item = existing[card_url]
@@ -220,7 +225,8 @@ async def async_register_cards(hass: HomeAssistant) -> None:
) )
_LOGGER.info( _LOGGER.info(
"TaskMate: updated resource: %s -> %s", "TaskMate: updated resource: %s -> %s",
current_url, versioned_url, current_url,
versioned_url,
) )
else: else:
_LOGGER.debug("TaskMate: resource up to date: %s", versioned_url) _LOGGER.debug("TaskMate: resource up to date: %s", versioned_url)
@@ -237,13 +243,12 @@ async def async_register_cards(hass: HomeAssistant) -> None:
continue continue
try: try:
await resources.async_delete_item(item["id"]) await resources.async_delete_item(item["id"])
_LOGGER.info( _LOGGER.info("TaskMate: removed retired resource: %s", item.get("url"))
"TaskMate: removed retired resource: %s", item.get("url")
)
except (AttributeError, KeyError, TypeError, OSError) as err: except (AttributeError, KeyError, TypeError, OSError) as err:
_LOGGER.warning( _LOGGER.warning(
"TaskMate: could not remove retired resource %s: %s", "TaskMate: could not remove retired resource %s: %s",
item.get("url"), err, item.get("url"),
err,
) )
except (AttributeError, KeyError, TypeError, OSError) as err: except (AttributeError, KeyError, TypeError, OSError) as err:
@@ -5,6 +5,7 @@ is public-by-URL and authenticated by an unguessable per-instance token in the
``?token=`` query param (compared in constant time). It serves a read-only feed ``?token=`` query param (compared in constant time). It serves a read-only feed
of upcoming chores; no mutation is possible. of upcoming chores; no mutation is possible.
""" """
from __future__ import annotations from __future__ import annotations
import hmac import hmac
@@ -28,6 +29,7 @@ HTTP_CAL_REGISTERED = "calendar_http_registered"
def _get_coordinator(hass: HomeAssistant): def _get_coordinator(hass: HomeAssistant):
from .coordinator import TaskMateCoordinator from .coordinator import TaskMateCoordinator
for value in hass.data.get(DOMAIN, {}).values(): for value in hass.data.get(DOMAIN, {}).values():
if isinstance(value, TaskMateCoordinator): if isinstance(value, TaskMateCoordinator):
return value return value
+6 -15
View File
@@ -8,6 +8,7 @@ Two views, both auth-gated by ``HomeAssistantView`` (so photos are never public)
Pure path/validation logic lives in :mod:`.photos` (unit-tested); this module is Pure path/validation logic lives in :mod:`.photos` (unit-tested); this module is
the thin aiohttp wrapper, verified on the dev HA instance. the thin aiohttp wrapper, verified on the dev HA instance.
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
@@ -37,9 +38,7 @@ class TaskMatePhotoUploadView(HomeAssistantView):
async def post(self, request: web.Request) -> web.Response: async def post(self, request: web.Request) -> web.Response:
# Cheap pre-check on the declared length before reading the body. # Cheap pre-check on the declared length before reading the body.
if request.content_length and request.content_length > photos.MAX_UPLOAD_BYTES: if request.content_length and request.content_length > photos.MAX_UPLOAD_BYTES:
return self.json_message( return self.json_message("File too large", HTTPStatus.REQUEST_ENTITY_TOO_LARGE)
"File too large", HTTPStatus.REQUEST_ENTITY_TOO_LARGE
)
try: try:
reader = await request.multipart() reader = await request.multipart()
@@ -61,22 +60,16 @@ class TaskMatePhotoUploadView(HomeAssistantView):
break break
data.extend(chunk) data.extend(chunk)
if len(data) > photos.MAX_UPLOAD_BYTES: if len(data) > photos.MAX_UPLOAD_BYTES:
return self.json_message( return self.json_message("File too large", HTTPStatus.REQUEST_ENTITY_TOO_LARGE)
"File too large", HTTPStatus.REQUEST_ENTITY_TOO_LARGE
)
ext = photos.detect_image_ext(bytes(data)) ext = photos.detect_image_ext(bytes(data))
if ext is None: if ext is None:
return self.json_message("Not a valid image", HTTPStatus.BAD_REQUEST) return self.json_message("Not a valid image", HTTPStatus.BAD_REQUEST)
# DoS guard: reject if the photo store is already at its disk budget. # DoS guard: reject if the photo store is already at its disk budget.
used = await self.hass.async_add_executor_job( used = await self.hass.async_add_executor_job(photos.total_photos_bytes, self.hass)
photos.total_photos_bytes, self.hass
)
if used + len(data) > photos.MAX_TOTAL_BYTES: if used + len(data) > photos.MAX_TOTAL_BYTES:
return self.json_message( return self.json_message("Photo storage full", HTTPStatus.INSUFFICIENT_STORAGE)
"Photo storage full", HTTPStatus.INSUFFICIENT_STORAGE
)
name = f"{uuid.uuid4().hex}.{ext}" name = f"{uuid.uuid4().hex}.{ext}"
directory = photos.photos_path(self.hass) directory = photos.photos_path(self.hass)
@@ -90,9 +83,7 @@ class TaskMatePhotoUploadView(HomeAssistantView):
await self.hass.async_add_executor_job(_write) await self.hass.async_add_executor_job(_write)
except OSError as err: except OSError as err:
_LOGGER.error("Failed to store evidence photo: %s", err) _LOGGER.error("Failed to store evidence photo: %s", err)
return self.json_message( return self.json_message("Could not store photo", HTTPStatus.INTERNAL_SERVER_ERROR)
"Could not store photo", HTTPStatus.INTERNAL_SERVER_ERROR
)
return self.json({"photo_url": f"{photos.URL_PREFIX}/{name}"}) return self.json({"photo_url": f"{photos.URL_PREFIX}/{name}"})
+24 -21
View File
@@ -4,6 +4,7 @@ Pure-ish helpers that turn the chore calendar projection into an RFC 5545 feed a
calendar app (Google/Apple/Outlook) can subscribe to. Token auth + the HTTP view calendar app (Google/Apple/Outlook) can subscribe to. Token auth + the HTTP view
live in ``http_calendar.py``; this module only builds text. live in ``http_calendar.py``; this module only builds text.
""" """
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
@@ -14,13 +15,7 @@ PRODID = "-//TaskMate//Chores//EN"
def _escape(text: str) -> str: def _escape(text: str) -> str:
"""Escape a value per RFC 5545 (backslash, comma, semicolon, newline).""" """Escape a value per RFC 5545 (backslash, comma, semicolon, newline)."""
return ( return str(text).replace("\\", "\\\\").replace("\n", "\\n").replace(",", "\\,").replace(";", "\\;")
str(text)
.replace("\\", "\\\\")
.replace("\n", "\\n")
.replace(",", "\\,")
.replace(";", "\\;")
)
def _fold(line: str) -> str: def _fold(line: str) -> str:
@@ -97,31 +92,39 @@ def build_chore_events(coordinator, start_day: date, end_day: date) -> list[dict
for chore in chores: for chore in chores:
if not _chore_applies_to_child(coordinator, chore, child.id, day): if not _chore_applies_to_child(coordinator, chore, child.id, day):
continue continue
window = coordinator._time_category_window( window = coordinator._time_category_window(getattr(chore, "time_category", "anytime"), day)
getattr(chore, "time_category", "anytime"), day
)
summary = f"{chore.name}{child.name}" summary = f"{chore.name}{child.name}"
desc = _chore_description(chore) desc = _chore_description(chore)
if window is None: if window is None:
events.append({ events.append(
"uid": make_uid(chore.id, child.id, day.isoformat(), "allday"), {
"summary": summary, "description": desc, "uid": make_uid(chore.id, child.id, day.isoformat(), "allday"),
"start": day, "end": day + timedelta(days=1), "all_day": True, "summary": summary,
}) "description": desc,
"start": day,
"end": day + timedelta(days=1),
"all_day": True,
}
)
else: else:
start_dt, end_dt = window start_dt, end_dt = window
events.append({ events.append(
"uid": make_uid(chore.id, child.id, day.isoformat(), "timed"), {
"summary": summary, "description": desc, "uid": make_uid(chore.id, child.id, day.isoformat(), "timed"),
"start": _ensure_aware(start_dt), "summary": summary,
"end": _ensure_aware(end_dt), "all_day": False, "description": desc,
}) "start": _ensure_aware(start_dt),
"end": _ensure_aware(end_dt),
"all_day": False,
}
)
day += timedelta(days=1) day += timedelta(days=1)
return events return events
def _ensure_aware(dt: datetime) -> datetime: def _ensure_aware(dt: datetime) -> datetime:
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
if dt.tzinfo is None: if dt.tzinfo is None:
return dt.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE) return dt.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE)
return dt return dt
+2
View File
@@ -8,6 +8,7 @@ the example sentences in ``custom_sentences/<lang>/taskmate.yaml`` into your HA
config (see custom_sentences/README.md). The speech-building logic is kept in config (see custom_sentences/README.md). The speech-building logic is kept in
pure helpers so it is unit-testable without the conversation stack. pure helpers so it is unit-testable without the conversation stack.
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
@@ -27,6 +28,7 @@ INTENT_POINTS = "TaskMatePoints"
def _get_coordinator(hass: HomeAssistant): def _get_coordinator(hass: HomeAssistant):
from .coordinator import TaskMateCoordinator from .coordinator import TaskMateCoordinator
for value in hass.data.get(DOMAIN, {}).values(): for value in hass.data.get(DOMAIN, {}).values():
if isinstance(value, TaskMateCoordinator): if isinstance(value, TaskMateCoordinator):
return value return value
+1 -1
View File
@@ -17,5 +17,5 @@
"iot_class": "calculated", "iot_class": "calculated",
"issue_tracker": "https://github.com/tempus2016/taskmate/issues", "issue_tracker": "https://github.com/tempus2016/taskmate/issues",
"requirements": [], "requirements": [],
"version": "5.1.0" "version": "5.1.1"
} }
+32 -16
View File
@@ -1,4 +1,5 @@
"""Data models for TaskMate integration.""" """Data models for TaskMate integration."""
from __future__ import annotations from __future__ import annotations
import logging import logging
@@ -24,6 +25,7 @@ def generate_id() -> str:
def dt_util_now_iso() -> str: def dt_util_now_iso() -> str:
"""Current local time as an ISO string (module-level so dataclass defaults can use it).""" """Current local time as an ISO string (module-level so dataclass defaults can use it)."""
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
return dt_util.now().isoformat() return dt_util.now().isoformat()
@@ -172,7 +174,7 @@ class Child:
notify_service: str | None = None notify_service: str | None = None
linked_user_id: str = "" # HA user id; when set, only that user (or an admin) may self-serve as this 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_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 quiet_hours_end: str = "" # "HH:MM" — end of do-not-disturb window; start>end means overnight
level: int = 1 # cached XP level (derived from total_points_earned) level: int = 1 # cached XP level (derived from total_points_earned)
# Guest profiles (#690): a visiting cousin gets a temporary child that # Guest profiles (#690): a visiting cousin gets a temporary child that
# expires on its own and stays out of the family leaderboard. # expires on its own and stays out of the family leaderboard.
@@ -265,7 +267,9 @@ class Chore:
# Optional uploaded photograph (#750). Takes precedence over `icon` at # Optional uploaded photograph (#750). Takes precedence over `icon` at
# every render site; stored as a /api/taskmate/image/<name> URL. # every render site; stored as a /api/taskmate/image/<name> URL.
image_url: str = "" image_url: str = ""
difficulty: str = "medium" # easy | medium | hard — scales awarded points by the tier multiplier (medium = ×1.0 baseline) difficulty: str = (
"medium" # easy | medium | hard — scales awarded points by the tier multiplier (medium = ×1.0 baseline)
)
# Scheduling # Scheduling
# schedule_mode: "specific_days" = show on selected days of week (Mode A) # schedule_mode: "specific_days" = show on selected days of week (Mode A)
# "recurring" = rolling window recurrence (Mode B) # "recurring" = rolling window recurrence (Mode B)
@@ -273,7 +277,7 @@ class Chore:
due_days: list[str] = field(default_factory=list) # Mode A: days to show chore due_days: list[str] = field(default_factory=list) # Mode A: days to show chore
# Mode B fields # Mode B fields
recurrence: str = "weekly" # every_2_days | weekly | every_2_weeks | monthly | every_3_months | every_6_months recurrence: str = "weekly" # every_2_days | weekly | every_2_weeks | monthly | every_3_months | every_6_months
recurrence_day: str = "" # optional: which day of week for weekly/every_2_weeks recurrence_day: str = "" # optional: which day of week for weekly/every_2_weeks
recurrence_start: str = "" # optional: ISO date anchor for every_2_days recurrence_start: str = "" # optional: ISO date anchor for every_2_days
first_occurrence_mode: str = "available_immediately" # available_immediately | wait_for_first_occurrence first_occurrence_mode: str = "available_immediately" # available_immediately | wait_for_first_occurrence
# Dynamic visibility # Dynamic visibility
@@ -293,7 +297,9 @@ class Chore:
# One-shot chore fields # One-shot chore fields
enabled: bool = True # False = soft-disabled (completed or expired) enabled: bool = True # False = soft-disabled (completed or expired)
disabled_for: list[str] = field(default_factory=list) # Child IDs this chore is disabled for disabled_for: list[str] = field(default_factory=list) # Child IDs this chore is disabled for
depends_on: list[str] = field(default_factory=list) # Chore IDs that must be approved-completed today before this is available depends_on: list[str] = field(
default_factory=list
) # Chore IDs that must be approved-completed today before this is available
created_date: str = "" # ISO date for one-shot expiry, e.g. "2026-04-16" created_date: str = "" # ISO date for one-shot expiry, e.g. "2026-04-16"
expires_on: str = "" # optional ISO end date; chore auto-disables the day after expires_on: str = "" # optional ISO end date; chore auto-disables the day after
# Reactive chores (#674): a short-lived chore raised by an automation, e.g. # Reactive chores (#674): a short-lived chore raised by an automation, e.g.
@@ -316,11 +322,20 @@ class Chore:
# Dynamic assignment (sibling rotation) # Dynamic assignment (sibling rotation)
assignment_mode: str = "everyone" # everyone | alternating | random assignment_mode: str = "everyone" # everyone | alternating | random
assignment_rotation_anchor: str = "" # ISO date; day-0 of the rotation for alternating assignment_rotation_anchor: str = "" # ISO date; day-0 of the rotation for alternating
assignment_current_child_id: str = "" # cached active child ID for today (computed at midnight and on create/update) assignment_current_child_id: str = (
"" # cached active child ID for today (computed at midnight and on create/update)
)
require_availability: bool = False # When True, skip children whose availability entity says they're unavailable require_availability: bool = False # When True, skip children whose availability entity says they're unavailable
# Skip state (ephemeral: cleared at midnight when skip_date != today) # Skip state (ephemeral: cleared at midnight when skip_date != today)
skip_date: str = "" # ISO date the skip applies to ("" = no active skip) skip_date: str = "" # ISO date the skip applies to ("" = no active skip)
skip_count: int = 0 # number of times skipped today; added to rotation index skip_count: int = 0 # number of times skipped today; added to rotation index
# Approved sibling swap (ephemeral, same shape as the skip pair above).
# This — not assignment_current_child_id — is the override every read-time
# eligibility gate consults, because assignment_current_child_id is itself
# *written from* _compute_active_children at midnight; reading it back there
# would pin the rotation to whoever it last cached (#781).
assignment_swap_child_id: str = "" # child the chore was swapped to
assignment_swap_date: str = "" # ISO date the swap applies to ("" = no swap)
# Calendar publish: list of HA calendar entity ids to mirror the chore to. Any number supported. # Calendar publish: list of HA calendar entity ids to mirror the chore to. Any number supported.
publish_calendar_entities: list[str] = field(default_factory=list) publish_calendar_entities: list[str] = field(default_factory=list)
# ISO dates already written to the configured calendars. Used for both # ISO dates already written to the configured calendars. Used for both
@@ -392,6 +407,8 @@ class Chore:
require_availability=data.get("require_availability", False), require_availability=data.get("require_availability", False),
skip_date=data.get("skip_date", ""), skip_date=data.get("skip_date", ""),
skip_count=int(data.get("skip_count", 0) or 0), skip_count=int(data.get("skip_count", 0) or 0),
assignment_swap_child_id=data.get("assignment_swap_child_id", ""),
assignment_swap_date=data.get("assignment_swap_date", ""),
publish_calendar_entities=list(data.get("publish_calendar_entities", [])), publish_calendar_entities=list(data.get("publish_calendar_entities", [])),
# Back-compat: old records stored a single ISO date in # Back-compat: old records stored a single ISO date in
# `publish_calendar_last_date`. Seed the new list with it so we # `publish_calendar_last_date`. Seed the new list with it so we
@@ -456,6 +473,8 @@ class Chore:
"require_availability": self.require_availability, "require_availability": self.require_availability,
"skip_date": self.skip_date, "skip_date": self.skip_date,
"skip_count": self.skip_count, "skip_count": self.skip_count,
"assignment_swap_child_id": self.assignment_swap_child_id,
"assignment_swap_date": self.assignment_swap_date,
"publish_calendar_entities": self.publish_calendar_entities, "publish_calendar_entities": self.publish_calendar_entities,
"publish_calendar_published_dates": self.publish_calendar_published_dates, "publish_calendar_published_dates": self.publish_calendar_published_dates,
"bonus_subtasks": [b.to_dict() for b in self.bonus_subtasks], "bonus_subtasks": [b.to_dict() for b in self.bonus_subtasks],
@@ -561,7 +580,7 @@ class Quest:
name: str name: str
description: str = "" description: str = ""
icon: str = "mdi:map-marker-path" icon: str = "mdi:map-marker-path"
steps: list[str] = field(default_factory=list) # ordered chore IDs steps: list[str] = field(default_factory=list) # ordered chore IDs
bonus_points: int = 25 bonus_points: int = 25
assigned_to: list[str] = field(default_factory=list) # child IDs; empty = all assigned_to: list[str] = field(default_factory=list) # child IDs; empty = all
repeatable: bool = False repeatable: bool = False
@@ -609,8 +628,8 @@ class Challenge:
name: str name: str
description: str = "" description: str = ""
icon: str = "mdi:trophy-outline" icon: str = "mdi:trophy-outline"
scope: str = "daily" # daily | weekly scope: str = "daily" # daily | weekly
metric: str = "chores" # chores | points metric: str = "chores" # chores | points
target: int = 3 target: int = 3
bonus_points: int = 15 bonus_points: int = 15
assigned_to: list[str] = field(default_factory=list) # child IDs; empty = all assigned_to: list[str] = field(default_factory=list) # child IDs; empty = all
@@ -708,8 +727,8 @@ class MandatoryMiss:
chore_id: str chore_id: str
child_id: str child_id: str
due_date: str # ISO date the chore was missed due_date: str # ISO date the chore was missed
period_id: str # the window that closed ("anytime" for all-day) period_id: str # the window that closed ("anytime" for all-day)
penalty_points: int = 0 penalty_points: int = 0
postpone_count: int = 0 postpone_count: int = 0
escalation_stage: int = 0 # 0=none 1=nudged 2=reminded 3=parent-alerted (FEAT-6) escalation_stage: int = 0 # 0=none 1=nudged 2=reminded 3=parent-alerted (FEAT-6)
@@ -1207,10 +1226,7 @@ class NotificationConfig:
return cls( return cls(
type_id=data.get("type_id", ""), type_id=data.get("type_id", ""),
master_enabled=bool(data.get("master_enabled", False)), master_enabled=bool(data.get("master_enabled", False)),
routes={ routes={rid: NotificationRoute.from_dict(rdata) for rid, rdata in raw_routes.items()},
rid: NotificationRoute.from_dict(rdata)
for rid, rdata in raw_routes.items()
},
nav_url=data.get("nav_url", "") or "", nav_url=data.get("nav_url", "") or "",
) )
@@ -1231,8 +1247,8 @@ class CustomNotification:
name: str name: str
message_template: str message_template: str
time: str # "HH:MM" time: str # "HH:MM"
day_mask: int = 0b1111111 # bit0=Mon … bit6=Sun day_mask: int = 0b1111111 # bit0=Mon … bit6=Sun
recipient_ids: list[str] = field(default_factory=list) recipient_ids: list[str] = field(default_factory=list)
enabled: bool = True enabled: bool = True
id: str = field(default_factory=generate_id) id: str = field(default_factory=generate_id)
+2 -3
View File
@@ -4,6 +4,7 @@ Lets the points-bearing knobs be read and changed from automations/scripts and
the HA UI without a service call. Values are persisted through the same the HA UI without a service call. Values are persisted through the same
settings store the panel uses, so panel and entity stay in sync. settings store the panel uses, so panel and entity stay in sync.
""" """
from __future__ import annotations from __future__ import annotations
from homeassistant.components.number import NumberEntity, NumberMode from homeassistant.components.number import NumberEntity, NumberMode
@@ -23,9 +24,7 @@ _NUMBERS = [
] ]
async def async_setup_entry( async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None:
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up the TaskMate setting-number entities.""" """Set up the TaskMate setting-number entities."""
coordinator: TaskMateCoordinator = hass.data[DOMAIN][entry.entry_id] coordinator: TaskMateCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(TaskMateSettingNumber(coordinator, entry, *cfg) for cfg in _NUMBERS) async_add_entities(TaskMateSettingNumber(coordinator, entry, *cfg) for cfg in _NUMBERS)
+1
View File
@@ -1,4 +1,5 @@
"""Sidebar panel registration for the TaskMate admin UI.""" """Sidebar panel registration for the TaskMate admin UI."""
from __future__ import annotations from __future__ import annotations
import json import json
+4 -6
View File
@@ -7,6 +7,7 @@ real HA install. The aiohttp views live in ``http_photos.py``.
Photos are stored as ``<32 hex>.<ext>`` under ``<config>/taskmate_photos`` and Photos are stored as ``<32 hex>.<ext>`` under ``<config>/taskmate_photos`` and
served (auth-gated) at ``/api/taskmate/photo/<name>``. served (auth-gated) at ``/api/taskmate/photo/<name>``.
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
@@ -86,7 +87,7 @@ def is_taskmate_photo_url(photo_url: str) -> bool:
prefix = URL_PREFIX + "/" prefix = URL_PREFIX + "/"
if not photo_url.startswith(prefix): if not photo_url.startswith(prefix):
return False return False
return bool(FILENAME_RE.match(photo_url[len(prefix):])) return bool(FILENAME_RE.match(photo_url[len(prefix) :]))
def photo_file_for_url(hass, photo_url: str) -> Path | None: def photo_file_for_url(hass, photo_url: str) -> Path | None:
@@ -101,7 +102,7 @@ def photo_file_for_url(hass, photo_url: str) -> Path | None:
prefix = URL_PREFIX + "/" prefix = URL_PREFIX + "/"
if not photo_url.startswith(prefix): if not photo_url.startswith(prefix):
return None return None
name = photo_url[len(prefix):] name = photo_url[len(prefix) :]
if not FILENAME_RE.match(name): if not FILENAME_RE.match(name):
return None return None
return photos_path(hass) / name return photos_path(hass) / name
@@ -181,10 +182,7 @@ async def async_sweep_orphan_photos(hass, referenced_urls, max_age_hours: int =
Returns the number of files deleted. Returns the number of files deleted.
""" """
prefix = URL_PREFIX + "/" prefix = URL_PREFIX + "/"
referenced = { referenced = {url[len(prefix) :] for url in referenced_urls if url and url.startswith(prefix)}
url[len(prefix):] for url in referenced_urls
if url and url.startswith(prefix)
}
directory = photos_path(hass) directory = photos_path(hass)
def _sweep() -> int: def _sweep() -> int:
+9 -4
View File
@@ -1,4 +1,5 @@
"""Select platform — expose key choice TaskMate settings as entities (FEAT-9).""" """Select platform — expose key choice TaskMate settings as entities (FEAT-9)."""
from __future__ import annotations from __future__ import annotations
from homeassistant.components.select import SelectEntity from homeassistant.components.select import SelectEntity
@@ -14,13 +15,17 @@ from .coordinator import TaskMateCoordinator
# (setting_key, translation_key, options, default, icon) # (setting_key, translation_key, options, default, icon)
_SELECTS = [ _SELECTS = [
("streak_reset_mode", "streak_reset_mode", ["reset", "pause"], "reset", "mdi:restart"), ("streak_reset_mode", "streak_reset_mode", ["reset", "pause"], "reset", "mdi:restart"),
("card_design", "card_design", ["classic", "playroom", "console", "cleanpro", "accessible"], "classic", "mdi:palette"), (
"card_design",
"card_design",
["classic", "playroom", "console", "cleanpro", "accessible"],
"classic",
"mdi:palette",
),
] ]
async def async_setup_entry( async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None:
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up the TaskMate setting-select entities.""" """Set up the TaskMate setting-select entities."""
coordinator: TaskMateCoordinator = hass.data[DOMAIN][entry.entry_id] coordinator: TaskMateCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(TaskMateSettingSelect(coordinator, entry, *cfg) for cfg in _SELECTS) async_add_entities(TaskMateSettingSelect(coordinator, entry, *cfg) for cfg in _SELECTS)
+299 -269
View File
@@ -1,4 +1,5 @@
"""Sensor platform for TaskMate integration.""" """Sensor platform for TaskMate integration."""
from __future__ import annotations from __future__ import annotations
import logging import logging
@@ -85,9 +86,7 @@ def _compute_common(coordinator: TaskMateCoordinator) -> dict:
for comp in pending_completions: for comp in pending_completions:
chore = chore_lookup.get(comp.chore_id) chore = chore_lookup.get(comp.chore_id)
if chore: if chore:
pending_points_by_child[comp.child_id] = ( pending_points_by_child[comp.child_id] = pending_points_by_child.get(comp.child_id, 0) + chore.points
pending_points_by_child.get(comp.child_id, 0) + chore.points
)
# Committed points per child (reward claims awaiting approval = points reserved). # Committed points per child (reward claims awaiting approval = points reserved).
# Pool-mode pending claims are skipped because their cost was already deducted # Pool-mode pending claims are skipped because their cost was already deducted
@@ -98,9 +97,7 @@ def _compute_common(coordinator: TaskMateCoordinator) -> dict:
continue continue
reward = reward_lookup.get(rc.reward_id) reward = reward_lookup.get(rc.reward_id)
if reward: if reward:
committed_points_by_child[rc.child_id] = ( committed_points_by_child[rc.child_id] = committed_points_by_child.get(rc.child_id, 0) + reward.cost
committed_points_by_child.get(rc.child_id, 0) + reward.cost
)
# Pool allocation lookups for v3.0 pool mode. # Pool allocation lookups for v3.0 pool mode.
pool_by_child_reward: dict[str, dict[str, int]] = {} pool_by_child_reward: dict[str, dict[str, int]] = {}
@@ -108,12 +105,8 @@ def _compute_common(coordinator: TaskMateCoordinator) -> dict:
total_allocated_by_child: dict[str, int] = {} total_allocated_by_child: dict[str, int] = {}
for pa in pool_alloc_objs: for pa in pool_alloc_objs:
pool_by_child_reward.setdefault(pa.child_id, {})[pa.reward_id] = pa.allocated_points pool_by_child_reward.setdefault(pa.child_id, {})[pa.reward_id] = pa.allocated_points
pool_total_by_reward[pa.reward_id] = ( pool_total_by_reward[pa.reward_id] = pool_total_by_reward.get(pa.reward_id, 0) + pa.allocated_points
pool_total_by_reward.get(pa.reward_id, 0) + pa.allocated_points total_allocated_by_child[pa.child_id] = total_allocated_by_child.get(pa.child_id, 0) + pa.allocated_points
)
total_allocated_by_child[pa.child_id] = (
total_allocated_by_child.get(pa.child_id, 0) + pa.allocated_points
)
common = { common = {
"data_id": data_id, "data_id": data_id,
@@ -151,51 +144,57 @@ def _build_children_summary(coordinator: TaskMateCoordinator, common: dict) -> l
for c in children: for c in children:
committed_amount = committed.get(c.id, 0) committed_amount = committed.get(c.id, 0)
lvl = coordinator.level_info(c) lvl = coordinator.level_info(c)
summary.append({ summary.append(
"level": lvl["level"], {
"level_progress": lvl["progress"], "level": lvl["level"],
"level_target": lvl["target"], "level_progress": lvl["progress"],
"id": c.id, "level_target": lvl["target"],
"name": c.name, "id": c.id,
"points": c.points, "name": c.name,
"pending_points": pending.get(c.id, 0), "points": c.points,
# Guest profiles (#690): cards filter these out of competitive views. "pending_points": pending.get(c.id, 0),
**({"is_guest": True, "guest_expires_on": getattr(c, "guest_expires_on", "")} # Guest profiles (#690): cards filter these out of competitive views.
if getattr(c, "is_guest", False) else {}), **(
# Chore roulette (#677): today's pick + spins left, so the card can {"is_guest": True, "guest_expires_on": getattr(c, "guest_expires_on", "")}
# show the result and disable the button once the allowance is used. if getattr(c, "is_guest", False)
**( else {}
{ ),
"roulette": { # Chore roulette (#677): today's pick + spins left, so the card can
**(coordinator.roulette_selection(c.id) or {}), # show the result and disable the button once the allowance is used.
"spins_left": coordinator.roulette_spins_left(c.id), **(
{
"roulette": {
**(coordinator.roulette_selection(c.id) or {}),
"spins_left": coordinator.roulette_spins_left(c.id),
}
} }
} if coordinator.roulette_enabled()
if coordinator.roulette_enabled() else {} else {}
), ),
"committed_points": committed_amount, "committed_points": committed_amount,
"allocated_points": allocated.get(c.id, 0), "allocated_points": allocated.get(c.id, 0),
# Allocations were deducted from child.points already, so spendable # Allocations were deducted from child.points already, so spendable
# only needs to account for pending-claim commitments. # only needs to account for pending-claim commitments.
"spendable_balance": max(0, c.points - committed_amount), "spendable_balance": max(0, c.points - committed_amount),
"chore_order": c.chore_order, "chore_order": c.chore_order,
"current_streak": getattr(c, 'current_streak', 0) or 0, "current_streak": getattr(c, "current_streak", 0) or 0,
"best_streak": getattr(c, 'best_streak', 0) or 0, "best_streak": getattr(c, "best_streak", 0) or 0,
"season_points": int(season.get(c.id, 0)), "season_points": int(season.get(c.id, 0)),
"total_points_earned": getattr(c, 'total_points_earned', 0) or 0, "total_points_earned": getattr(c, "total_points_earned", 0) or 0,
"total_chores_completed": getattr(c, 'total_chores_completed', 0) or 0, "total_chores_completed": getattr(c, "total_chores_completed", 0) or 0,
"avatar": getattr(c, 'avatar', 'mdi:account-circle') or 'mdi:account-circle', "avatar": getattr(c, "avatar", "mdi:account-circle") or "mdi:account-circle",
"last_completion_date": getattr(c, 'last_completion_date', None), "last_completion_date": getattr(c, "last_completion_date", None),
"streak_paused": getattr(c, 'streak_paused', False), "streak_paused": getattr(c, "streak_paused", False),
"on_vacation": coordinator._is_child_on_vacation(c), "on_vacation": coordinator._is_child_on_vacation(c),
"streak_milestones_achieved": getattr(c, 'streak_milestones_achieved', None) or [], "streak_milestones_achieved": getattr(c, "streak_milestones_achieved", None) or [],
"awarded_perfect_weeks": getattr(c, 'awarded_perfect_weeks', None) or [], "awarded_perfect_weeks": getattr(c, "awarded_perfect_weeks", None) or [],
"career_score": getattr(c, 'career_score', 0) or 0, "career_score": getattr(c, "career_score", 0) or 0,
"total_penalties_received": getattr(c, 'total_penalties_received', 0) or 0, "total_penalties_received": getattr(c, "total_penalties_received", 0) or 0,
"quests": coordinator.quest_progress_for_child(c.id), "quests": coordinator.quest_progress_for_child(c.id),
"avatar_options": coordinator.avatar_options_for_child(c), "avatar_options": coordinator.avatar_options_for_child(c),
"challenges": coordinator.challenge_progress_for_child(c.id), "challenges": coordinator.challenge_progress_for_child(c.id),
}) }
)
return summary return summary
@@ -223,61 +222,61 @@ def _build_chores_list(coordinator: TaskMateCoordinator, common: dict) -> list[d
"time_category": c.time_category, "time_category": c.time_category,
"assigned_to": assigned_to, "assigned_to": assigned_to,
"depends_on": depends_on, "depends_on": depends_on,
"schedule_mode": getattr(c, 'schedule_mode', 'specific_days'), "schedule_mode": getattr(c, "schedule_mode", "specific_days"),
"enabled": getattr(c, 'enabled', True), "enabled": getattr(c, "enabled", True),
"assignment_mode": getattr(c, 'assignment_mode', 'everyone'), "assignment_mode": getattr(c, "assignment_mode", "everyone"),
} }
# Difficulty tier + the points it actually awards. Emitted only when # Difficulty tier + the points it actually awards. Emitted only when
# non-default (medium / ×1.0) so simple chores stay compact. # non-default (medium / ×1.0) so simple chores stay compact.
difficulty = getattr(c, 'difficulty', 'medium') or 'medium' difficulty = getattr(c, "difficulty", "medium") or "medium"
if difficulty != 'medium': if difficulty != "medium":
record["difficulty"] = difficulty record["difficulty"] = difficulty
effective_points = coordinator.effective_chore_points(c) effective_points = coordinator.effective_chore_points(c)
if effective_points != c.points: if effective_points != c.points:
record["effective_points"] = effective_points record["effective_points"] = effective_points
# Optional fields — emit only when non-default to save bytes. # Optional fields — emit only when non-default to save bytes.
description = getattr(c, 'description', '') or '' description = getattr(c, "description", "") or ""
if description: if description:
record["description"] = description record["description"] = description
daily_limit = getattr(c, 'daily_limit', 1) daily_limit = getattr(c, "daily_limit", 1)
if daily_limit != 1: if daily_limit != 1:
record["daily_limit"] = daily_limit record["daily_limit"] = daily_limit
claim_allowance_minutes = getattr(c, 'claim_allowance_minutes', 0) or 0 claim_allowance_minutes = getattr(c, "claim_allowance_minutes", 0) or 0
if claim_allowance_minutes: if claim_allowance_minutes:
record["claim_allowance_minutes"] = claim_allowance_minutes record["claim_allowance_minutes"] = claim_allowance_minutes
due_days = getattr(c, 'due_days', []) or [] due_days = getattr(c, "due_days", []) or []
if due_days: if due_days:
record["due_days"] = due_days record["due_days"] = due_days
requires_approval = getattr(c, 'requires_approval', True) requires_approval = getattr(c, "requires_approval", True)
if not requires_approval: if not requires_approval:
record["requires_approval"] = False record["requires_approval"] = False
# Mandatory chores (#532): emit only when set so the child card can # Mandatory chores (#532): emit only when set so the child card can
# show the mandatory styling/badge. penalty rides along when non-zero. # show the mandatory styling/badge. penalty rides along when non-zero.
if getattr(c, 'mandatory', False): if getattr(c, "mandatory", False):
record["mandatory"] = True record["mandatory"] = True
penalty = getattr(c, 'mandatory_penalty_points', 0) or 0 penalty = getattr(c, "mandatory_penalty_points", 0) or 0
if penalty: if penalty:
record["mandatory_penalty_points"] = penalty record["mandatory_penalty_points"] = penalty
if getattr(c, 'require_photo', False): if getattr(c, "require_photo", False):
record["require_photo"] = True record["require_photo"] = True
recurrence = getattr(c, 'recurrence', 'weekly') recurrence = getattr(c, "recurrence", "weekly")
if recurrence != 'weekly': if recurrence != "weekly":
record["recurrence"] = recurrence record["recurrence"] = recurrence
recurrence_day = getattr(c, 'recurrence_day', '') recurrence_day = getattr(c, "recurrence_day", "")
if recurrence_day: if recurrence_day:
record["recurrence_day"] = recurrence_day record["recurrence_day"] = recurrence_day
recurrence_start = getattr(c, 'recurrence_start', '') recurrence_start = getattr(c, "recurrence_start", "")
if recurrence_start: if recurrence_start:
record["recurrence_start"] = recurrence_start record["recurrence_start"] = recurrence_start
visibility_entity = getattr(c, 'visibility_entity', '') visibility_entity = getattr(c, "visibility_entity", "")
if visibility_entity: if visibility_entity:
record["visibility_entity"] = visibility_entity record["visibility_entity"] = visibility_entity
record["visibility_operator"] = getattr(c, 'visibility_operator', 'equals') record["visibility_operator"] = getattr(c, "visibility_operator", "equals")
record["visibility_state"] = getattr(c, 'visibility_state', 'on') record["visibility_state"] = getattr(c, "visibility_state", "on")
weather_entity = getattr(c, 'weather_entity', '') weather_entity = getattr(c, "weather_entity", "")
if weather_entity: if weather_entity:
record["weather_entity"] = weather_entity record["weather_entity"] = weather_entity
record["weather_block_conditions"] = list(getattr(c, 'weather_block_conditions', []) or []) record["weather_block_conditions"] = list(getattr(c, "weather_block_conditions", []) or [])
for limit in ("weather_temp_min", "weather_temp_max", "weather_wind_max"): for limit in ("weather_temp_min", "weather_temp_max", "weather_wind_max"):
value = getattr(c, limit, None) value = getattr(c, limit, None)
if value is not None: if value is not None:
@@ -287,44 +286,41 @@ def _build_chores_list(coordinator: TaskMateCoordinator, common: dict) -> list[d
reason = coordinator.weather_block_reason(c) reason = coordinator.weather_block_reason(c)
if reason: if reason:
record["weather_blocked"] = reason record["weather_blocked"] = reason
deadline_at = getattr(c, 'deadline_at', '') deadline_at = getattr(c, "deadline_at", "")
if deadline_at: if deadline_at:
record["deadline_at"] = deadline_at record["deadline_at"] = deadline_at
speed_bonus = getattr(c, 'speed_bonus_points', 0) speed_bonus = getattr(c, "speed_bonus_points", 0)
if speed_bonus: if speed_bonus:
record["speed_bonus_points"] = speed_bonus record["speed_bonus_points"] = speed_bonus
disabled_for = getattr(c, 'disabled_for', []) disabled_for = getattr(c, "disabled_for", [])
if disabled_for: if disabled_for:
record["disabled_for"] = disabled_for record["disabled_for"] = disabled_for
created_date = getattr(c, 'created_date', '') created_date = getattr(c, "created_date", "")
if created_date: if created_date:
record["created_date"] = created_date record["created_date"] = created_date
assignment_current_child_id = getattr(c, 'assignment_current_child_id', '') assignment_current_child_id = getattr(c, "assignment_current_child_id", "")
if assignment_current_child_id: if assignment_current_child_id:
record["assignment_current_child_id"] = assignment_current_child_id record["assignment_current_child_id"] = assignment_current_child_id
icon = getattr(c, 'icon', '') icon = getattr(c, "icon", "")
if icon: if icon:
record["icon"] = icon record["icon"] = icon
# Signed so the card's <img> loads; emitted only when set, matching # Signed so the card's <img> loads; emitted only when set, matching
# `icon` above, to keep records under the 16KB recorder limit. # `icon` above, to keep records under the 16KB recorder limit.
image_url = getattr(c, 'image_url', '') image_url = getattr(c, "image_url", "")
if image_url: if image_url:
record["image_url"] = images.sign_image_url(common["hass"], image_url) record["image_url"] = images.sign_image_url(common["hass"], image_url)
completion_sound = getattr(c, 'completion_sound', 'coin') completion_sound = getattr(c, "completion_sound", "coin")
if completion_sound and completion_sound != 'coin': if completion_sound and completion_sound != "coin":
record["completion_sound"] = completion_sound record["completion_sound"] = completion_sound
task_type = getattr(c, 'task_type', 'standard') task_type = getattr(c, "task_type", "standard")
if task_type == "timed": if task_type == "timed":
record["task_type"] = "timed" record["task_type"] = "timed"
record["timed_rate_points"] = getattr(c, 'timed_rate_points', 10) record["timed_rate_points"] = getattr(c, "timed_rate_points", 10)
record["timed_rate_minutes"] = getattr(c, 'timed_rate_minutes', 5) record["timed_rate_minutes"] = getattr(c, "timed_rate_minutes", 5)
record["timed_max_daily_minutes"] = getattr(c, 'timed_max_daily_minutes', 0) record["timed_max_daily_minutes"] = getattr(c, "timed_max_daily_minutes", 0)
bonus_subtasks = getattr(c, 'bonus_subtasks', []) bonus_subtasks = getattr(c, "bonus_subtasks", [])
if bonus_subtasks: if bonus_subtasks:
record["bonus_subtasks"] = [ record["bonus_subtasks"] = [{"id": b.id, "name": b.name, "points": b.points} for b in bonus_subtasks]
{"id": b.id, "name": b.name, "points": b.points}
for b in bonus_subtasks
]
chores_list.append(record) chores_list.append(record)
return chores_list return chores_list
@@ -356,9 +352,9 @@ def _build_todays_completions(common: dict) -> list[dict]:
out = [] out = []
for comp in common["all_completions"]: for comp in common["all_completions"]:
comp_dt = comp.completed_at comp_dt = comp.completed_at
if hasattr(comp_dt, 'astimezone'): if hasattr(comp_dt, "astimezone"):
comp_dt = dt_util.as_local(comp_dt) comp_dt = dt_util.as_local(comp_dt)
comp_date = comp_dt.date() if hasattr(comp_dt, 'date') else comp_dt comp_date = comp_dt.date() if hasattr(comp_dt, "date") else comp_dt
if comp_date != today: if comp_date != today:
continue continue
matched_chore = chore_lookup.get(comp.chore_id) matched_chore = chore_lookup.get(comp.chore_id)
@@ -379,11 +375,15 @@ def _build_todays_completions(common: dict) -> list[dict]:
"completion_id": comp.id, "completion_id": comp.id,
"chore_id": comp.chore_id, "chore_id": comp.chore_id,
"child_id": comp.child_id, "child_id": comp.child_id,
"child_name": "Parent" if comp.child_id == "__parent__" else (child_lookup[comp.child_id].name if comp.child_id in child_lookup else ""), "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": display_name, "chore_name": display_name,
"points": display_points, "points": display_points,
"approved": comp.approved, "approved": comp.approved,
"completed_at": comp.completed_at.isoformat() if hasattr(comp.completed_at, 'isoformat') else str(comp.completed_at), "completed_at": comp.completed_at.isoformat()
if hasattr(comp.completed_at, "isoformat")
else str(comp.completed_at),
"bonus_subtask_id": bonus_subtask_id, "bonus_subtask_id": bonus_subtask_id,
} }
if timed_secs > 0: if timed_secs > 0:
@@ -402,7 +402,7 @@ def _build_active_timed_sessions(coordinator: TaskMateCoordinator) -> list[dict]
sessions = coordinator.data.get("timed_sessions", []) sessions = coordinator.data.get("timed_sessions", [])
out = [] out = []
for s in sessions: for s in sessions:
if hasattr(s, 'state'): if hasattr(s, "state"):
state = s.state state = s.state
if state not in ("running", "paused"): if state not in ("running", "paused"):
continue continue
@@ -412,13 +412,15 @@ def _build_active_timed_sessions(coordinator: TaskMateCoordinator) -> list[dict]
last_seg = segments[-1] last_seg = segments[-1]
if isinstance(last_seg, dict) and last_seg.get("end") is None: if isinstance(last_seg, dict) and last_seg.get("end") is None:
current_segment_start = last_seg.get("start", "") current_segment_start = last_seg.get("start", "")
out.append({ out.append(
"chore_id": s.chore_id, {
"child_id": s.child_id, "chore_id": s.chore_id,
"state": state, "child_id": s.child_id,
"total_seconds_today": s.total_seconds_today, "state": state,
"current_segment_start": current_segment_start, "total_seconds_today": s.total_seconds_today,
}) "current_segment_start": current_segment_start,
}
)
else: else:
state = s.get("state", "stopped") if isinstance(s, dict) else "stopped" state = s.get("state", "stopped") if isinstance(s, dict) else "stopped"
if state not in ("running", "paused"): if state not in ("running", "paused"):
@@ -429,13 +431,15 @@ def _build_active_timed_sessions(coordinator: TaskMateCoordinator) -> list[dict]
last_seg = segments[-1] last_seg = segments[-1]
if isinstance(last_seg, dict) and last_seg.get("end") is None: if isinstance(last_seg, dict) and last_seg.get("end") is None:
current_segment_start = last_seg.get("start", "") current_segment_start = last_seg.get("start", "")
out.append({ out.append(
"chore_id": s.get("chore_id", "") if isinstance(s, dict) else "", {
"child_id": s.get("child_id", "") if isinstance(s, dict) else "", "chore_id": s.get("chore_id", "") if isinstance(s, dict) else "",
"state": state, "child_id": s.get("child_id", "") if isinstance(s, dict) else "",
"total_seconds_today": s.get("total_seconds_today", 0) if isinstance(s, dict) else 0, "state": state,
"current_segment_start": current_segment_start, "total_seconds_today": s.get("total_seconds_today", 0) if isinstance(s, dict) else 0,
}) "current_segment_start": current_segment_start,
}
)
return out return out
@@ -448,20 +452,12 @@ def _build_rewards_list(common: dict) -> list[dict]:
today = dt_util.now().date() today = dt_util.now().date()
out = [] out = []
for r in rewards: for r in rewards:
assigned = ( assigned = r.assigned_to if isinstance(r.assigned_to, list) and r.assigned_to else [c.id for c in children]
r.assigned_to
if isinstance(r.assigned_to, list) and r.assigned_to
else [c.id for c in children]
)
calculated_costs = {child_id: r.cost for child_id in assigned} calculated_costs = {child_id: r.cost for child_id in assigned}
reward_pool_allocations = { reward_pool_allocations = {cid: pool_by_child_reward.get(cid, {}).get(r.id, 0) for cid in assigned}
cid: pool_by_child_reward.get(cid, {}).get(r.id, 0) for cid in assigned jackpot_pool_total = pool_total_by_reward.get(r.id, 0) if getattr(r, "is_jackpot", False) else None
} quantity = getattr(r, "quantity", None)
jackpot_pool_total = ( expires_at = getattr(r, "expires_at", None)
pool_total_by_reward.get(r.id, 0) if getattr(r, 'is_jackpot', False) else None
)
quantity = getattr(r, 'quantity', None)
expires_at = getattr(r, 'expires_at', None)
is_sold_out = quantity is not None and quantity <= 0 is_sold_out = quantity is not None and quantity <= 0
is_expired = False is_expired = False
days_until_expiry: int | None = None days_until_expiry: int | None = None
@@ -472,25 +468,27 @@ def _build_rewards_list(common: dict) -> list[dict]:
days_until_expiry = (deadline - today).days days_until_expiry = (deadline - today).days
except (TypeError, ValueError): except (TypeError, ValueError):
pass pass
out.append({ out.append(
"id": r.id, {
"name": r.name, "id": r.id,
"cost": r.cost, "name": r.name,
"description": getattr(r, 'description', ''), "cost": r.cost,
"icon": r.icon, "description": getattr(r, "description", ""),
"assigned_to": r.assigned_to if isinstance(r.assigned_to, list) else [], "icon": r.icon,
"is_jackpot": getattr(r, 'is_jackpot', False), "assigned_to": r.assigned_to if isinstance(r.assigned_to, list) else [],
"pool_enabled": getattr(r, 'pool_enabled', False), "is_jackpot": getattr(r, "is_jackpot", False),
"calculated_costs": calculated_costs, "pool_enabled": getattr(r, "pool_enabled", False),
"pool_allocations": reward_pool_allocations, "calculated_costs": calculated_costs,
"jackpot_pool_total": jackpot_pool_total, "pool_allocations": reward_pool_allocations,
"quantity": quantity, "jackpot_pool_total": jackpot_pool_total,
"expires_at": expires_at, "quantity": quantity,
"is_sold_out": is_sold_out, "expires_at": expires_at,
"is_expired": is_expired, "is_sold_out": is_sold_out,
"is_available": not (is_sold_out or is_expired), "is_expired": is_expired,
"days_until_expiry": days_until_expiry, "is_available": not (is_sold_out or is_expired),
}) "days_until_expiry": days_until_expiry,
}
)
return out return out
@@ -504,17 +502,19 @@ def _build_pending_reward_claims(common: dict) -> list[dict]:
child = child_lookup.get(rc.child_id) child = child_lookup.get(rc.child_id)
if not reward or not child: if not reward or not child:
continue continue
out.append({ out.append(
"claim_id": rc.id, {
"reward_id": rc.reward_id, "claim_id": rc.id,
"child_id": rc.child_id, "reward_id": rc.reward_id,
"child_name": child.name, "child_id": rc.child_id,
"child_avatar": getattr(child, 'avatar', 'mdi:account-circle') or 'mdi:account-circle', "child_name": child.name,
"reward_name": reward.name, "child_avatar": getattr(child, "avatar", "mdi:account-circle") or "mdi:account-circle",
"reward_icon": reward.icon or 'mdi:gift', "reward_name": reward.name,
"cost": reward.cost, "reward_icon": reward.icon or "mdi:gift",
"claimed_at": rc.claimed_at.isoformat() if hasattr(rc.claimed_at, 'isoformat') else str(rc.claimed_at), "cost": reward.cost,
}) "claimed_at": rc.claimed_at.isoformat() if hasattr(rc.claimed_at, "isoformat") else str(rc.claimed_at),
}
)
return out return out
@@ -536,16 +536,22 @@ def _build_recent_completions(common: dict, limit: int = 35) -> list[dict]:
rate_seconds = matched_chore.timed_rate_minutes * 60 rate_seconds = matched_chore.timed_rate_minutes * 60
if rate_seconds > 0: if rate_seconds > 0:
display_points = (timed_secs // rate_seconds) * matched_chore.timed_rate_points display_points = (timed_secs // rate_seconds) * matched_chore.timed_rate_points
out.append({ out.append(
"completion_id": comp.id, {
"chore_id": comp.chore_id, "completion_id": comp.id,
"child_id": comp.child_id, "chore_id": comp.chore_id,
"child_name": "Parent" if comp.child_id == "__parent__" else (child_lookup[comp.child_id].name if comp.child_id in child_lookup else ""), "child_id": comp.child_id,
"chore_name": matched_chore.name if matched_chore else "", "child_name": "Parent"
"points": display_points, if comp.child_id == "__parent__"
"approved": comp.approved, else (child_lookup[comp.child_id].name if comp.child_id in child_lookup else ""),
"completed_at": comp.completed_at.isoformat() if hasattr(comp.completed_at, 'isoformat') else str(comp.completed_at), "chore_name": matched_chore.name if matched_chore else "",
}) "points": display_points,
"approved": comp.approved,
"completed_at": comp.completed_at.isoformat()
if hasattr(comp.completed_at, "isoformat")
else str(comp.completed_at),
}
)
return out return out
@@ -558,21 +564,23 @@ def _build_photo_gallery(common: dict, limit: int = 40) -> list[dict]:
""" """
child_lookup = common["child_lookup"] child_lookup = common["child_lookup"]
chore_lookup = common["chore_lookup"] chore_lookup = common["chore_lookup"]
with_photos = [ with_photos = [c for c in common["all_completions"] if getattr(c, "photo_url", "")]
c for c in common["all_completions"] if getattr(c, "photo_url", "")
]
recent = sorted(with_photos, key=lambda c: c.completed_at, reverse=True)[:limit] recent = sorted(with_photos, key=lambda c: c.completed_at, reverse=True)[:limit]
out = [] out = []
for comp in recent: for comp in recent:
chore = chore_lookup.get(comp.chore_id) chore = chore_lookup.get(comp.chore_id)
out.append({ out.append(
"completion_id": comp.id, {
"child_name": child_lookup[comp.child_id].name if comp.child_id in child_lookup else "", "completion_id": comp.id,
"chore_name": chore.name if chore else "", "child_name": child_lookup[comp.child_id].name if comp.child_id in child_lookup else "",
"approved": comp.approved, "chore_name": chore.name if chore else "",
"completed_at": comp.completed_at.isoformat() if hasattr(comp.completed_at, "isoformat") else str(comp.completed_at), "approved": comp.approved,
"photo_url": comp.photo_url, "completed_at": comp.completed_at.isoformat()
}) if hasattr(comp.completed_at, "isoformat")
else str(comp.completed_at),
"photo_url": comp.photo_url,
}
)
return out return out
@@ -593,15 +601,17 @@ def _build_recent_transactions(common: dict, limit: int = 20) -> list[dict]:
child = child_lookup.get(t.child_id) child = child_lookup.get(t.child_id)
if not child: if not child:
continue continue
events.append({ events.append(
"transaction_id": t.id, {
"type": "points_added" if t.points > 0 else "points_removed", "transaction_id": t.id,
"child_id": t.child_id, "type": "points_added" if t.points > 0 else "points_removed",
"child_name": child.name, "child_id": t.child_id,
"points": t.points, "child_name": child.name,
"reason": t.reason or "", "points": t.points,
"created_at": t.created_at.isoformat() if hasattr(t.created_at, 'isoformat') else str(t.created_at), "reason": t.reason or "",
}) "created_at": t.created_at.isoformat() if hasattr(t.created_at, "isoformat") else str(t.created_at),
}
)
for rc in all_reward_claims: for rc in all_reward_claims:
child = child_lookup.get(rc.child_id) child = child_lookup.get(rc.child_id)
@@ -610,43 +620,51 @@ def _build_recent_transactions(common: dict, limit: int = 20) -> list[dict]:
continue continue
event_type = "reward_approved" if rc.approved else "reward_claimed" event_type = "reward_approved" if rc.approved else "reward_claimed"
timestamp = rc.approved_at if rc.approved and rc.approved_at else rc.claimed_at timestamp = rc.approved_at if rc.approved and rc.approved_at else rc.claimed_at
events.append({ events.append(
"transaction_id": rc.id, {
"type": event_type, "transaction_id": rc.id,
"child_id": rc.child_id, "type": event_type,
"child_name": child.name, "child_id": rc.child_id,
"reward_id": rc.reward_id, "child_name": child.name,
"reward_name": reward.name, "reward_id": rc.reward_id,
"reward_icon": reward.icon or "mdi:gift", "reward_name": reward.name,
"points": -reward.cost, "reward_icon": reward.icon or "mdi:gift",
"approved": rc.approved, "points": -reward.cost,
"created_at": timestamp.isoformat() if hasattr(timestamp, 'isoformat') else str(timestamp), "approved": rc.approved,
}) "created_at": timestamp.isoformat() if hasattr(timestamp, "isoformat") else str(timestamp),
}
)
events.sort(key=lambda e: e["created_at"], reverse=True) events.sort(key=lambda e: e["created_at"], reverse=True)
return events[:limit] return events[:limit]
def _build_penalties_list(common: dict) -> list[dict]: def _build_penalties_list(common: dict) -> list[dict]:
return [{ return [
"id": p.id, {
"name": p.name, "id": p.id,
"points": p.points, "name": p.name,
"description": p.description, "points": p.points,
"icon": p.icon, "description": p.description,
"assigned_to": p.assigned_to or [], "icon": p.icon,
} for p in common["data"].get("penalties", [])] "assigned_to": p.assigned_to or [],
}
for p in common["data"].get("penalties", [])
]
def _build_bonuses_list(common: dict) -> list[dict]: def _build_bonuses_list(common: dict) -> list[dict]:
return [{ return [
"id": b.id, {
"name": b.name, "id": b.id,
"points": b.points, "name": b.name,
"description": b.description, "points": b.points,
"icon": b.icon, "description": b.description,
"assigned_to": b.assigned_to or [], "icon": b.icon,
} for b in common["data"].get("bonuses", [])] "assigned_to": b.assigned_to or [],
}
for b in common["data"].get("bonuses", [])
]
async def async_setup_entry( async def async_setup_entry(
@@ -795,8 +813,10 @@ class TaskMateOverallStatsSensor(_CachedAttrsSensor):
# Legacy shape kept for cards that still read the four fixed keys. # Legacy shape kept for cards that still read the four fixed keys.
legacy_defaults = { legacy_defaults = {
"morning": ("06:00", "12:00"), "afternoon": ("12:00", "17:00"), "morning": ("06:00", "12:00"),
"evening": ("17:00", "21:00"), "night": ("21:00", "23:59"), "afternoon": ("12:00", "17:00"),
"evening": ("17:00", "21:00"),
"night": ("21:00", "23:59"),
} }
time_boundaries = {} time_boundaries = {}
for cat, (def_start, def_end) in legacy_defaults.items(): for cat, (def_start, def_end) in legacy_defaults.items():
@@ -813,7 +833,8 @@ class TaskMateOverallStatsSensor(_CachedAttrsSensor):
"perfect_week_enabled": settings.get("perfect_week_enabled", "true") == "true", "perfect_week_enabled": settings.get("perfect_week_enabled", "true") == "true",
"perfect_week_bonus": _safe_int(settings.get("perfect_week_bonus"), 50), "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_requires_all_chores": settings.get("streak_requires_all_chores", "false") in (True, "true"),
"perfect_week_requires_all_chores": settings.get("perfect_week_requires_all_chores", "false") in (True, "true"), "perfect_week_requires_all_chores": settings.get("perfect_week_requires_all_chores", "false")
in (True, "true"),
"total_children": len(children), "total_children": len(children),
"total_chores": len(chores), "total_chores": len(chores),
"total_rewards": len(rewards), "total_rewards": len(rewards),
@@ -1117,6 +1138,7 @@ class ChildStatsSensor(TaskMateBaseSensor):
# drop it once any pool member has completed it today (so a parent # drop it once any pool member has completed it today (so a parent
# crediting the off-rotation child clears the chore for everyone). # crediting the off-rotation child clears the chore for everyone).
chores = self.coordinator.data.get("chores", []) chores = self.coordinator.data.get("chores", [])
def _included(c): def _included(c):
if not (child.id in c.assigned_to or not c.assigned_to): if not (child.id in c.assigned_to or not c.assigned_to):
return False return False
@@ -1128,6 +1150,7 @@ class ChildStatsSensor(TaskMateBaseSensor):
if self.coordinator._is_rotation_done_today(c): if self.coordinator._is_rotation_done_today(c):
return False return False
return True return True
assigned_chores = [c for c in chores if _included(c)] assigned_chores = [c for c in chores if _included(c)]
return { return {
@@ -1141,7 +1164,10 @@ class ChildStatsSensor(TaskMateBaseSensor):
"best_streak": child.best_streak, "best_streak": child.best_streak,
"career_score": child.career_score, "career_score": child.career_score,
"total_penalties_received": child.total_penalties_received, "total_penalties_received": child.total_penalties_received,
"assigned_chores": [{"id": c.id, "name": c.name, "points": c.points, "time_category": c.time_category} for c in assigned_chores], "assigned_chores": [
{"id": c.id, "name": c.name, "points": c.points, "time_category": c.time_category}
for c in assigned_chores
],
"chore_order": child.chore_order, "chore_order": child.chore_order,
} }
@@ -1166,9 +1192,7 @@ class ChildBadgesSensor(TaskMateBaseSensor):
@property @property
def native_value(self) -> int: def native_value(self) -> int:
"""Number of badges earned by this child.""" """Number of badges earned by this child."""
return len( return len(self.coordinator.storage.get_awarded_badges_for_child(self.child_id))
self.coordinator.storage.get_awarded_badges_for_child(self.child_id)
)
@property @property
def extra_state_attributes(self) -> dict: def extra_state_attributes(self) -> dict:
@@ -1180,10 +1204,7 @@ class ChildBadgesSensor(TaskMateBaseSensor):
return {"earned": [], "available": [], "total_badges": 0} return {"earned": [], "available": [], "total_badges": 0}
all_badges = [b for b in storage.get_badges() if b.enabled] all_badges = [b for b in storage.get_badges() if b.enabled]
applicable = [ applicable = [b for b in all_badges if not b.assigned_to or self.child_id in b.assigned_to]
b for b in all_badges
if not b.assigned_to or self.child_id in b.assigned_to
]
awarded_records = storage.get_awarded_badges_for_child(self.child_id) awarded_records = storage.get_awarded_badges_for_child(self.child_id)
record_by_id = {a.badge_id: a for a in awarded_records} record_by_id = {a.badge_id: a for a in awarded_records}
@@ -1193,35 +1214,46 @@ class ChildBadgesSensor(TaskMateBaseSensor):
for b in applicable: for b in applicable:
if b.id in record_by_id: if b.id in record_by_id:
rec = record_by_id[b.id] rec = record_by_id[b.id]
earned.append({ earned.append(
"badge_id": b.id, {
"name": b.name, "badge_id": b.id,
"icon": b.icon, "name": b.name,
"tier": b.tier, "icon": b.icon,
"earned_at": rec.earned_at.isoformat() if rec.earned_at else None, "tier": b.tier,
"manually_awarded": rec.manually_awarded, "earned_at": rec.earned_at.isoformat() if rec.earned_at else None,
"silent": rec.silent, "manually_awarded": rec.manually_awarded,
}) "silent": rec.silent,
}
)
else: else:
if not b.criteria: # The criterion that actually gates the award: for an AND badge
# that is the weakest one, for an OR badge the strongest. Using
# min() for both understated OR badges (and could report <100%
# for one already earned) — see coord_badges.BadgeEvaluator.
combinator = (getattr(b, "combinator", "AND") or "AND").upper()
scored = []
for c in b.criteria:
cur = resolve_metric(c.metric, child, storage)
pct = min(100, int(100 * cur / max(c.value, 1)))
scored.append((pct, c.metric, cur, c.value))
if not scored:
progress_pct = 0 progress_pct = 0
closest = None
else: else:
pcts = [] pick = max(scored) if combinator == "OR" else min(scored)
for c in b.criteria: progress_pct = pick[0]
cur = resolve_metric(c.metric, child, storage) closest = {"metric": pick[1], "current": pick[2], "target": pick[3]}
target = max(c.value, 1) available.append(
pcts.append(min(100, int(100 * cur / target))) {
progress_pct = min(pcts) if pcts else 0 "badge_id": b.id,
available.append({ "name": b.name,
"badge_id": b.id, "icon": b.icon,
"name": b.name, "tier": b.tier,
"icon": b.icon, "progress_pct": progress_pct,
"tier": b.tier, "closest_criterion": closest,
"progress_pct": progress_pct, "criteria_summary": ", ".join(f"{c.metric} >= {c.value}" for c in b.criteria),
"criteria_summary": ", ".join( }
f"{c.metric} >= {c.value}" for c in b.criteria )
),
})
earned.sort(key=lambda e: e.get("earned_at") or "", reverse=True) earned.sort(key=lambda e: e.get("earned_at") or "", reverse=True)
@@ -1274,9 +1306,7 @@ class PendingApprovalsSensor(TaskMateBaseSensor):
# list render them identically. # list render them identically.
bonus_subtask_id = getattr(comp, "bonus_subtask_id", "") or "" bonus_subtask_id = getattr(comp, "bonus_subtask_id", "") or ""
if bonus_subtask_id: if bonus_subtask_id:
subtask = next( subtask = next((b for b in chore.bonus_subtasks if b.id == bonus_subtask_id), None)
(b for b in chore.bonus_subtasks if b.id == bonus_subtask_id), None
)
chore_name = f"{chore.name} {subtask.name}" if subtask else chore.name chore_name = f"{chore.name} {subtask.name}" if subtask else chore.name
pts = subtask.points if subtask else 0 pts = subtask.points if subtask else 0
else: else:
@@ -1303,9 +1333,7 @@ class PendingApprovalsSensor(TaskMateBaseSensor):
detail["timed_duration_seconds"] = timed_secs detail["timed_duration_seconds"] = timed_secs
photo = getattr(comp, "photo_url", "") or "" photo = getattr(comp, "photo_url", "") or ""
if photo: if photo:
detail["photo_url"] = photos.sign_photo_url( detail["photo_url"] = photos.sign_photo_url(self.coordinator.hass, photo)
self.coordinator.hass, photo
)
completion_details.append(detail) completion_details.append(detail)
reward_details = [] reward_details = []
@@ -1313,16 +1341,18 @@ class PendingApprovalsSensor(TaskMateBaseSensor):
child = self.coordinator.get_child(claim.child_id) child = self.coordinator.get_child(claim.child_id)
reward = self.coordinator.get_reward(claim.reward_id) reward = self.coordinator.get_reward(claim.reward_id)
if child and reward: if child and reward:
reward_details.append({ reward_details.append(
"claim_id": claim.id, {
"type": "reward", "claim_id": claim.id,
"child_name": child.name, "type": "reward",
"child_id": child.id, "child_name": child.name,
"reward_name": reward.name, "child_id": child.id,
"reward_id": reward.id, "reward_name": reward.name,
"cost": reward.cost, "reward_id": reward.id,
"claimed_at": claim.claimed_at.isoformat(), "cost": reward.cost,
}) "claimed_at": claim.claimed_at.isoformat(),
}
)
mandatory_misses = self.coordinator.mandatory_misses_state() mandatory_misses = self.coordinator.mandatory_misses_state()
return { return {
+125 -97
View File
@@ -1,4 +1,5 @@
"""Storage management for TaskMate integration.""" """Storage management for TaskMate integration."""
from __future__ import annotations from __future__ import annotations
import logging import logging
@@ -120,6 +121,15 @@ class TaskMateStorage:
if "scheduled_changes" not in self._data: if "scheduled_changes" not in self._data:
self._data["scheduled_changes"] = [] self._data["scheduled_changes"] = []
# Drop settled swap requests (#783) and ones pointing at a chore or child
# that no longer exists (#785). Approval used to only flip a status flag,
# and deletes did not cascade, so existing installs carry both kinds —
# the orphans surface in the parent's approval queue as an unclearable
# "? wants to swap ?" row. Runs on every load rather than behind a
# one-shot flag: it is a cheap list filter, and it also sweeps up records
# left by a downgrade to a version without the cascades.
self._drop_dead_swap_requests()
# Notifications overhaul (v3.9.0) # Notifications overhaul (v3.9.0)
if "parent_recipients" not in self._data: if "parent_recipients" not in self._data:
self._data["parent_recipients"] = [] self._data["parent_recipients"] = []
@@ -182,8 +192,8 @@ class TaskMateStorage:
self._data["_pool_semantics_version"] = 2 self._data["_pool_semantics_version"] = 2
if adjusted: if adjusted:
_LOGGER.info( _LOGGER.info(
"TaskMate: migrated %d pool allocation(s) to beta2 semantics " "TaskMate: migrated %d pool allocation(s) to beta2 semantics (points now deducted at allocation time)",
"(points now deducted at allocation time)", adjusted adjusted,
) )
await self.async_save() await self.async_save()
@@ -232,15 +242,13 @@ class TaskMateStorage:
"Migrating chore '%s' assigned_to: '%s' -> '%s' (name to ID)", "Migrating chore '%s' assigned_to: '%s' -> '%s' (name to ID)",
chore.get("name", "unknown"), chore.get("name", "unknown"),
assignment, assignment,
name_to_id[assignment] name_to_id[assignment],
) )
else: else:
# Unknown value, keep it but log a warning # Unknown value, keep it but log a warning
new_assigned_to.append(assignment) new_assigned_to.append(assignment)
_LOGGER.warning( _LOGGER.warning(
"Chore '%s' has unknown assigned_to value: '%s'", "Chore '%s' has unknown assigned_to value: '%s'", chore.get("name", "unknown"), assignment
chore.get("name", "unknown"),
assignment
) )
if chore_modified: if chore_modified:
@@ -269,10 +277,7 @@ class TaskMateStorage:
self._data["_career_score_initialized"] = True self._data["_career_score_initialized"] = True
if children: if children:
_LOGGER.info( _LOGGER.info("TaskMate: initialized career_score for %d child(ren) from total_points_earned", len(children))
"TaskMate: initialized career_score for %d child(ren) "
"from total_points_earned", len(children)
)
await self.async_save() await self.async_save()
async def async_save(self) -> None: async def async_save(self) -> None:
@@ -336,9 +341,7 @@ class TaskMateStorage:
def remove_child(self, child_id: str) -> None: def remove_child(self, child_id: str) -> None:
"""Remove a child and cascade-delete their awarded badges.""" """Remove a child and cascade-delete their awarded badges."""
self._data["children"] = [ self._data["children"] = [c for c in self._data.get("children", []) if c.get("id") != child_id]
c for c in self._data.get("children", []) if c.get("id") != child_id
]
self.remove_awards_for_child(child_id) self.remove_awards_for_child(child_id)
# Chores management # Chores management
@@ -370,9 +373,7 @@ class TaskMateStorage:
def remove_chore(self, chore_id: str) -> None: def remove_chore(self, chore_id: str) -> None:
"""Remove a chore.""" """Remove a chore."""
self._data["chores"] = [ self._data["chores"] = [c for c in self._data.get("chores", []) if c.get("id") != chore_id]
c for c in self._data.get("chores", []) if c.get("id") != chore_id
]
order = self._data.get("chore_display_order", []) order = self._data.get("chore_display_order", [])
if chore_id in order: if chore_id in order:
order.remove(chore_id) order.remove(chore_id)
@@ -414,9 +415,7 @@ class TaskMateStorage:
def remove_reward(self, reward_id: str) -> None: def remove_reward(self, reward_id: str) -> None:
"""Remove a reward.""" """Remove a reward."""
self._data["rewards"] = [ self._data["rewards"] = [r for r in self._data.get("rewards", []) if r.get("id") != reward_id]
r for r in self._data.get("rewards", []) if r.get("id") != reward_id
]
# Completions management # Completions management
def get_completions(self) -> list[ChoreCompletion]: def get_completions(self) -> list[ChoreCompletion]:
@@ -447,9 +446,7 @@ class TaskMateStorage:
def remove_completion(self, completion_id: str) -> None: def remove_completion(self, completion_id: str) -> None:
"""Remove a completion record.""" """Remove a completion record."""
self._data["completions"] = [ self._data["completions"] = [c for c in self._data.get("completions", []) if c.get("id") != completion_id]
c for c in self._data.get("completions", []) if c.get("id") != completion_id
]
# Mandatory-miss management (#532) # Mandatory-miss management (#532)
def get_mandatory_misses(self) -> list[MandatoryMiss]: def get_mandatory_misses(self) -> list[MandatoryMiss]:
@@ -470,9 +467,7 @@ class TaskMateStorage:
def remove_mandatory_miss(self, miss_id: str) -> None: def remove_mandatory_miss(self, miss_id: str) -> None:
"""Remove a mandatory-miss item by id.""" """Remove a mandatory-miss item by id."""
self._data["mandatory_misses"] = [ self._data["mandatory_misses"] = [m for m in self._data.get("mandatory_misses", []) if m.get("id") != miss_id]
m for m in self._data.get("mandatory_misses", []) if m.get("id") != miss_id
]
def replace_mandatory_misses(self, misses: list[MandatoryMiss]) -> None: def replace_mandatory_misses(self, misses: list[MandatoryMiss]) -> None:
"""Replace the whole mandatory-miss collection.""" """Replace the whole mandatory-miss collection."""
@@ -507,9 +502,7 @@ class TaskMateStorage:
def remove_reward_claim(self, claim_id: str) -> None: def remove_reward_claim(self, claim_id: str) -> None:
"""Remove a reward claim.""" """Remove a reward claim."""
self._data["reward_claims"] = [ self._data["reward_claims"] = [c for c in self._data.get("reward_claims", []) if c.get("id") != claim_id]
c for c in self._data.get("reward_claims", []) if c.get("id") != claim_id
]
# Penalties management # Penalties management
def get_penalties(self) -> list[Penalty]: def get_penalties(self) -> list[Penalty]:
@@ -538,9 +531,7 @@ class TaskMateStorage:
def remove_penalty(self, penalty_id: str) -> None: def remove_penalty(self, penalty_id: str) -> None:
"""Remove a penalty.""" """Remove a penalty."""
self._data["penalties"] = [ self._data["penalties"] = [p for p in self._data.get("penalties", []) if p.get("id") != penalty_id]
p for p in self._data.get("penalties", []) if p.get("id") != penalty_id
]
# Bonuses management # Bonuses management
def get_bonuses(self) -> list[Bonus]: def get_bonuses(self) -> list[Bonus]:
@@ -569,9 +560,7 @@ class TaskMateStorage:
def remove_bonus(self, bonus_id: str) -> None: def remove_bonus(self, bonus_id: str) -> None:
"""Remove a bonus.""" """Remove a bonus."""
self._data["bonuses"] = [ self._data["bonuses"] = [b for b in self._data.get("bonuses", []) if b.get("id") != bonus_id]
b for b in self._data.get("bonuses", []) if b.get("id") != bonus_id
]
# Badges management # Badges management
def get_badges(self) -> list[Badge]: def get_badges(self) -> list[Badge]:
@@ -600,9 +589,7 @@ class TaskMateStorage:
def remove_badge(self, badge_id: str) -> None: def remove_badge(self, badge_id: str) -> None:
"""Remove a badge and cascade-delete its awards.""" """Remove a badge and cascade-delete its awards."""
self._data["badges"] = [ self._data["badges"] = [b for b in self._data.get("badges", []) if b.get("id") != badge_id]
b for b in self._data.get("badges", []) if b.get("id") != badge_id
]
self.remove_awards_for_badge(badge_id) self.remove_awards_for_badge(badge_id)
# Awarded badges management # Awarded badges management
@@ -620,9 +607,7 @@ class TaskMateStorage:
def remove_awarded_badge(self, awarded_id: str) -> None: def remove_awarded_badge(self, awarded_id: str) -> None:
"""Remove an awarded-badge record by id.""" """Remove an awarded-badge record by id."""
self._data["awarded_badges"] = [ self._data["awarded_badges"] = [a for a in self._data.get("awarded_badges", []) if a.get("id") != awarded_id]
a for a in self._data.get("awarded_badges", []) if a.get("id") != awarded_id
]
def remove_awards_for_badge(self, badge_id: str) -> None: def remove_awards_for_badge(self, badge_id: str) -> None:
"""Cascade-delete all awards referencing a badge id.""" """Cascade-delete all awards referencing a badge id."""
@@ -705,9 +690,7 @@ class TaskMateStorage:
cfg = NotificationConfig( cfg = NotificationConfig(
type_id=tid, type_id=tid,
master_enabled=True, master_enabled=True,
routes={ routes={seeded_parent_id: NotificationRoute(enabled=True)} if seeded_parent_id else {},
seeded_parent_id: NotificationRoute(enabled=True)
} if seeded_parent_id else {},
) )
nc[tid] = cfg.to_dict() nc[tid] = cfg.to_dict()
@@ -728,8 +711,7 @@ class TaskMateStorage:
def delete_parent_recipient(self, parent_id: str) -> None: def delete_parent_recipient(self, parent_id: str) -> None:
self._data["parent_recipients"] = [ self._data["parent_recipients"] = [
r for r in self._data.get("parent_recipients", []) r for r in self._data.get("parent_recipients", []) if r.get("id") != parent_id
if r.get("id") != parent_id
] ]
# --- notification config --- # --- notification config ---
@@ -749,9 +731,7 @@ class TaskMateStorage:
cfg.nav_url = nav_url cfg.nav_url = nav_url
self._data.setdefault("notification_config", {})[type_id] = cfg.to_dict() self._data.setdefault("notification_config", {})[type_id] = cfg.to_dict()
def set_notification_route( def set_notification_route(self, type_id: str, recipient_id: str, route: NotificationRoute) -> None:
self, type_id: str, recipient_id: str, route: NotificationRoute
) -> None:
cfg = self.get_notification_config(type_id) cfg = self.get_notification_config(type_id)
cfg.routes[recipient_id] = route cfg.routes[recipient_id] = route
self._data.setdefault("notification_config", {})[type_id] = cfg.to_dict() self._data.setdefault("notification_config", {})[type_id] = cfg.to_dict()
@@ -764,10 +744,7 @@ class TaskMateStorage:
# --- custom notifications --- # --- custom notifications ---
def get_custom_notifications(self) -> list[CustomNotification]: def get_custom_notifications(self) -> list[CustomNotification]:
return [ return [CustomNotification.from_dict(d) for d in self._data.get("custom_notifications", [])]
CustomNotification.from_dict(d)
for d in self._data.get("custom_notifications", [])
]
def upsert_custom_notification(self, n: CustomNotification) -> None: def upsert_custom_notification(self, n: CustomNotification) -> None:
rows = self._data.setdefault("custom_notifications", []) rows = self._data.setdefault("custom_notifications", [])
@@ -779,15 +756,12 @@ class TaskMateStorage:
def delete_custom_notification(self, custom_id: str) -> None: def delete_custom_notification(self, custom_id: str) -> None:
self._data["custom_notifications"] = [ self._data["custom_notifications"] = [
r for r in self._data.get("custom_notifications", []) r for r in self._data.get("custom_notifications", []) if r.get("id") != custom_id
if r.get("id") != custom_id
] ]
# --- streak-at-risk cutoff --- # --- streak-at-risk cutoff ---
def get_streak_at_risk_cutoff(self) -> str: def get_streak_at_risk_cutoff(self) -> str:
return (self._data.get("settings", {}) or {}).get( return (self._data.get("settings", {}) or {}).get("streak_at_risk_cutoff_time", "20:00")
"streak_at_risk_cutoff_time", "20:00"
)
def set_streak_at_risk_cutoff(self, hhmm: str) -> None: def set_streak_at_risk_cutoff(self, hhmm: str) -> None:
self._data.setdefault("settings", {})["streak_at_risk_cutoff_time"] = hhmm self._data.setdefault("settings", {})["streak_at_risk_cutoff_time"] = hhmm
@@ -811,16 +785,14 @@ class TaskMateStorage:
def get_escalation_reminder_minutes(self) -> int: def get_escalation_reminder_minutes(self) -> int:
"""Minutes after a mandatory miss before the child reminder escalates.""" """Minutes after a mandatory miss before the child reminder escalates."""
try: try:
return max(1, int((self._data.get("settings", {}) or {}).get( return max(1, int((self._data.get("settings", {}) or {}).get("mandatory_escalation_reminder_minutes", 30)))
"mandatory_escalation_reminder_minutes", 30)))
except (TypeError, ValueError): except (TypeError, ValueError):
return 30 return 30
def get_escalation_parent_minutes(self) -> int: def get_escalation_parent_minutes(self) -> int:
"""Minutes after a mandatory miss before the parent alert escalates.""" """Minutes after a mandatory miss before the parent alert escalates."""
try: try:
return max(1, int((self._data.get("settings", {}) or {}).get( return max(1, int((self._data.get("settings", {}) or {}).get("mandatory_escalation_parent_minutes", 120)))
"mandatory_escalation_parent_minutes", 120)))
except (TypeError, ValueError): except (TypeError, ValueError):
return 120 return 120
@@ -863,9 +835,7 @@ class TaskMateStorage:
def remove_task_group(self, group_id: str) -> None: def remove_task_group(self, group_id: str) -> None:
"""Remove a task group.""" """Remove a task group."""
self._data["task_groups"] = [ self._data["task_groups"] = [g for g in self._data.get("task_groups", []) if g.get("id") != group_id]
g for g in self._data.get("task_groups", []) if g.get("id") != group_id
]
def remove_chore_from_task_groups(self, chore_id: str) -> None: def remove_chore_from_task_groups(self, chore_id: str) -> None:
"""Strip a chore ID from every group (used on chore delete).""" """Strip a chore ID from every group (used on chore delete)."""
@@ -921,9 +891,7 @@ class TaskMateStorage:
# the single choke point all awards flow through — the rolling 200-cap on # the single choke point all awards flow through — the rolling 200-cap on
# transactions makes them unreliable for a monthly total (FEAT-2). # transactions makes them unreliable for a monthly total (FEAT-2).
if transaction.points > 0: if transaction.points > 0:
self.record_season_points( self.record_season_points(transaction.child_id, transaction.points, transaction.created_at)
transaction.child_id, transaction.points, transaction.created_at
)
# Keep only the last 200 transactions to avoid unbounded storage growth # Keep only the last 200 transactions to avoid unbounded storage growth
if len(self._data["points_transactions"]) > 200: if len(self._data["points_transactions"]) > 200:
@@ -1019,6 +987,57 @@ class TaskMateStorage:
return True return True
return False return False
def _drop_dead_swap_requests(self) -> None:
"""Prune swap requests that are settled or dangling (#783, #785).
Called from ``async_load``. A request survives only when it is still
pending *and* every id it names still resolves the chore, the
requester, and ``from_child_id`` when set (it is "" for a chore with no
cached assignee yet, which is a normal request, not a dead reference).
"""
swap_requests = self._data.get("swap_requests")
if not swap_requests:
return
chore_ids = {c.get("id") for c in self._data.get("chores", [])}
child_ids = {c.get("id") for c in self._data.get("children", [])}
def _alive(req: dict) -> bool:
if req.get("status") != "pending":
return False
if req.get("chore_id") not in chore_ids:
return False
if req.get("requester_id") not in child_ids:
return False
from_id = req.get("from_child_id") or ""
return not from_id or from_id in child_ids
kept = [r for r in swap_requests if _alive(r)]
if len(kept) != len(swap_requests):
_LOGGER.debug(
"Dropped %d settled or orphaned swap request(s) from storage",
len(swap_requests) - len(kept),
)
self._data["swap_requests"] = kept
def remove_swap_requests_for_chore(self, chore_id: str) -> None:
"""Drop a deleted chore's swap requests so they can't linger in the
approval queue with nothing to approve."""
self._data["swap_requests"] = [r for r in self._data.get("swap_requests", []) if r.get("chore_id") != chore_id]
def remove_swap_requests_for_child(self, child_id: str) -> None:
"""Drop swap requests a deleted child is either side of.
Both ends matter: the requester is who the handover goes *to*, and
`from_child_id` is who it comes from and is rendered in the queue.
Either being gone makes the request undeliverable.
"""
self._data["swap_requests"] = [
r
for r in self._data.get("swap_requests", [])
if r.get("requester_id") != child_id and r.get("from_child_id") != child_id
]
# ── Quests (chore chains) ──────────────────────────────────────────── # ── Quests (chore chains) ────────────────────────────────────────────
def get_quests(self) -> list[Quest]: def get_quests(self) -> list[Quest]:
return [Quest.from_dict(q) for q in self._data.get("quests", [])] return [Quest.from_dict(q) for q in self._data.get("quests", [])]
@@ -1041,9 +1060,7 @@ class TaskMateStorage:
self.add_quest(quest) self.add_quest(quest)
def remove_quest(self, quest_id: str) -> None: def remove_quest(self, quest_id: str) -> None:
self._data["quests"] = [ self._data["quests"] = [q for q in self._data.get("quests", []) if q.get("id") != quest_id]
q for q in self._data.get("quests", []) if q.get("id") != quest_id
]
# Drop any progress tracked for this quest # Drop any progress tracked for this quest
prog = self._data.get("quest_progress", {}) prog = self._data.get("quest_progress", {})
prog.pop(quest_id, None) prog.pop(quest_id, None)
@@ -1084,9 +1101,7 @@ class TaskMateStorage:
self.add_challenge(challenge) self.add_challenge(challenge)
def remove_challenge(self, challenge_id: str) -> None: def remove_challenge(self, challenge_id: str) -> None:
self._data["challenges"] = [ self._data["challenges"] = [c for c in self._data.get("challenges", []) if c.get("id") != challenge_id]
c for c in self._data.get("challenges", []) if c.get("id") != challenge_id
]
self._data.get("challenge_progress", {}).pop(challenge_id, None) self._data.get("challenge_progress", {}).pop(challenge_id, None)
def get_challenge_progress(self) -> dict: def get_challenge_progress(self) -> dict:
@@ -1107,6 +1122,7 @@ class TaskMateStorage:
def export_data(self) -> dict: def export_data(self) -> dict:
"""Return a deep copy of the full stored data (for backup/export).""" """Return a deep copy of the full stored data (for backup/export)."""
import copy import copy
return copy.deepcopy(self._data) return copy.deepcopy(self._data)
def import_data(self, data: dict) -> None: def import_data(self, data: dict) -> None:
@@ -1116,14 +1132,29 @@ class TaskMateStorage:
a partial import. a partial import.
""" """
import copy import copy
if not isinstance(data, dict): if not isinstance(data, dict):
raise ValueError("import data must be an object") raise ValueError("import data must be an object")
self._data = copy.deepcopy(data) self._data = copy.deepcopy(data)
list_keys = ( list_keys = (
"children", "chores", "rewards", "penalties", "bonuses", "children",
"task_groups", "completions", "mandatory_misses", "reward_claims", "points_transactions", "chores",
"pool_allocations", "badges", "awarded_badges", "parent_recipients", "rewards",
"audit_log", "timed_sessions", "quests", "challenges", "penalties",
"bonuses",
"task_groups",
"completions",
"mandatory_misses",
"reward_claims",
"points_transactions",
"pool_allocations",
"badges",
"awarded_badges",
"parent_recipients",
"audit_log",
"timed_sessions",
"quests",
"challenges",
) )
for k in list_keys: for k in list_keys:
if not isinstance(self._data.get(k), list): if not isinstance(self._data.get(k), list):
@@ -1146,6 +1177,7 @@ class TaskMateStorage:
well-formed photo URLs so the panel never renders a foreign/dangerous one. well-formed photo URLs so the panel never renders a foreign/dangerous one.
""" """
from .photos import is_taskmate_photo_url from .photos import is_taskmate_photo_url
for comp in self._data.get("completions", []): for comp in self._data.get("completions", []):
if not isinstance(comp, dict): if not isinstance(comp, dict):
continue continue
@@ -1163,21 +1195,15 @@ class TaskMateStorage:
def remove_completions_for_child(self, child_id: str) -> None: def remove_completions_for_child(self, child_id: str) -> None:
"""Remove all completions for a given child.""" """Remove all completions for a given child."""
self._data["completions"] = [ self._data["completions"] = [c for c in self._data.get("completions", []) if c.get("child_id") != child_id]
c for c in self._data.get("completions", []) if c.get("child_id") != child_id
]
def remove_completions_for_chore(self, chore_id: str) -> None: def remove_completions_for_chore(self, chore_id: str) -> None:
"""Remove all completions for a given chore.""" """Remove all completions for a given chore."""
self._data["completions"] = [ self._data["completions"] = [c for c in self._data.get("completions", []) if c.get("chore_id") != chore_id]
c for c in self._data.get("completions", []) if c.get("chore_id") != chore_id
]
def remove_reward_claims_for_child(self, child_id: str) -> None: def remove_reward_claims_for_child(self, child_id: str) -> None:
"""Remove all reward claims for a given child.""" """Remove all reward claims for a given child."""
self._data["reward_claims"] = [ self._data["reward_claims"] = [c for c in self._data.get("reward_claims", []) if c.get("child_id") != child_id]
c for c in self._data.get("reward_claims", []) if c.get("child_id") != child_id
]
def remove_reward_claims_for_reward(self, reward_id: str) -> None: def remove_reward_claims_for_reward(self, reward_id: str) -> None:
"""Remove all reward claims for a given reward.""" """Remove all reward claims for a given reward."""
@@ -1209,7 +1235,8 @@ class TaskMateStorage:
def remove_pool_allocation(self, child_id: str, reward_id: str) -> None: def remove_pool_allocation(self, child_id: str, reward_id: str) -> None:
"""Remove a pool allocation for a specific (child, reward) pair.""" """Remove a pool allocation for a specific (child, reward) pair."""
self._data["pool_allocations"] = [ self._data["pool_allocations"] = [
a for a in self._data.get("pool_allocations", []) a
for a in self._data.get("pool_allocations", [])
if not (a.get("child_id") == child_id and a.get("reward_id") == reward_id) if not (a.get("child_id") == child_id and a.get("reward_id") == reward_id)
] ]
@@ -1309,18 +1336,22 @@ class TaskMateStorage:
def get_timed_session(self, chore_id: str, child_id: str, session_date: str) -> TimedSession | None: def get_timed_session(self, chore_id: str, child_id: str, session_date: str) -> TimedSession | None:
"""Get a timed session for a specific chore/child/date.""" """Get a timed session for a specific chore/child/date."""
for s in self._data.get("timed_sessions", []): for s in self._data.get("timed_sessions", []):
if (s.get("chore_id") == chore_id if (
and s.get("child_id") == child_id s.get("chore_id") == chore_id
and s.get("session_date") == session_date): and s.get("child_id") == child_id
and s.get("session_date") == session_date
):
return TimedSession.from_dict(s) return TimedSession.from_dict(s)
return None return None
def get_active_timed_session(self, chore_id: str, child_id: str) -> TimedSession | None: def get_active_timed_session(self, chore_id: str, child_id: str) -> TimedSession | None:
"""Get a running or paused session for a chore/child pair.""" """Get a running or paused session for a chore/child pair."""
for s in self._data.get("timed_sessions", []): for s in self._data.get("timed_sessions", []):
if (s.get("chore_id") == chore_id if (
and s.get("child_id") == child_id s.get("chore_id") == chore_id
and s.get("state") in ("running", "paused")): and s.get("child_id") == child_id
and s.get("state") in ("running", "paused")
):
return TimedSession.from_dict(s) return TimedSession.from_dict(s)
return None return None
@@ -1335,10 +1366,7 @@ class TaskMateStorage:
def remove_timed_session(self, session_id: str) -> None: def remove_timed_session(self, session_id: str) -> None:
"""Remove a timed session.""" """Remove a timed session."""
self._data["timed_sessions"] = [ self._data["timed_sessions"] = [s for s in self._data.get("timed_sessions", []) if s.get("id") != session_id]
s for s in self._data.get("timed_sessions", [])
if s.get("id") != session_id
]
# Generic settings # Generic settings
def get_setting(self, key: str, default: Any = "") -> Any: def get_setting(self, key: str, default: Any = "") -> Any:
+281 -31
View File
@@ -1,15 +1,35 @@
"""Built-in chore template packs for TaskMate.""" """Built-in chore template packs for TaskMate."""
from __future__ import annotations from __future__ import annotations
TEMPLATE_CHORE_FIELDS = ( TEMPLATE_CHORE_FIELDS = (
"name", "points", "description", "requires_approval", "time_category", "name",
"daily_limit", "completion_sound", "schedule_mode", "due_days", "points",
"recurrence", "recurrence_day", "recurrence_start", "first_occurrence_mode", "description",
"assignment_mode", "require_availability", "visibility_entity", "requires_approval",
"visibility_state", "visibility_operator", "time_category",
"weather_entity", "weather_block_conditions", "weather_temp_min", "daily_limit",
"weather_temp_max", "weather_wind_max", "task_type", "completion_sound",
"timed_rate_points", "timed_rate_minutes", "timed_max_daily_minutes", "schedule_mode",
"due_days",
"recurrence",
"recurrence_day",
"recurrence_start",
"first_occurrence_mode",
"assignment_mode",
"require_availability",
"visibility_entity",
"visibility_state",
"visibility_operator",
"weather_entity",
"weather_block_conditions",
"weather_temp_min",
"weather_temp_max",
"weather_wind_max",
"task_type",
"timed_rate_points",
"timed_rate_minutes",
"timed_max_daily_minutes",
) )
_WEEKDAYS = ["monday", "tuesday", "wednesday", "thursday", "friday"] _WEEKDAYS = ["monday", "tuesday", "wednesday", "thursday", "friday"]
@@ -22,10 +42,50 @@ BUILT_IN_TEMPLATES: list[dict] = [
"icon": "mdi:weather-sunny", "icon": "mdi:weather-sunny",
"builtin": True, "builtin": True,
"chores": [ "chores": [
{"name": "Make bed", "points": 2, "time_category": "morning", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"}, {
{"name": "Brush teeth", "points": 1, "time_category": "morning", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"}, "name": "Make bed",
{"name": "Get dressed", "points": 1, "time_category": "morning", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"}, "points": 2,
{"name": "Pack school bag", "points": 2, "time_category": "morning", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"}, "time_category": "morning",
"schedule_mode": "specific_days",
"due_days": list(_WEEKDAYS),
"requires_approval": False,
"assignment_mode": "everyone",
"daily_limit": 1,
"completion_sound": "coin",
},
{
"name": "Brush teeth",
"points": 1,
"time_category": "morning",
"schedule_mode": "specific_days",
"due_days": list(_WEEKDAYS),
"requires_approval": False,
"assignment_mode": "everyone",
"daily_limit": 1,
"completion_sound": "coin",
},
{
"name": "Get dressed",
"points": 1,
"time_category": "morning",
"schedule_mode": "specific_days",
"due_days": list(_WEEKDAYS),
"requires_approval": False,
"assignment_mode": "everyone",
"daily_limit": 1,
"completion_sound": "coin",
},
{
"name": "Pack school bag",
"points": 2,
"time_category": "morning",
"schedule_mode": "specific_days",
"due_days": list(_WEEKDAYS),
"requires_approval": False,
"assignment_mode": "everyone",
"daily_limit": 1,
"completion_sound": "coin",
},
], ],
}, },
{ {
@@ -34,10 +94,50 @@ BUILT_IN_TEMPLATES: list[dict] = [
"icon": "mdi:weather-night", "icon": "mdi:weather-night",
"builtin": True, "builtin": True,
"chores": [ "chores": [
{"name": "Brush teeth", "points": 1, "time_category": "evening", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"}, {
{"name": "Put on pyjamas", "points": 1, "time_category": "evening", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"}, "name": "Brush teeth",
{"name": "Tidy room", "points": 2, "time_category": "evening", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"}, "points": 1,
{"name": "Set out clothes for tomorrow", "points": 1, "time_category": "evening", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"}, "time_category": "evening",
"schedule_mode": "specific_days",
"due_days": list(_WEEKDAYS),
"requires_approval": False,
"assignment_mode": "everyone",
"daily_limit": 1,
"completion_sound": "coin",
},
{
"name": "Put on pyjamas",
"points": 1,
"time_category": "evening",
"schedule_mode": "specific_days",
"due_days": list(_WEEKDAYS),
"requires_approval": False,
"assignment_mode": "everyone",
"daily_limit": 1,
"completion_sound": "coin",
},
{
"name": "Tidy room",
"points": 2,
"time_category": "evening",
"schedule_mode": "specific_days",
"due_days": list(_WEEKDAYS),
"requires_approval": False,
"assignment_mode": "everyone",
"daily_limit": 1,
"completion_sound": "coin",
},
{
"name": "Set out clothes for tomorrow",
"points": 1,
"time_category": "evening",
"schedule_mode": "specific_days",
"due_days": list(_WEEKDAYS),
"requires_approval": False,
"assignment_mode": "everyone",
"daily_limit": 1,
"completion_sound": "coin",
},
], ],
}, },
{ {
@@ -46,10 +146,50 @@ BUILT_IN_TEMPLATES: list[dict] = [
"icon": "mdi:silverware-fork-knife", "icon": "mdi:silverware-fork-knife",
"builtin": True, "builtin": True,
"chores": [ "chores": [
{"name": "Set table", "points": 2, "time_category": "evening", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"}, {
{"name": "Clear plates", "points": 2, "time_category": "evening", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"}, "name": "Set table",
{"name": "Load dishwasher", "points": 3, "time_category": "evening", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"}, "points": 2,
{"name": "Wipe counters", "points": 2, "time_category": "evening", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"}, "time_category": "evening",
"schedule_mode": "specific_days",
"due_days": list(_WEEKDAYS),
"requires_approval": False,
"assignment_mode": "everyone",
"daily_limit": 1,
"completion_sound": "coin",
},
{
"name": "Clear plates",
"points": 2,
"time_category": "evening",
"schedule_mode": "specific_days",
"due_days": list(_WEEKDAYS),
"requires_approval": False,
"assignment_mode": "everyone",
"daily_limit": 1,
"completion_sound": "coin",
},
{
"name": "Load dishwasher",
"points": 3,
"time_category": "evening",
"schedule_mode": "specific_days",
"due_days": list(_WEEKDAYS),
"requires_approval": False,
"assignment_mode": "everyone",
"daily_limit": 1,
"completion_sound": "coin",
},
{
"name": "Wipe counters",
"points": 2,
"time_category": "evening",
"schedule_mode": "specific_days",
"due_days": list(_WEEKDAYS),
"requires_approval": False,
"assignment_mode": "everyone",
"daily_limit": 1,
"completion_sound": "coin",
},
], ],
}, },
{ {
@@ -58,10 +198,50 @@ BUILT_IN_TEMPLATES: list[dict] = [
"icon": "mdi:broom", "icon": "mdi:broom",
"builtin": True, "builtin": True,
"chores": [ "chores": [
{"name": "Tidy bedroom", "points": 3, "time_category": "anytime", "schedule_mode": "specific_days", "due_days": list(_WEEKENDS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"}, {
{"name": "Hoover", "points": 4, "time_category": "anytime", "schedule_mode": "specific_days", "due_days": list(_WEEKENDS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"}, "name": "Tidy bedroom",
{"name": "Help with laundry", "points": 3, "time_category": "anytime", "schedule_mode": "specific_days", "due_days": list(_WEEKENDS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"}, "points": 3,
{"name": "Take bins out", "points": 2, "time_category": "anytime", "schedule_mode": "specific_days", "due_days": list(_WEEKENDS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"}, "time_category": "anytime",
"schedule_mode": "specific_days",
"due_days": list(_WEEKENDS),
"requires_approval": False,
"assignment_mode": "everyone",
"daily_limit": 1,
"completion_sound": "coin",
},
{
"name": "Hoover",
"points": 4,
"time_category": "anytime",
"schedule_mode": "specific_days",
"due_days": list(_WEEKENDS),
"requires_approval": False,
"assignment_mode": "everyone",
"daily_limit": 1,
"completion_sound": "coin",
},
{
"name": "Help with laundry",
"points": 3,
"time_category": "anytime",
"schedule_mode": "specific_days",
"due_days": list(_WEEKENDS),
"requires_approval": False,
"assignment_mode": "everyone",
"daily_limit": 1,
"completion_sound": "coin",
},
{
"name": "Take bins out",
"points": 2,
"time_category": "anytime",
"schedule_mode": "specific_days",
"due_days": list(_WEEKENDS),
"requires_approval": False,
"assignment_mode": "everyone",
"daily_limit": 1,
"completion_sound": "coin",
},
], ],
}, },
{ {
@@ -70,10 +250,50 @@ BUILT_IN_TEMPLATES: list[dict] = [
"icon": "mdi:paw", "icon": "mdi:paw",
"builtin": True, "builtin": True,
"chores": [ "chores": [
{"name": "Feed pet", "points": 2, "time_category": "morning", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"}, {
{"name": "Fill water bowl", "points": 1, "time_category": "morning", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"}, "name": "Feed pet",
{"name": "Walk dog", "points": 3, "time_category": "afternoon", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"}, "points": 2,
{"name": "Clean litter tray", "points": 3, "time_category": "anytime", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"}, "time_category": "morning",
"schedule_mode": "specific_days",
"due_days": list(_WEEKDAYS),
"requires_approval": False,
"assignment_mode": "everyone",
"daily_limit": 1,
"completion_sound": "coin",
},
{
"name": "Fill water bowl",
"points": 1,
"time_category": "morning",
"schedule_mode": "specific_days",
"due_days": list(_WEEKDAYS),
"requires_approval": False,
"assignment_mode": "everyone",
"daily_limit": 1,
"completion_sound": "coin",
},
{
"name": "Walk dog",
"points": 3,
"time_category": "afternoon",
"schedule_mode": "specific_days",
"due_days": list(_WEEKDAYS),
"requires_approval": False,
"assignment_mode": "everyone",
"daily_limit": 1,
"completion_sound": "coin",
},
{
"name": "Clean litter tray",
"points": 3,
"time_category": "anytime",
"schedule_mode": "specific_days",
"due_days": list(_WEEKDAYS),
"requires_approval": False,
"assignment_mode": "everyone",
"daily_limit": 1,
"completion_sound": "coin",
},
], ],
}, },
{ {
@@ -82,9 +302,39 @@ BUILT_IN_TEMPLATES: list[dict] = [
"icon": "mdi:book-open-variant", "icon": "mdi:book-open-variant",
"builtin": True, "builtin": True,
"chores": [ "chores": [
{"name": "Do homework", "points": 3, "time_category": "afternoon", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"}, {
{"name": "Read for 20 minutes", "points": 2, "time_category": "anytime", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"}, "name": "Do homework",
{"name": "Practice instrument", "points": 2, "time_category": "anytime", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"}, "points": 3,
"time_category": "afternoon",
"schedule_mode": "specific_days",
"due_days": list(_WEEKDAYS),
"requires_approval": False,
"assignment_mode": "everyone",
"daily_limit": 1,
"completion_sound": "coin",
},
{
"name": "Read for 20 minutes",
"points": 2,
"time_category": "anytime",
"schedule_mode": "specific_days",
"due_days": list(_WEEKDAYS),
"requires_approval": False,
"assignment_mode": "everyone",
"daily_limit": 1,
"completion_sound": "coin",
},
{
"name": "Practice instrument",
"points": 2,
"time_category": "anytime",
"schedule_mode": "specific_days",
"due_days": list(_WEEKDAYS),
"requires_approval": False,
"assignment_mode": "everyone",
"daily_limit": 1,
"completion_sound": "coin",
},
], ],
}, },
] ]
+2 -3
View File
@@ -5,6 +5,7 @@ today (via the shared get_due_chores_for_child). Checking an item off completes
the chore, so the native HA to-do card and voice assistants can drive TaskMate the chore, so the native HA to-do card and voice assistants can drive TaskMate
without the custom cards. without the custom cards.
""" """
from __future__ import annotations from __future__ import annotations
from homeassistant.components.todo import ( from homeassistant.components.todo import (
@@ -23,9 +24,7 @@ from .const import DOMAIN
from .coordinator import TaskMateCoordinator from .coordinator import TaskMateCoordinator
async def async_setup_entry( async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None:
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up a to-do list per child, adding new children as they appear.""" """Set up a to-do list per child, adding new children as they appear."""
coordinator: TaskMateCoordinator = hass.data[DOMAIN][entry.entry_id] coordinator: TaskMateCoordinator = hass.data[DOMAIN][entry.entry_id]
tracked: set[str] = set() tracked: set[str] = set()
File diff suppressed because it is too large Load Diff
@@ -153,6 +153,7 @@
"badges.editor_help": "Konfigurieren Sie per YAML: Setzen Sie entity auf Ihren Badges-Sensor (z.B. sensor.taskmate_badges_mia).", "badges.editor_help": "Konfigurieren Sie per YAML: Setzen Sie entity auf Ihren Badges-Sensor (z.B. sensor.taskmate_badges_mia).",
"badges.entity_not_found": "{entity} nicht gefunden", "badges.entity_not_found": "{entity} nicht gefunden",
"badges.label": "Abzeichen", "badges.label": "Abzeichen",
"badges.next_up": "Als Nächstes",
"badges.title_with_name": "Abzeichen von {name}", "badges.title_with_name": "Abzeichen von {name}",
"bonuses.add_bonus": "Bonus hinzufügen", "bonuses.add_bonus": "Bonus hinzufügen",
"bonuses.applying_to": "Beantragung bei {childName} aktuelles Guthaben: {points} {pointsName}", "bonuses.applying_to": "Beantragung bei {childName} aktuelles Guthaben: {points} {pointsName}",
@@ -154,6 +154,7 @@
"badges.editor_help": "Configure via YAML: set entity to your badges sensor (e.g. sensor.taskmate_badges_mia).", "badges.editor_help": "Configure via YAML: set entity to your badges sensor (e.g. sensor.taskmate_badges_mia).",
"badges.entity_not_found": "{entity} not found", "badges.entity_not_found": "{entity} not found",
"badges.label": "Badges", "badges.label": "Badges",
"badges.next_up": "Next up",
"badges.title_with_name": "{name}'s Badges", "badges.title_with_name": "{name}'s Badges",
"bonuses.add_bonus": "Add Bonus", "bonuses.add_bonus": "Add Bonus",
"bonuses.applying_to": "Applying to {childName} — current balance: {points} {pointsName}", "bonuses.applying_to": "Applying to {childName} — current balance: {points} {pointsName}",
@@ -154,6 +154,7 @@
"badges.editor_help": "Configure via YAML: set entity to your badges sensor (e.g. sensor.taskmate_badges_mia).", "badges.editor_help": "Configure via YAML: set entity to your badges sensor (e.g. sensor.taskmate_badges_mia).",
"badges.entity_not_found": "{entity} not found", "badges.entity_not_found": "{entity} not found",
"badges.label": "Badges", "badges.label": "Badges",
"badges.next_up": "Next up",
"badges.title_with_name": "{name}'s Badges", "badges.title_with_name": "{name}'s Badges",
"bonuses.add_bonus": "Add Bonus", "bonuses.add_bonus": "Add Bonus",
"bonuses.applying_to": "Applying to {childName} — current balance: {points} {pointsName}", "bonuses.applying_to": "Applying to {childName} — current balance: {points} {pointsName}",
@@ -153,6 +153,7 @@
"badges.editor_help": "Configurez via YAML : définissez entity sur votre capteur de badges (ex. sensor.taskmate_badges_mia).", "badges.editor_help": "Configurez via YAML : définissez entity sur votre capteur de badges (ex. sensor.taskmate_badges_mia).",
"badges.entity_not_found": "{entity} introuvable", "badges.entity_not_found": "{entity} introuvable",
"badges.label": "Badges", "badges.label": "Badges",
"badges.next_up": "Prochain",
"badges.title_with_name": "Badges de {name}", "badges.title_with_name": "Badges de {name}",
"bonuses.add_bonus": "Ajouter un bonus", "bonuses.add_bonus": "Ajouter un bonus",
"bonuses.applying_to": "Application à {childName} — solde actuel : {points} {pointsName}", "bonuses.applying_to": "Application à {childName} — solde actuel : {points} {pointsName}",
@@ -153,6 +153,7 @@
"badges.editor_help": "Konfigurer via YAML: sett entity til din badge-sensor (f.eks. sensor.taskmate_badges_mia).", "badges.editor_help": "Konfigurer via YAML: sett entity til din badge-sensor (f.eks. sensor.taskmate_badges_mia).",
"badges.entity_not_found": "{entity} ikke funnet", "badges.entity_not_found": "{entity} ikke funnet",
"badges.label": "Merker", "badges.label": "Merker",
"badges.next_up": "Neste",
"badges.title_with_name": "{name}s merker", "badges.title_with_name": "{name}s merker",
"bonuses.add_bonus": "Legg til bonus", "bonuses.add_bonus": "Legg til bonus",
"bonuses.applying_to": "Brukes på {childName} — nåværende saldo: {points} {pointsName}", "bonuses.applying_to": "Brukes på {childName} — nåværende saldo: {points} {pointsName}",
@@ -153,6 +153,7 @@
"badges.editor_help": "Konfigurer via YAML: sett entity til din badge-sensor (t.d. sensor.taskmate_badges_mia).", "badges.editor_help": "Konfigurer via YAML: sett entity til din badge-sensor (t.d. sensor.taskmate_badges_mia).",
"badges.entity_not_found": "{entity} ikkje funne", "badges.entity_not_found": "{entity} ikkje funne",
"badges.label": "Merke", "badges.label": "Merke",
"badges.next_up": "Neste",
"badges.title_with_name": "Merka til {name}", "badges.title_with_name": "Merka til {name}",
"bonuses.add_bonus": "Legg til bonus", "bonuses.add_bonus": "Legg til bonus",
"bonuses.applying_to": "Vert brukt på {childName} — noverande saldo: {points} {pointsName}", "bonuses.applying_to": "Vert brukt på {childName} — noverande saldo: {points} {pointsName}",
@@ -153,6 +153,7 @@
"badges.editor_help": "Configure via YAML: defina entity para o sensor de emblemas (ex. sensor.taskmate_badges_mia).", "badges.editor_help": "Configure via YAML: defina entity para o sensor de emblemas (ex. sensor.taskmate_badges_mia).",
"badges.entity_not_found": "{entity} não encontrado", "badges.entity_not_found": "{entity} não encontrado",
"badges.label": "Conquistas", "badges.label": "Conquistas",
"badges.next_up": "A seguir",
"badges.title_with_name": "Conquistas de {name}", "badges.title_with_name": "Conquistas de {name}",
"bonuses.add_bonus": "Adicionar Bónus", "bonuses.add_bonus": "Adicionar Bónus",
"bonuses.applying_to": "A aplicar a {childName} — saldo atual: {points} {pointsName}", "bonuses.applying_to": "A aplicar a {childName} — saldo atual: {points} {pointsName}",
@@ -153,6 +153,7 @@
"badges.editor_help": "Configure via YAML: defina entity para o sensor de emblemas (ex. sensor.taskmate_badges_mia).", "badges.editor_help": "Configure via YAML: defina entity para o sensor de emblemas (ex. sensor.taskmate_badges_mia).",
"badges.entity_not_found": "{entity} não encontrado", "badges.entity_not_found": "{entity} não encontrado",
"badges.label": "Conquistas", "badges.label": "Conquistas",
"badges.next_up": "A seguir",
"badges.title_with_name": "Conquistas de {name}", "badges.title_with_name": "Conquistas de {name}",
"bonuses.add_bonus": "Adicionar Bónus", "bonuses.add_bonus": "Adicionar Bónus",
"bonuses.applying_to": "A aplicar a {childName} — saldo atual: {points} {pointsName}", "bonuses.applying_to": "A aplicar a {childName} — saldo atual: {points} {pointsName}",
@@ -160,6 +160,13 @@ class TaskMateChildCard extends LitElement {
}[tier] || '#888'; }[tier] || '#888';
} }
_badgeKeydown(e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this._openBadgesView();
}
}
_openBadgesView() { _openBadgesView() {
const slug = this.config?.child_id const slug = this.config?.child_id
? String(this.config.child_id).toLowerCase().replace(/\s+/g, '_') ? String(this.config.child_id).toLowerCase().replace(/\s+/g, '_')
@@ -1906,6 +1913,74 @@ class TaskMateChildCard extends LitElement {
white-space: nowrap; white-space: nowrap;
} }
/* ── Next badge progress (#780) ── */
.next-badge {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
cursor: pointer;
border-bottom: 1px solid var(--divider-color, #e0e0e0);
}
.next-badge:hover { background: var(--secondary-background-color, rgba(0,0,0,0.03)); }
.next-badge:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: -2px;
}
.next-badge-icon {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
--mdc-icon-size: 16px;
color: #1a1a1a;
border: 2px dashed var(--t);
background: transparent;
flex-shrink: 0;
opacity: 0.85;
}
.next-badge-body { flex: 1; min-width: 0; }
.next-badge-top {
display: flex;
align-items: baseline;
gap: 6px;
margin-bottom: 5px;
}
.next-badge-label {
font-size: 11px;
color: var(--secondary-text-color);
white-space: nowrap;
}
.next-badge-name {
font-size: 12.5px;
font-weight: 700;
color: var(--primary-text-color);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.next-badge-count {
margin-left: auto;
font-size: 11px;
font-weight: 700;
color: var(--secondary-text-color);
white-space: nowrap;
}
.next-badge-bar {
height: 6px;
border-radius: 3px;
background: var(--divider-color, #e0e0e0);
overflow: hidden;
}
.next-badge-bar i {
display: block;
height: 100%;
border-radius: 3px;
transition: width 0.4s ease;
}
/* /*
DESIGNED STYLES (playroom / console / cleanpro) DESIGNED STYLES (playroom / console / cleanpro)
Shared .tmd kit + tokens come from taskmate-design.js styles(). Shared .tmd kit + tokens come from taskmate-design.js styles().
@@ -2042,6 +2117,49 @@ class TaskMateChildCard extends LitElement {
.tmd-badge-mini ha-icon { --mdc-icon-size: 16px; color: #fff; } .tmd-badge-mini ha-icon { --mdc-icon-size: 16px; color: #fff; }
.tmd-badges .more { font-size: 11px; font-weight: 800; color: var(--tmd-accent); } .tmd-badges .more { font-size: 11px; font-weight: 800; color: var(--tmd-accent); }
/* Designed: next badge progress (#780) */
.tmd-next-badge {
display: flex; align-items: center; gap: 9px;
padding: 9px 11px; margin-bottom: 11px;
background: var(--tmd-surface-2); border: 1px solid var(--tmd-border);
border-radius: var(--tmd-radius-sm); cursor: pointer;
}
.tmd-next-badge:focus-visible { outline: 2px solid var(--tmd-accent); outline-offset: 1px; }
.tmd-next-badge .ic {
width: 26px; height: 26px; border-radius: 50%; display: grid; place-items: center;
border: 2px dashed var(--t, var(--tmd-accent)); color: var(--tmd-dim); flex-shrink: 0;
}
.tmd-next-badge .ic ha-icon { --mdc-icon-size: 15px; }
.tmd-next-badge .bd { flex: 1; min-width: 0; }
.tmd-next-badge .top { display: flex; align-items: baseline; gap: 6px; margin-bottom: 5px; }
.tmd-next-badge .lbl {
font-size: 11px; font-weight: 800; color: var(--tmd-dim);
text-transform: uppercase; letter-spacing: .04em; white-space: nowrap;
}
.tmd-next-badge .nm {
font-size: 12.5px; font-weight: 700; color: var(--tmd-text);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.tmd-next-badge .cnt {
margin-left: auto; font-size: 11px; font-weight: 800;
color: var(--tmd-dim); white-space: nowrap;
}
.tmd-next-badge .bar {
height: 6px; border-radius: 999px; background: var(--tmd-border); overflow: hidden;
}
.tmd-next-badge .bar i {
display: block; height: 100%; border-radius: 999px; transition: width .4s ease;
}
/* Playroom is chunkier, console squares everything off, accessible needs
a solid ring rather than a dashed one to stay legible. */
:host([data-tm-design="playroom"]) .tmd-next-badge .bar { height: 8px; }
:host([data-tm-design="console"]) .tmd-next-badge .bar,
:host([data-tm-design="console"]) .tmd-next-badge .bar i { border-radius: 0; }
:host([data-tm-design="accessible"]) .tmd-next-badge .ic { border-style: solid; }
:host([data-tm-design="accessible"]) .tmd-next-badge .bar { height: 10px; }
:host([data-tm-design="accessible"]) .tmd-next-badge .nm,
:host([data-tm-design="accessible"]) .tmd-next-badge .cnt { font-size: 14px; }
/* Designed: vacation banner + swappable section */ /* Designed: vacation banner + swappable section */
.tmd-vacation { .tmd-vacation {
display: flex; align-items: center; gap: 7px; padding: 9px 11px; margin-bottom: 11px; display: flex; align-items: center; gap: 7px; padding: 9px 11px; margin-bottom: 11px;
@@ -2072,6 +2190,7 @@ class TaskMateChildCard extends LitElement {
show_countdown: true, // Show midnight reset countdown below section title show_countdown: true, // Show midnight reset countdown below section title
show_due_days_only: true, // Whether to apply due_days filtering at all show_due_days_only: true, // Whether to apply due_days filtering at all
show_badges: true, // Show badge strip between points and chores show_badges: true, // Show badge strip between points and chores
show_next_badge: true, // Show progress toward the closest unearned badge
header_color: '#9b59b6', header_color: '#9b59b6',
...config, ...config,
}; };
@@ -2172,6 +2291,7 @@ class TaskMateChildCard extends LitElement {
const badgesEntity = this._resolveBadgesEntity(child); const badgesEntity = this._resolveBadgesEntity(child);
const earnedBadges = (badgesEntity?.attributes?.earned) || []; const earnedBadges = (badgesEntity?.attributes?.earned) || [];
const showBadges = this.config.show_badges !== false && earnedBadges.length > 0; const showBadges = this.config.show_badges !== false && earnedBadges.length > 0;
const nextBadge = this._nextBadge(badgesEntity);
// Get pending points for this child // Get pending points for this child
const pendingPoints = child.pending_points || 0; const pendingPoints = child.pending_points || 0;
@@ -2256,6 +2376,28 @@ class TaskMateChildCard extends LitElement {
</div> </div>
` : ''} ` : ''}
${nextBadge ? html`
<div class="next-badge" role="button" tabindex="0"
aria-label="${this._t('badges.next_up')} — ${nextBadge.name} ${nextBadge.label}"
@click=${() => this._openBadgesView()}
@keydown=${(e) => this._badgeKeydown(e)}>
<div class="next-badge-icon" style="--t: ${this._tierColor(nextBadge.badge.tier)}">
<ha-icon icon="${nextBadge.badge.icon || 'mdi:trophy-outline'}"></ha-icon>
</div>
<div class="next-badge-body">
<div class="next-badge-top">
<span class="next-badge-label">${this._t('badges.next_up')}</span>
<span class="next-badge-name">${nextBadge.name}</span>
<span class="next-badge-count">${nextBadge.label}</span>
</div>
<div class="next-badge-bar" role="progressbar"
aria-valuenow="${nextBadge.pct}" aria-valuemin="0" aria-valuemax="100">
<i style="width: ${nextBadge.pct}%; background: ${this._tierColor(nextBadge.badge.tier)}"></i>
</div>
</div>
</div>
` : ''}
<div class="chores-container"> <div class="chores-container">
${childChores.length === 0 ${childChores.length === 0
? this._renderEmptyState() ? this._renderEmptyState()
@@ -2346,6 +2488,39 @@ class TaskMateChildCard extends LitElement {
return null; return null;
} }
/* The single closest unearned badge (#780) the kid-facing "next up" line.
`available[]` is already sorted by nothing in particular, so pick the
highest progress_pct. Badges nobody has started (0%) are skipped: a bar
stuck at zero is noise, not motivation. Returns null when there is
nothing worth showing. */
_nextBadge(badgesEntity) {
if (this.config.show_next_badge === false) return null;
const available = badgesEntity?.attributes?.available || [];
let best = null;
for (const b of available) {
const pct = Math.max(0, Math.min(100, Number(b.progress_pct) || 0));
if (pct <= 0) continue;
if (!best || pct > best.pct) best = { badge: b, pct };
}
if (!best) return null;
const c = best.badge.closest_criterion;
// Older backends (and criteria-free, manual-award badges) have no
// closest_criterion — fall back to the percentage.
best.label = c && c.target ? `${c.current} / ${c.target}` : `${best.pct}%`;
best.name = this._badgeName(best.badge);
return best;
}
// Built-in badge names arrive from the sensor in English; the localised
// name lives under badge.name_<suffix> (same scheme as the panel).
_badgeName(b) {
const id = b.badge_id || "";
if (!id.startsWith("builtin.")) return b.name;
const key = "badge.name_" + id.slice("builtin.".length);
const t = this._t(key);
return t !== key ? t : b.name;
}
_designTone(i) { return `var(--tmd-c${(i % 6) + 1})`; } _designTone(i) { return `var(--tmd-c${(i % 6) + 1})`; }
_av(child, tone, size) { _av(child, tone, size) {
@@ -2489,6 +2664,7 @@ class TaskMateChildCard extends LitElement {
const badgesEntity = this._resolveBadgesEntity(child); const badgesEntity = this._resolveBadgesEntity(child);
const earnedBadges = (badgesEntity?.attributes?.earned) || []; const earnedBadges = (badgesEntity?.attributes?.earned) || [];
const showBadges = this.config.show_badges !== false && earnedBadges.length > 0; const showBadges = this.config.show_badges !== false && earnedBadges.length > 0;
const nextBadge = this._nextBadge(badgesEntity);
const pendingPoints = child.pending_points || 0; const pendingPoints = child.pending_points || 0;
const countdown = this.config.show_countdown !== false ? this._getMidnightCountdown() : null; const countdown = this.config.show_countdown !== false ? this._getMidnightCountdown() : null;
@@ -2532,6 +2708,26 @@ class TaskMateChildCard extends LitElement {
</div>`)} </div>`)}
${earnedBadges.length > 5 ? html`<span class="more">+${earnedBadges.length - 5} →</span>` : ""} ${earnedBadges.length > 5 ? html`<span class="more">+${earnedBadges.length - 5} →</span>` : ""}
</div>` : ""} </div>` : ""}
${nextBadge ? html`
<div class="tmd-next-badge" role="button" tabindex="0"
aria-label="${this._t("badges.next_up")} — ${nextBadge.name} ${nextBadge.label}"
@click=${() => this._openBadgesView()}
@keydown=${(e) => this._badgeKeydown(e)}>
<div class="ic" style="--t:${this._tierColor(nextBadge.badge.tier)}">
<ha-icon icon="${nextBadge.badge.icon || "mdi:trophy-outline"}"></ha-icon>
</div>
<div class="bd">
<div class="top">
<span class="lbl">${this._t("badges.next_up")}</span>
<span class="nm">${nextBadge.name}</span>
<span class="cnt">${nextBadge.label}</span>
</div>
<div class="bar" role="progressbar"
aria-valuenow="${nextBadge.pct}" aria-valuemin="0" aria-valuemax="100">
<i style="width:${nextBadge.pct}%;background:${this._tierColor(nextBadge.badge.tier)}"></i>
</div>
</div>
</div>` : ""}
${sectionLine} ${sectionLine}
${this._renderRoulette(child, childChores, pointsIcon)} ${this._renderRoulette(child, childChores, pointsIcon)}
${body} ${body}
+21 -21
View File
@@ -15,9 +15,9 @@
"latest_source": "https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/images/ThirdReality/SmartPlug_Zigbee_PROD_OTA_V101_1.01.01.ota", "latest_source": "https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/images/ThirdReality/SmartPlug_Zigbee_PROD_OTA_V101_1.01.01.ota",
"latest_release_notes": null "latest_release_notes": null
}, },
"power": 0.5, "power": 0.4,
"linkquality": 116, "linkquality": 116,
"current": 1.23, "current": 0.03,
"power_on_behavior": "on" "power_on_behavior": "on"
}, },
"0xffffb40e0607af27": { "0xffffb40e0607af27": {
@@ -27,7 +27,7 @@
"led_brightness": 100, "led_brightness": 100,
"countdown_to_turn_off": 0, "countdown_to_turn_off": 0,
"countdown_to_turn_on": 0, "countdown_to_turn_on": 0,
"power": 2.1, "power": 2.7,
"current": 0.12, "current": 0.12,
"energy": 28.37, "energy": 28.37,
"power_factor": 0.18, "power_factor": 0.18,
@@ -45,10 +45,10 @@
"state": "ON", "state": "ON",
"led_brightness": 100, "led_brightness": 100,
"countdown_to_turn_off": 0, "countdown_to_turn_off": 0,
"voltage": 120.7, "voltage": 121.1,
"countdown_to_turn_on": 0, "countdown_to_turn_on": 0,
"energy": 55.33, "energy": 55.35,
"power_factor": 0.89, "power_factor": 0.2,
"ac_frequency": 60, "ac_frequency": 60,
"update": { "update": {
"state": "idle", "state": "idle",
@@ -58,8 +58,8 @@
"latest_release_notes": null "latest_release_notes": null
}, },
"linkquality": 134, "linkquality": 134,
"power": 84.6, "power": 0.5,
"current": 0.8, "current": 0.04,
"power_on_behavior": "on" "power_on_behavior": "on"
}, },
"0xb40e060fffe031e3": { "0xb40e060fffe031e3": {
@@ -74,13 +74,13 @@
"led_brightness": 100, "led_brightness": 100,
"countdown_to_turn_off": 0, "countdown_to_turn_off": 0,
"countdown_to_turn_on": 0, "countdown_to_turn_on": 0,
"voltage": 120.5, "voltage": 120.3,
"state": "ON", "state": "ON",
"ac_frequency": 60, "ac_frequency": 60,
"energy": 113.64, "energy": 113.67,
"power": 0.8, "power": 0.8,
"current": 0.05, "current": 0.87,
"power_factor": 0.41, "power_factor": 0.34,
"update": { "update": {
"state": "idle", "state": "idle",
"installed_version": 268513381, "installed_version": 268513381,
@@ -96,12 +96,12 @@
"countdown_to_turn_off": 0, "countdown_to_turn_off": 0,
"countdown_to_turn_on": 0, "countdown_to_turn_on": 0,
"voltage": 121.3, "voltage": 121.3,
"energy": 50.5, "energy": 50.52,
"state": "ON", "state": "ON",
"power": 44.2, "power": 38.6,
"current": 0.53, "current": 0.42,
"ac_frequency": 60, "ac_frequency": 60,
"power_factor": 0.76, "power_factor": 0.74,
"update": { "update": {
"state": "idle", "state": "idle",
"installed_version": 268513381, "installed_version": 268513381,
@@ -114,11 +114,11 @@
}, },
"0xffffb40e060895b3": { "0xffffb40e060895b3": {
"state": "ON", "state": "ON",
"voltage": 121, "voltage": 121.4,
"ac_frequency": 60, "ac_frequency": 60,
"energy": 7.14, "energy": 7.14,
"current": 0.01, "current": 0.01,
"power": 0.2, "power": 0.1,
"power_factor": 0.11, "power_factor": 0.11,
"linkquality": 123, "linkquality": 123,
"update": { "update": {
@@ -172,7 +172,7 @@
"0xffffb40e060893d8": { "0xffffb40e060893d8": {
"state": "ON", "state": "ON",
"led_brightness": 100, "led_brightness": 100,
"voltage": 121.2, "voltage": 121.7,
"countdown_to_turn_off": 0, "countdown_to_turn_off": 0,
"countdown_to_turn_on": 0, "countdown_to_turn_on": 0,
"energy": 3.11, "energy": 3.11,
@@ -187,8 +187,8 @@
"latest_source": "https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/images/ThirdReality/SmartPlug_Zigbee_PROD_OTA_V101_1.01.01.ota", "latest_source": "https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/images/ThirdReality/SmartPlug_Zigbee_PROD_OTA_V101_1.01.01.ota",
"latest_release_notes": null "latest_release_notes": null
}, },
"power_factor": 0, "power_factor": 0.1,
"power": 0 "power": 0.1
}, },
"0xa4c1380d0679ffff": { "0xa4c1380d0679ffff": {
"battery": 100, "battery": 100,