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."""
from __future__ import annotations
import copy
@@ -121,7 +122,15 @@ from .websocket import async_register_websocket_commands
_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
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))
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
@@ -171,6 +181,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# stack never blocks setup.
try:
from .intents import async_setup_intents
async_setup_intents(hass)
except Exception as err: # noqa: BLE001
_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)
_async_update_service_descriptions(hass)
coordinator.async_add_listener(
lambda: _async_update_service_descriptions(hass)
)
coordinator.async_add_listener(lambda: _async_update_service_descriptions(hass))
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
# instances — hass.data[DOMAIN] also holds bookkeeping flags.
remaining_entries = [
value for value in hass.data[DOMAIN].values()
if isinstance(value, TaskMateCoordinator)
]
remaining_entries = [value for value in hass.data[DOMAIN].values() if isinstance(value, TaskMateCoordinator)]
if not remaining_entries:
_async_unregister_services(hass)
hass.data[DOMAIN][SERVICES_REGISTERED] = False
@@ -329,8 +335,16 @@ async def _async_require_parent(hass: HomeAssistant, call: ServiceCall) -> None:
_AUDIT_TARGET_KEYS = (
"chore_id", "reward_id", "penalty_id", "bonus_id", "badge_id",
"task_group_id", "miss_id", "claim_id", "transaction_id", "type_id",
"chore_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]}"
break
try:
await coordinator.async_record_audit(
user_id, user_name, f"service.{call.service}", target
)
await coordinator.async_record_audit(user_id, user_name, f"service.{call.service}", target)
except Exception: # noqa: BLE001 - audit must never break the action
_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
raises it (e.g. complete_chore) passes through untouched.
"""
@wraps(handler)
async def wrapped(call: ServiceCall) -> None:
try:
await handler(call)
except ValueError as err:
raise ServiceValidationError(str(err)) from err
return wrapped
async def _async_require_linked_child(
hass: HomeAssistant, call: ServiceCall, coordinator, child_id: str
) -> None:
async def _async_require_linked_child(hass: HomeAssistant, call: ServiceCall, coordinator, child_id: str) -> None:
"""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
@@ -426,11 +438,13 @@ async def _async_register_services(hass: HomeAssistant) -> None:
def _admin(handler):
"""Wrap a service handler so only admins (or context-less calls) run it."""
@wraps(handler)
async def wrapped(call: ServiceCall) -> None:
await _async_require_admin(hass, call)
await handler(call)
await _async_record_service_audit(hass, call)
# Compose with _safe so admin handlers also convert coordinator
# ValueErrors into clean validation errors. The admin gate raises
# 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.
"""
@wraps(handler)
async def wrapped(call: ServiceCall) -> None:
await _async_require_parent(hass, call)
await handler(call)
await _async_record_service_audit(hass, call)
return _safe(wrapped)
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)
try:
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", ""),
)
except ValueError as err:
@@ -497,9 +515,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
_LOGGER.error("No TaskMate coordinator available")
return
await _async_require_linked_child(hass, call, coordinator, call.data[ATTR_CHILD_ID])
await coordinator.async_start_timed_task(
call.data[ATTR_CHORE_ID], call.data[ATTR_CHILD_ID]
)
await coordinator.async_start_timed_task(call.data[ATTR_CHORE_ID], call.data[ATTR_CHILD_ID])
async def handle_pause_timed_task(call: ServiceCall) -> None:
"""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")
return
await _async_require_linked_child(hass, call, coordinator, call.data[ATTR_CHILD_ID])
await coordinator.async_pause_timed_task(
call.data[ATTR_CHORE_ID], call.data[ATTR_CHILD_ID]
)
await coordinator.async_pause_timed_task(call.data[ATTR_CHORE_ID], call.data[ATTR_CHILD_ID])
async def handle_stop_timed_task(call: ServiceCall) -> None:
"""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")
return
await _async_require_linked_child(hass, call, coordinator, call.data[ATTR_CHILD_ID])
await coordinator.async_stop_timed_task(
call.data[ATTR_CHORE_ID], call.data[ATTR_CHILD_ID]
)
await coordinator.async_stop_timed_task(call.data[ATTR_CHORE_ID], call.data[ATTR_CHILD_ID])
async def handle_approve_chore(call: ServiceCall) -> None:
"""Handle the approve_chore service call."""
@@ -604,7 +616,9 @@ async def _async_register_services(hass: HomeAssistant) -> None:
_LOGGER.error("No TaskMate coordinator available")
return
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:
@@ -614,7 +628,8 @@ async def _async_register_services(hass: HomeAssistant) -> None:
_LOGGER.error("No TaskMate coordinator available")
return
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:
@@ -867,9 +882,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
_LOGGER.error("No TaskMate coordinator available")
return
try:
await coordinator.async_set_chore_manual_start(
call.data[ATTR_CHORE_ID], call.data[ATTR_CHILD_ID]
)
await coordinator.async_set_chore_manual_start(call.data[ATTR_CHORE_ID], call.data[ATTR_CHILD_ID])
except ValueError as err:
_LOGGER.warning("set_chore_manual_start rejected: %s", err)
raise
@@ -1154,16 +1167,22 @@ async def _async_register_services(hass: HomeAssistant) -> None:
_miss_schema = vol.Schema({vol.Required("miss_id"): cv.string})
hass.services.async_register(
DOMAIN, SERVICE_APPLY_MANDATORY_PENALTY,
_parent(handle_apply_mandatory_penalty), schema=_miss_schema,
DOMAIN,
SERVICE_APPLY_MANDATORY_PENALTY,
_parent(handle_apply_mandatory_penalty),
schema=_miss_schema,
)
hass.services.async_register(
DOMAIN, SERVICE_POSTPONE_MANDATORY_CHORE,
_parent(handle_postpone_mandatory_chore), schema=_miss_schema,
DOMAIN,
SERVICE_POSTPONE_MANDATORY_CHORE,
_parent(handle_postpone_mandatory_chore),
schema=_miss_schema,
)
hass.services.async_register(
DOMAIN, SERVICE_DISMISS_MANDATORY_CHORE,
_parent(handle_dismiss_mandatory_chore), schema=_miss_schema,
DOMAIN,
SERVICE_DISMISS_MANDATORY_CHORE,
_parent(handle_dismiss_mandatory_chore),
schema=_miss_schema,
)
hass.services.async_register(
@@ -1240,12 +1259,14 @@ async def _async_register_services(hass: HomeAssistant) -> None:
DOMAIN,
SERVICE_READ_ALOUD,
_safe(handle_read_aloud),
schema=vol.Schema({
vol.Required(ATTR_CHILD_ID): cv.string,
vol.Optional("media_player", default=""): cv.string,
vol.Optional("tts_entity", default=""): cv.string,
vol.Optional("message", default=""): cv.string,
}),
schema=vol.Schema(
{
vol.Required(ATTR_CHILD_ID): cv.string,
vol.Optional("media_player", default=""): cv.string,
vol.Optional("tts_entity", default=""): cv.string,
vol.Optional("message", default=""): cv.string,
}
),
)
hass.services.async_register(
@@ -1283,7 +1304,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
DOMAIN,
SERVICE_REJECT_REWARD,
_parent(handle_reject_reward),
schema=vol.Schema({ vol.Required("claim_id"): cv.string }),
schema=vol.Schema({vol.Required("claim_id"): cv.string}),
)
hass.services.async_register(
@@ -1342,11 +1363,28 @@ async def _async_register_services(hass: HomeAssistant) -> None:
_safe(handle_preview_sound),
schema=vol.Schema(
{
vol.Required(ATTR_SOUND): vol.In([
"none", "coin", "levelup", "fanfare", "chime", "powerup", "undo",
"fart1", "fart2", "fart3", "fart4", "fart5", "fart6", "fart7",
"fart8", "fart9", "fart10", "fart_random",
]),
vol.Required(ATTR_SOUND): vol.In(
[
"none",
"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(
DOMAIN,
SERVICE_ADD_PENALTY,
_admin(handle_add_penalty),
schema=vol.Schema({
vol.Required(ATTR_PENALTY_NAME): cv.string,
vol.Required(ATTR_PENALTY_POINTS): cv.positive_int,
vol.Optional(ATTR_PENALTY_DESCRIPTION, default=""): 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]),
}),
schema=vol.Schema(
{
vol.Required(ATTR_PENALTY_NAME): cv.string,
vol.Required(ATTR_PENALTY_POINTS): cv.positive_int,
vol.Optional(ATTR_PENALTY_DESCRIPTION, default=""): 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(
DOMAIN,
SERVICE_UPDATE_PENALTY,
_admin(handle_update_penalty),
schema=vol.Schema({
vol.Required(ATTR_PENALTY_ID): cv.string,
vol.Optional(ATTR_PENALTY_NAME): cv.string,
vol.Optional(ATTR_PENALTY_POINTS): cv.positive_int,
vol.Optional(ATTR_PENALTY_DESCRIPTION): cv.string,
vol.Optional(ATTR_PENALTY_ICON): cv.string,
vol.Optional(ATTR_PENALTY_ASSIGNED_TO): vol.All(cv.ensure_list, [cv.string]),
}),
schema=vol.Schema(
{
vol.Required(ATTR_PENALTY_ID): cv.string,
vol.Optional(ATTR_PENALTY_NAME): cv.string,
vol.Optional(ATTR_PENALTY_POINTS): cv.positive_int,
vol.Optional(ATTR_PENALTY_DESCRIPTION): 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(
@@ -1402,37 +1443,43 @@ async def _async_register_services(hass: HomeAssistant) -> None:
DOMAIN,
SERVICE_APPLY_PENALTY,
_parent(handle_apply_penalty),
schema=vol.Schema({
vol.Required(ATTR_PENALTY_ID): cv.string,
vol.Required(ATTR_CHILD_ID): cv.string,
}),
schema=vol.Schema(
{
vol.Required(ATTR_PENALTY_ID): cv.string,
vol.Required(ATTR_CHILD_ID): cv.string,
}
),
)
hass.services.async_register(
DOMAIN,
SERVICE_ADD_BONUS,
_admin(handle_add_bonus),
schema=vol.Schema({
vol.Required(ATTR_BONUS_NAME): cv.string,
vol.Required(ATTR_BONUS_POINTS): cv.positive_int,
vol.Optional(ATTR_BONUS_DESCRIPTION, default=""): 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]),
}),
schema=vol.Schema(
{
vol.Required(ATTR_BONUS_NAME): cv.string,
vol.Required(ATTR_BONUS_POINTS): cv.positive_int,
vol.Optional(ATTR_BONUS_DESCRIPTION, default=""): 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(
DOMAIN,
SERVICE_UPDATE_BONUS,
_admin(handle_update_bonus),
schema=vol.Schema({
vol.Required(ATTR_BONUS_ID): cv.string,
vol.Optional(ATTR_BONUS_NAME): cv.string,
vol.Optional(ATTR_BONUS_POINTS): cv.positive_int,
vol.Optional(ATTR_BONUS_DESCRIPTION): cv.string,
vol.Optional(ATTR_BONUS_ICON): cv.string,
vol.Optional(ATTR_BONUS_ASSIGNED_TO): vol.All(cv.ensure_list, [cv.string]),
}),
schema=vol.Schema(
{
vol.Required(ATTR_BONUS_ID): cv.string,
vol.Optional(ATTR_BONUS_NAME): cv.string,
vol.Optional(ATTR_BONUS_POINTS): cv.positive_int,
vol.Optional(ATTR_BONUS_DESCRIPTION): 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(
@@ -1446,30 +1493,32 @@ async def _async_register_services(hass: HomeAssistant) -> None:
DOMAIN,
SERVICE_APPLY_BONUS,
_parent(handle_apply_bonus),
schema=vol.Schema({
vol.Required(ATTR_BONUS_ID): cv.string,
vol.Required(ATTR_CHILD_ID): cv.string,
}),
schema=vol.Schema(
{
vol.Required(ATTR_BONUS_ID): cv.string,
vol.Required(ATTR_CHILD_ID): cv.string,
}
),
)
hass.services.async_register(
DOMAIN,
SERVICE_ADD_CHORE,
_admin(handle_add_chore),
schema=vol.Schema({
vol.Required(ATTR_CHORE_NAME): cv.string,
vol.Optional(ATTR_CHORE_DESCRIPTION, default=""): cv.string,
vol.Optional(ATTR_CHORE_POINTS, default=10): cv.positive_int,
vol.Optional(ATTR_CHORE_ASSIGNED_TO, default=[]): vol.All(cv.ensure_list, [cv.string]),
vol.Optional(ATTR_CHORE_TIME_CATEGORY, default="anytime"): vol.In(TIME_CATEGORIES),
vol.Optional("difficulty", default=DEFAULT_DIFFICULTY): vol.In(DIFFICULTY_TIERS),
vol.Optional(ATTR_CHORE_ONE_SHOT, default=False): cv.boolean,
vol.Optional(ATTR_CHORE_REQUIRES_APPROVAL, default=True): cv.boolean,
vol.Optional(ATTR_CHORE_EXPIRES_IN_MINUTES, default=0): vol.All(
cv.positive_int, vol.Range(max=10080)
),
vol.Optional(ATTR_CHORE_SPEED_BONUS_POINTS, default=0): cv.positive_int,
}),
schema=vol.Schema(
{
vol.Required(ATTR_CHORE_NAME): cv.string,
vol.Optional(ATTR_CHORE_DESCRIPTION, default=""): cv.string,
vol.Optional(ATTR_CHORE_POINTS, default=10): cv.positive_int,
vol.Optional(ATTR_CHORE_ASSIGNED_TO, default=[]): vol.All(cv.ensure_list, [cv.string]),
vol.Optional(ATTR_CHORE_TIME_CATEGORY, default="anytime"): vol.In(TIME_CATEGORIES),
vol.Optional("difficulty", default=DEFAULT_DIFFICULTY): vol.In(DIFFICULTY_TIERS),
vol.Optional(ATTR_CHORE_ONE_SHOT, default=False): cv.boolean,
vol.Optional(ATTR_CHORE_REQUIRES_APPROVAL, default=True): cv.boolean,
vol.Optional(ATTR_CHORE_EXPIRES_IN_MINUTES, default=0): vol.All(cv.positive_int, vol.Range(max=10080)),
vol.Optional(ATTR_CHORE_SPEED_BONUS_POINTS, default=0): cv.positive_int,
}
),
)
hass.services.async_register(
@@ -1483,33 +1532,39 @@ async def _async_register_services(hass: HomeAssistant) -> None:
DOMAIN,
SERVICE_SET_CHORE_MANUAL_START,
_admin(handle_set_chore_manual_start),
schema=vol.Schema({
vol.Required(ATTR_CHORE_ID): cv.string,
vol.Required(ATTR_CHILD_ID): cv.string,
}),
schema=vol.Schema(
{
vol.Required(ATTR_CHORE_ID): cv.string,
vol.Required(ATTR_CHILD_ID): cv.string,
}
),
)
hass.services.async_register(
DOMAIN,
SERVICE_ADD_TASK_GROUP,
_admin(handle_add_task_group),
schema=vol.Schema({
vol.Required(CONF_TASK_GROUP_NAME): 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]),
}),
schema=vol.Schema(
{
vol.Required(CONF_TASK_GROUP_NAME): 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(
DOMAIN,
SERVICE_UPDATE_TASK_GROUP,
_admin(handle_update_task_group),
schema=vol.Schema({
vol.Required(CONF_TASK_GROUP_ID): cv.string,
vol.Optional(CONF_TASK_GROUP_NAME): 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]),
}),
schema=vol.Schema(
{
vol.Required(CONF_TASK_GROUP_ID): cv.string,
vol.Optional(CONF_TASK_GROUP_NAME): 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(
@@ -1523,36 +1578,40 @@ async def _async_register_services(hass: HomeAssistant) -> None:
DOMAIN,
"add_badge",
_admin(handle_add_badge),
schema=vol.Schema({
vol.Required(ATTR_BADGE_NAME): cv.string,
vol.Optional(ATTR_BADGE_DESCRIPTION, default=""): cv.string,
vol.Optional(ATTR_BADGE_ICON, default="mdi:trophy"): cv.string,
vol.Optional(ATTR_BADGE_TIER, default="bronze"): vol.In(["bronze", "silver", "gold", "platinum"]),
vol.Optional(ATTR_BADGE_POINT_BONUS, default=0): vol.Coerce(int),
vol.Optional(ATTR_BADGE_CRITERIA, default=[]): list,
vol.Optional(ATTR_BADGE_COMBINATOR, default="AND"): cv.string,
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,
}),
schema=vol.Schema(
{
vol.Required(ATTR_BADGE_NAME): cv.string,
vol.Optional(ATTR_BADGE_DESCRIPTION, default=""): cv.string,
vol.Optional(ATTR_BADGE_ICON, default="mdi:trophy"): cv.string,
vol.Optional(ATTR_BADGE_TIER, default="bronze"): vol.In(["bronze", "silver", "gold", "platinum"]),
vol.Optional(ATTR_BADGE_POINT_BONUS, default=0): vol.Coerce(int),
vol.Optional(ATTR_BADGE_CRITERIA, default=[]): list,
vol.Optional(ATTR_BADGE_COMBINATOR, default="AND"): cv.string,
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(
DOMAIN,
"update_badge",
_admin(handle_update_badge),
schema=vol.Schema({
vol.Required(ATTR_BADGE_ID): cv.string,
vol.Optional(ATTR_BADGE_NAME): cv.string,
vol.Optional(ATTR_BADGE_DESCRIPTION): cv.string,
vol.Optional(ATTR_BADGE_ICON): cv.string,
vol.Optional(ATTR_BADGE_TIER): vol.In(["bronze", "silver", "gold", "platinum"]),
vol.Optional(ATTR_BADGE_POINT_BONUS): vol.Coerce(int),
vol.Optional(ATTR_BADGE_CRITERIA): list,
vol.Optional(ATTR_BADGE_COMBINATOR): cv.string,
vol.Optional(ATTR_BADGE_ASSIGNED_TO): vol.All(cv.ensure_list, [cv.string]),
vol.Optional(ATTR_BADGE_ENABLED): cv.boolean,
vol.Optional(ATTR_BADGE_NOTIFY_ON_EARN): cv.boolean,
}),
schema=vol.Schema(
{
vol.Required(ATTR_BADGE_ID): cv.string,
vol.Optional(ATTR_BADGE_NAME): cv.string,
vol.Optional(ATTR_BADGE_DESCRIPTION): cv.string,
vol.Optional(ATTR_BADGE_ICON): cv.string,
vol.Optional(ATTR_BADGE_TIER): vol.In(["bronze", "silver", "gold", "platinum"]),
vol.Optional(ATTR_BADGE_POINT_BONUS): vol.Coerce(int),
vol.Optional(ATTR_BADGE_CRITERIA): list,
vol.Optional(ATTR_BADGE_COMBINATOR): cv.string,
vol.Optional(ATTR_BADGE_ASSIGNED_TO): vol.All(cv.ensure_list, [cv.string]),
vol.Optional(ATTR_BADGE_ENABLED): cv.boolean,
vol.Optional(ATTR_BADGE_NOTIFY_ON_EARN): cv.boolean,
}
),
)
hass.services.async_register(
@@ -1566,10 +1625,12 @@ async def _async_register_services(hass: HomeAssistant) -> None:
DOMAIN,
"award_badge_manually",
_parent(handle_award_badge_manually),
schema=vol.Schema({
vol.Required(ATTR_BADGE_ID): cv.string,
vol.Required(ATTR_CHILD_ID): cv.string,
}),
schema=vol.Schema(
{
vol.Required(ATTR_BADGE_ID): cv.string,
vol.Required(ATTR_CHILD_ID): cv.string,
}
),
)
hass.services.async_register(
@@ -1,4 +1,5 @@
"""Binary sensor platform for TaskMate integration."""
from __future__ import annotations
from homeassistant.components.binary_sensor import BinarySensorEntity
+6 -13
View File
@@ -1,4 +1,5 @@
"""Button platform for TaskMate integration."""
from __future__ import annotations
import logging
@@ -38,15 +39,11 @@ async def async_setup_entry(
if getattr(chore, "assignment_mode", "everyone") == "unassigned":
continue
if not chore.assigned_to or child.id in chore.assigned_to:
entities.append(
CompleteChoreButton(coordinator, entry, child, chore)
)
entities.append(CompleteChoreButton(coordinator, entry, child, chore))
# Reward claim buttons
for reward in rewards:
entities.append(
ClaimRewardButton(coordinator, entry, child, reward)
)
entities.append(ClaimRewardButton(coordinator, entry, child, reward))
# Track which entity combos already exist
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:
key = f"{child.id}_{chore.id}_complete"
if key not in tracked_combos:
new_entities.append(
CompleteChoreButton(coordinator, entry, child, chore)
)
new_entities.append(CompleteChoreButton(coordinator, entry, child, chore))
tracked_combos.add(key)
for reward in current_rewards:
key = f"{child.id}_{reward.id}_claim"
if key not in tracked_combos:
new_entities.append(
ClaimRewardButton(coordinator, entry, child, reward)
)
new_entities.append(ClaimRewardButton(coordinator, entry, child, reward))
tracked_combos.add(key)
if new_entities:
@@ -142,7 +135,7 @@ class CompleteChoreButton(TaskMateBaseButton):
# Chores gained an optional icon in #683, defaulting to "". Fall back on
# falsiness, not on the attribute being absent — otherwise every chore
# without a picture gets a blank button icon.
return (getattr(chore, 'icon', "") or "mdi:check-circle") if chore else "mdi:check-circle"
return (getattr(chore, "icon", "") or "mdi:check-circle") if chore else "mdi:check-circle"
@property
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
supported.
"""
from __future__ import annotations
import logging
@@ -67,9 +68,7 @@ async def async_setup_entry(
coordinator.async_add_listener(_async_add_new)
def _chore_applies_to_child(
coordinator: TaskMateCoordinator, chore: Chore, child_id: str, day: date
) -> bool:
def _chore_applies_to_child(coordinator: TaskMateCoordinator, chore: Chore, child_id: str, day: date) -> bool:
"""True if ``chore`` is scheduled for ``child_id`` on ``day``.
Combines the recurrence schedule with the assignment engine so the calendar
@@ -165,9 +164,7 @@ class TaskMateCalendar(CoordinatorEntity, CalendarEntity):
return []
return self._build_events(child, start_date.date(), end_date.date())
def _build_events(
self, child: Child, start_day: date, end_day: date
) -> list[CalendarEvent]:
def _build_events(self, child: Child, start_day: date, end_day: date) -> list[CalendarEvent]:
coord = self.coordinator
events: list[CalendarEvent] = []
@@ -197,25 +194,27 @@ class TaskMateCalendar(CoordinatorEntity, CalendarEntity):
for chore in chores:
if not _chore_applies_to_child(coord, chore, child.id, day):
continue
window = coord._time_category_window(
getattr(chore, "time_category", "anytime"), day
)
window = coord._time_category_window(getattr(chore, "time_category", "anytime"), day)
desc = _chore_description(chore)
if window is None:
events.append(CalendarEvent(
start=day,
end=day + timedelta(days=1),
summary=chore.name,
description=desc,
))
events.append(
CalendarEvent(
start=day,
end=day + timedelta(days=1),
summary=chore.name,
description=desc,
)
)
else:
start_dt, end_dt = window
events.append(CalendarEvent(
start=start_dt.replace(tzinfo=tz),
end=end_dt.replace(tzinfo=tz),
summary=chore.name,
description=desc,
))
events.append(
CalendarEvent(
start=start_dt.replace(tzinfo=tz),
end=end_dt.replace(tzinfo=tz),
summary=chore.name,
description=desc,
)
)
day += timedelta(days=1)
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
the integration's own ``Store`` rather than in ``config_entry.options``.
"""
from __future__ import annotations
from typing import Any
@@ -24,9 +25,7 @@ class TaskMateConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
async def async_step_user(self, user_input: dict[str, Any] | None = None) -> FlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
+38 -37
View File
@@ -1,4 +1,5 @@
"""Constants for TaskMate integration."""
from typing import Final
DOMAIN: Final = "taskmate"
@@ -131,10 +132,10 @@ TIME_CATEGORY_ICONS: Final = {
# always available) and never appears in this list. An empty label means
# "use the translated built-in name for this id".
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": "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": "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"},
]
MAX_TIME_PERIODS: Final = 24
@@ -340,24 +341,24 @@ STATE_CLAIMED: Final = "claimed"
# Most sounds are synthesized via Web Audio API
# Fart sounds are CC0 audio files from BigSoundBank.com and GfxSounds.com
COMPLETION_SOUND_OPTIONS: Final = [
"none", # No sound
"coin", # Coin collect sound
"levelup", # Level up / success sound
"fanfare", # Celebratory fanfare
"chime", # Simple chime
"powerup", # Power up sound
"undo", # Sad/descending "womp womp" for undo actions
"fart1", # Flatulence 1 (short)
"fart2", # Flatulence 2 (short)
"fart3", # Flatulence 3 (short)
"fart4", # Pony flatulence 2 (~3 sec)
"fart5", # Flatulence 4 - discreet (short)
"fart6", # Prout'cochons 1 - pig game sound (short)
"fart7", # Prout'cochons 2 - pig game sound (short)
"fart8", # Prout'cochons 3 - pig game sound (short)
"fart9", # Pony flatulence 1 (short)
"fart10", # Baby fart (short)
"fart_random", # Random fart - picks a random fart sound each time!
"none", # No sound
"coin", # Coin collect sound
"levelup", # Level up / success sound
"fanfare", # Celebratory fanfare
"chime", # Simple chime
"powerup", # Power up sound
"undo", # Sad/descending "womp womp" for undo actions
"fart1", # Flatulence 1 (short)
"fart2", # Flatulence 2 (short)
"fart3", # Flatulence 3 (short)
"fart4", # Pony flatulence 2 (~3 sec)
"fart5", # Flatulence 4 - discreet (short)
"fart6", # Prout'cochons 1 - pig game sound (short)
"fart7", # Prout'cochons 2 - pig game sound (short)
"fart8", # Prout'cochons 3 - pig game sound (short)
"fart9", # Pony flatulence 1 (short)
"fart10", # Baby fart (short)
"fart_random", # Random fart - picks a random fart sound each time!
]
# Default completion sound
@@ -377,22 +378,22 @@ DEFAULT_DIFFICULTY: Final = "medium"
DEFAULT_DIFFICULTY_MULTIPLIERS: Final = {"easy": 0.5, "medium": 1.0, "hard": 2.0}
# --- Notification type IDs (v3.9.0) ---
NOTIF_TYPE_BEDTIME_REMINDER: Final = "bedtime_reminder"
NOTIF_TYPE_STREAK_AT_RISK: Final = "streak_at_risk"
NOTIF_TYPE_ALL_CHORES_DONE: Final = "all_chores_done"
NOTIF_TYPE_BADGE_EARNED: Final = "badge_earned"
NOTIF_TYPE_PENDING_CHORE_APPROVAL: Final = "pending_chore_approval"
NOTIF_TYPE_PENDING_REWARD_CLAIM: Final = "pending_reward_claim"
NOTIF_TYPE_STREAK_MILESTONE: Final = "streak_milestone"
NOTIF_TYPE_LEVEL_UP: Final = "level_up"
NOTIF_TYPE_WEEKLY_DIGEST: Final = "weekly_digest"
NOTIF_TYPE_CELEBRATION: Final = "celebration"
NOTIF_TYPE_MANDATORY_REMINDER: Final = "mandatory_reminder"
NOTIF_TYPE_MANDATORY_PARENT_ALERT: Final = "mandatory_parent_alert"
NOTIF_TYPE_MONTHLY_REPORT: Final = "monthly_report"
NOTIF_TYPE_SEASON_CHAMPION: Final = "season_champion"
NOTIF_TYPE_FAMILY_GOAL_REACHED: Final = "family_goal_reached"
NOTIF_TYPE_BEDTIME_REMINDER: Final = "bedtime_reminder"
NOTIF_TYPE_STREAK_AT_RISK: Final = "streak_at_risk"
NOTIF_TYPE_ALL_CHORES_DONE: Final = "all_chores_done"
NOTIF_TYPE_BADGE_EARNED: Final = "badge_earned"
NOTIF_TYPE_PENDING_CHORE_APPROVAL: Final = "pending_chore_approval"
NOTIF_TYPE_PENDING_REWARD_CLAIM: Final = "pending_reward_claim"
NOTIF_TYPE_STREAK_MILESTONE: Final = "streak_milestone"
NOTIF_TYPE_LEVEL_UP: Final = "level_up"
NOTIF_TYPE_WEEKLY_DIGEST: Final = "weekly_digest"
NOTIF_TYPE_CELEBRATION: Final = "celebration"
NOTIF_TYPE_MANDATORY_REMINDER: Final = "mandatory_reminder"
NOTIF_TYPE_MANDATORY_PARENT_ALERT: Final = "mandatory_parent_alert"
NOTIF_TYPE_MONTHLY_REPORT: Final = "monthly_report"
NOTIF_TYPE_SEASON_CHAMPION: Final = "season_champion"
NOTIF_TYPE_FAMILY_GOAL_REACHED: Final = "family_goal_reached"
# 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.
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."""
from __future__ import annotations
import asyncio
@@ -118,9 +119,15 @@ class AssignmentsMixin:
await self.storage.async_save()
await self.async_refresh()
_AVAILABLE_STATES: frozenset[str] = frozenset({
"on", "home", "available", "present", "true",
})
_AVAILABLE_STATES: frozenset[str] = frozenset(
{
"on",
"home",
"available",
"present",
"true",
}
)
def _is_visibility_entity_active(
self, visibility_entity: str, visibility_state: str, visibility_operator: str = "equals"
@@ -201,7 +208,7 @@ class AssignmentsMixin:
return True
# 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():
if str(attr_value).lower() == parsed_state.lower():
return True
@@ -232,7 +239,8 @@ class AssignmentsMixin:
if state_obj is None or state_obj.state in ("unavailable", "unknown", None, ""):
_LOGGER.debug(
"Weather entity '%s' unavailable, not blocking chore '%s'",
entity_id, getattr(chore, "name", ""),
entity_id,
getattr(chore, "name", ""),
)
return None
@@ -352,6 +360,26 @@ class AssignmentsMixin:
return cached
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]:
mode = getattr(chore, "assignment_mode", "everyone")
require_availability = getattr(chore, "require_availability", False)
@@ -359,6 +387,16 @@ class AssignmentsMixin:
if mode == "unassigned":
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":
# Competitive: every child in the resolved pool sees it until the
# first completion fills the shared quota (see _is_rotation_done_today).
@@ -458,9 +496,7 @@ class AssignmentsMixin:
return result
def _apply_sticky_policy(
self, group, chore_by_id: dict[str, Chore], result: dict[str, str]
) -> None:
def _apply_sticky_policy(self, group, chore_by_id: dict[str, Chore], result: dict[str, str]) -> None:
"""Force followers onto the leader chore's assignee (when in pool)."""
leader_id = group.chore_ids[0]
leader_child = result.get(leader_id)
@@ -478,12 +514,12 @@ class AssignmentsMixin:
else:
_LOGGER.debug(
"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(
self, group, chore_by_id: dict[str, Chore], result: dict[str, str]
) -> None:
def _apply_spread_policy(self, group, chore_by_id: dict[str, Chore], result: dict[str, str]) -> None:
"""Assign group members to distinct children; wraps when pool < group size."""
used: set[str] = set()
for chore_id in group.chore_ids:
@@ -529,17 +565,18 @@ class AssignmentsMixin:
size = len(pool)
# Cache per-call so the same child isn't queried twice in a scan.
cache: dict[str, bool] = {}
def available(cid: str) -> bool:
if cid not in cache:
cache[cid] = self._is_child_available(cid)
return cache[cid]
for step in range(size):
cid = pool[(start_idx + step) % size]
if available(cid):
return cid
_LOGGER.debug(
"Availability skip: no available child in pool %s for chore, "
"hiding chore (all children unavailable)",
"Availability skip: no available child in pool %s for chore, hiding chore (all children unavailable)",
pool,
)
return ""
@@ -557,7 +594,7 @@ class AssignmentsMixin:
active child still has uncompleted bonus sub-tasks for today, keep
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
# PERF-1: result depends only on the chore; memoize per availability build.
cache = getattr(self, "_avail_cache", None)
@@ -573,7 +610,7 @@ class AssignmentsMixin:
if not pool:
return False
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
completed_bonus_ids_today: set[str] = set()
for comp in self._cached_completions():
@@ -581,14 +618,14 @@ class AssignmentsMixin:
continue
comp_dt = comp.completed_at
try:
if hasattr(comp_dt, 'astimezone'):
if hasattr(comp_dt, "astimezone"):
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):
continue
if comp_date != today:
continue
bonus_id = getattr(comp, 'bonus_subtask_id', None)
bonus_id = getattr(comp, "bonus_subtask_id", None)
if bonus_id:
# Bonus completions don't count toward the parent's daily
# 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__":
completions_today += 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
else:
daily_limit = getattr(chore, 'daily_limit', 1) or 1
daily_limit = getattr(chore, "daily_limit", 1) or 1
if completions_today < daily_limit:
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:
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:
return False
return True
@@ -621,8 +658,8 @@ class AssignmentsMixin:
Runs at midnight. All chores are processed concurrently so the runtime
is bounded by the slowest single publish, not the sum across chores.
Also clears stale skip state (skip_date != today) so yesterday's skip
doesn't bleed into the new day.
Also clears stale skip and swap state (dated != today) so yesterday's
skip or approved sibling swap doesn't bleed into the new day.
"""
today = dt_util.as_local(dt_util.now()).date()
today_iso = today.isoformat()
@@ -630,11 +667,16 @@ class AssignmentsMixin:
if not chores:
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:
if getattr(chore, "skip_date", "") and chore.skip_date != today_iso:
chore.skip_date = ""
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.
daily = self._compute_daily_assignments(today)
@@ -650,11 +692,15 @@ class AssignmentsMixin:
await self._publish_chore_to_calendars(chore, today)
if list(getattr(chore, "publish_calendar_published_dates", []) or []) != before:
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:
stored = self.storage.get_chore(chore.id)
if stored and (stored.skip_date or stored.skip_count):
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:
self.storage.update_chore(chore)
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
to any avatar they've unlocked; parents can set any catalogue avatar.
"""
from __future__ import annotations
import logging
@@ -14,14 +15,14 @@ _LOGGER = logging.getLogger(__name__)
# Shipped defaults so the feature is useful out of the box. Parents can replace
# the whole list from the panel.
DEFAULT_AVATAR_CATALOG: list[dict] = [
{"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": "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": "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": "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": "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": "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": "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": "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},
]
@@ -67,13 +68,15 @@ class AvatarsMixin:
req = f"{value}-day streak"
else:
req = ""
out.append({
"id": entry.get("id", entry.get("icon")),
"label": entry.get("label", ""),
"icon": entry.get("icon"),
"unlocked": self._avatar_unlocked(entry, child),
"requirement": req,
})
out.append(
{
"id": entry.get("id", entry.get("icon")),
"label": entry.get("label", ""),
"icon": entry.get("icon"),
"unlocked": self._avatar_unlocked(entry, child),
"requirement": req,
}
)
return out
async def async_update_avatar_catalog(self, catalog: list[dict]) -> None:
@@ -83,13 +86,15 @@ class AvatarsMixin:
icon = (a.get("icon") or "").strip()
if not icon:
continue
cleaned.append({
"id": (a.get("id") or icon).strip(),
"label": (a.get("label") or "").strip(),
"icon": icon,
"unlock_type": a.get("unlock_type", "free"),
"unlock_value": int(a.get("unlock_value", 0) or 0),
})
cleaned.append(
{
"id": (a.get("id") or icon).strip(),
"label": (a.get("label") or "").strip(),
"icon": icon,
"unlock_type": a.get("unlock_type", "free"),
"unlock_value": int(a.get("unlock_value", 0) or 0),
}
)
self.storage.set_setting("avatar_catalog", cleaned)
await self.storage.async_save()
await self.async_refresh()
+119 -41
View File
@@ -1,4 +1,5 @@
"""Badge evaluation engine and built-in catalogue."""
from __future__ import annotations
import logging
@@ -8,8 +9,9 @@ from .models import Badge, BadgeCriterion, Child
_LOGGER = logging.getLogger(__name__)
def _b(id_suffix: str, name: str, description: str, icon: str, tier: str,
point_bonus: int, metric: str, value: int) -> Badge:
def _b(
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."""
criteria = [BadgeCriterion(metric=metric, operator=">=", value=value)] if metric else []
badge = Badge(
@@ -29,39 +31,116 @@ def _b(id_suffix: str, name: str, description: str, icon: str, tier: str,
BUILTIN_CATALOGUE: list[Badge] = [
# Bronze
_b("first_chore", "First Chore", "Complete your very first chore",
"mdi:check-circle", "bronze", 0, "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),
_b(
"first_chore",
"First Chore",
"Complete your very first chore",
"mdi:check-circle",
"bronze",
0,
"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
_b("500_points", "500 Points", "Earn 500 lifetime points",
"mdi:star-circle", "silver", 25, "total_points", 500),
_b("50_chores", "50 Chores Completed", "Complete 50 chores",
"mdi:checkbox-multiple-marked-circle", "silver", 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),
_b("500_points", "500 Points", "Earn 500 lifetime points", "mdi:star-circle", "silver", 25, "total_points", 500),
_b(
"50_chores",
"50 Chores Completed",
"Complete 50 chores",
"mdi:checkbox-multiple-marked-circle",
"silver",
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
_b("1000_points", "1000 Points", "Earn 1000 lifetime points",
"mdi:trophy", "gold", 100, "total_points", 1000),
_b("100_chores", "100 Chores Completed", "Complete 100 chores",
"mdi:trophy-variant", "gold", 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),
_b("1000_points", "1000 Points", "Earn 1000 lifetime points", "mdi:trophy", "gold", 100, "total_points", 1000),
_b(
"100_chores",
"100 Chores Completed",
"Complete 100 chores",
"mdi:trophy-variant",
"gold",
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
_b("5000_points", "5000 Points", "Earn 5000 lifetime points",
"mdi:diamond-stone", "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),
_b(
"5000_points",
"5000 Points",
"Earn 5000 lifetime points",
"mdi:diamond-stone",
"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":
return 1 if (child.total_chores_completed or 0) >= 1 else 0
if metric in ("total_rewards", "first_reward"):
approved_count = sum(
1 for c in storage.get_reward_claims()
if c.child_id == child.id and c.approved
)
approved_count = sum(1 for c in storage.get_reward_claims() if c.child_id == child.id and c.approved)
if metric == "first_reward":
return 1 if approved_count >= 1 else 0
return approved_count
@@ -240,7 +316,9 @@ class BadgeCoordinator:
self.storage.add_awarded_badge(award)
if bonus > 0:
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(
"taskmate_badge_earned",
@@ -260,9 +338,7 @@ class BadgeCoordinator:
async def revoke(self, awarded_id: str) -> bool:
"""Revoke an awarded badge; reverse bonus_credited if > 0."""
matching = [
a for a in self.storage.get_awarded_badges() if a.id == awarded_id
]
matching = [a for a in self.storage.get_awarded_badges() if a.id == awarded_id]
if not matching:
return False
award = matching[0]
@@ -288,7 +364,9 @@ class BadgeCoordinator:
total = 0
for child in self.storage.get_children():
new_awards = await self.evaluate_for_child(
child.id, "manual", silent=True,
child.id,
"manual",
silent=True,
)
total += len(new_awards)
return total
+23 -20
View File
@@ -1,4 +1,5 @@
"""Calendar operations mixin for TaskMateCoordinator."""
from __future__ import annotations
import asyncio
@@ -55,13 +56,15 @@ class CalendarMixin:
pid = str(entry.get("id") or "").strip()
if not pid or pid == "anytime" or start is None or end is None:
continue
periods.append({
"id": pid,
"label": str(entry.get("label") or "").strip(),
"start": start.strftime("%H:%M"),
"end": end.strftime("%H:%M"),
"icon": str(entry.get("icon") or "") or TIME_CATEGORY_ICONS.get(pid, "mdi:clock-outline"),
})
periods.append(
{
"id": pid,
"label": str(entry.get("label") or "").strip(),
"start": start.strftime("%H:%M"),
"end": end.strftime("%H:%M"),
"icon": str(entry.get("icon") or "") or TIME_CATEGORY_ICONS.get(pid, "mdi:clock-outline"),
}
)
if periods:
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"])
start = self._parse_hhmm(start_str) or self._parse_hhmm(default["start"])
end = self._parse_hhmm(end_str) or self._parse_hhmm(default["end"])
periods.append({
"id": pid,
"label": "",
"start": start.strftime("%H:%M"),
"end": end.strftime("%H:%M"),
"icon": default["icon"],
})
periods.append(
{
"id": pid,
"label": "",
"start": start.strftime("%H:%M"),
"end": end.strftime("%H:%M"),
"icon": default["icon"],
}
)
return sorted(periods, key=lambda p: p["start"])
def _get_time_boundaries(self) -> dict[str, tuple[time, time] | None]:
@@ -109,9 +114,9 @@ class CalendarMixin:
def _calendar_projection_days(self) -> int:
"""Return the configured projection horizon, clamped to the allowed range."""
try:
raw = int(float(self.storage.get_setting(
"calendar_projection_days", str(DEFAULT_CALENDAR_PROJECTION_DAYS)
)))
raw = int(
float(self.storage.get_setting("calendar_projection_days", str(DEFAULT_CALENDAR_PROJECTION_DAYS)))
)
except (TypeError, ValueError):
raw = DEFAULT_CALENDAR_PROJECTION_DAYS
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:
"""Build the calendar.create_event payload for one (chore, day)."""
description = self._chore_event_marker(chore)
window = self._time_category_window(
getattr(chore, "time_category", "anytime"), day
)
window = self._time_category_window(getattr(chore, "time_category", "anytime"), day)
if window is None:
return {
"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
new Monday-anchored week).
"""
from __future__ import annotations
import logging
@@ -89,19 +90,21 @@ class ChallengesMixin:
_, period_key = self._period_start_key(ch.scope)
prog = self.storage.get_challenge_child_progress(ch.id, child_id)
awarded = bool(prog.get("awarded")) and prog.get("period") == period_key
out.append({
"challenge_id": ch.id,
"name": ch.name,
"icon": ch.icon,
"scope": ch.scope,
"metric": ch.metric,
"target": ch.target,
"progress": min(value, ch.target),
"value": value,
"bonus_points": ch.bonus_points,
"complete": value >= ch.target,
"awarded": awarded,
})
out.append(
{
"challenge_id": ch.id,
"name": ch.name,
"icon": ch.icon,
"scope": ch.scope,
"metric": ch.metric,
"target": ch.target,
"progress": min(value, ch.target),
"value": value,
"bonus_points": ch.bonus_points,
"complete": value >= ch.target,
"awarded": awarded,
}
)
return out
# ── Evaluation ───────────────────────────────────────────────────────
@@ -142,24 +145,36 @@ class ChallengesMixin:
child.points += bonus
child.total_points_earned += bonus
child.career_score = child.total_points_earned - child.total_penalties_received
self.storage.add_points_transaction(PointsTransaction(
child_id=child.id, points=bonus,
reason=f"Challenge complete: {challenge.name}", created_at=dt_util.now(),
))
self.storage.add_points_transaction(
PointsTransaction(
child_id=child.id,
points=bonus,
reason=f"Challenge complete: {challenge.name}",
created_at=dt_util.now(),
)
)
if hasattr(self, "_maybe_level_up"):
await self._maybe_level_up(child)
self.storage.update_child(child)
self.hass.bus.async_fire("taskmate_challenge_completed", {
"child_id": child.id, "child_name": child.name,
"challenge_id": challenge.id, "challenge_name": challenge.name,
"scope": challenge.scope, "bonus": bonus,
"timestamp": dt_util.now().isoformat(),
})
self.hass.bus.async_fire(
"taskmate_challenge_completed",
{
"child_id": child.id,
"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"):
await self._celebrate(
child, "challenge_completed",
child,
"challenge_completed",
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)
+188 -148
View File
@@ -1,4 +1,5 @@
"""Chore operations mixin for TaskMateCoordinator."""
from __future__ import annotations
import logging
@@ -26,10 +27,15 @@ def _add_months(d: date, months: int) -> date:
_DOW_MAP = {
'monday': 0, 'tuesday': 1, 'wednesday': 2, 'thursday': 3,
'friday': 4, 'saturday': 5, 'sunday': 6,
"monday": 0,
"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:
@@ -124,7 +130,9 @@ class ChoresMixin:
# For random/balanced manual-start, override today's cached child so
# the parent sees the chosen child immediately.
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:
chore.assignment_current_child_id = manual_start_child_id
self.storage.add_chore(chore)
@@ -138,6 +146,7 @@ class ChoresMixin:
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."""
from .models import generate_id
chore = self.get_chore(chore_id)
if not chore:
raise ValueError(f"Chore {chore_id} not found")
@@ -164,20 +173,37 @@ class ChoresMixin:
async def async_approve_swap(self, req_id: str) -> None:
"""Approve a swap — reassign today's chore to the requester."""
req = next((r for r in self.storage.get_swap_requests()
if r.get("id") == req_id and r.get("status") == "pending"), None)
req = next(
(r for r in self.storage.get_swap_requests() if r.get("id") == req_id and r.get("status") == "pending"),
None,
)
if not req:
raise ValueError(f"Swap request {req_id} not found")
chore = self.get_chore(req["chore_id"])
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"]
self.storage.update_chore(chore)
self.storage.update_swap_request(req_id, status="approved")
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(),
})
# Consume the request, exactly as rejection does (#783). Nothing reads a
# request once it leaves "pending" — both readers filter on it — so
# keeping it would grow the store forever. The approval stays observable
# through the event below and the chore's own dated override. `req` is
# 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.async_refresh()
@@ -197,7 +223,8 @@ class ChoresMixin:
except (ValueError, TypeError):
_LOGGER.warning(
"Chore '%s' has an unparseable deadline_at %r — ignoring it",
getattr(chore, "name", ""), raw,
getattr(chore, "name", ""),
raw,
)
return None
# A naive value came from hand-edited storage; treat it as local time
@@ -237,7 +264,8 @@ class ChoresMixin:
changed = True
_LOGGER.info(
"Reactive chore '%s' expired (deadline %s)",
chore.name, getattr(chore, "deadline_at", ""),
chore.name,
getattr(chore, "deadline_at", ""),
)
self.hass.bus.async_fire(
"taskmate_chore_expired",
@@ -297,6 +325,8 @@ class ChoresMixin:
data["assignment_current_child_id"] = ""
data["skip_date"] = ""
data["skip_count"] = 0
data["assignment_swap_child_id"] = ""
data["assignment_swap_date"] = ""
data["publish_calendar_published_dates"] = []
data["disabled_for"] = []
data["enabled"] = True
@@ -352,9 +382,7 @@ class ChoresMixin:
await self.async_refresh()
return count
async def async_approve_chores_bulk(
self, completion_ids: list[str] | None = None
) -> int:
async def async_approve_chores_bulk(self, completion_ids: list[str] | None = None) -> int:
"""Approve several pending chore completions at once. Returns count approved.
If completion_ids is given, only those (still-pending) completions are
@@ -390,7 +418,7 @@ class ChoresMixin:
visibility_entity: str = "",
visibility_state: str = "on",
visibility_operator: str = "equals",
) -> list[Chore]:
) -> list[Chore]:
"""Add multiple chores at once with shared settings."""
chores = []
for name in chore_names:
@@ -412,7 +440,7 @@ class ChoresMixin:
visibility_entity=visibility_entity,
visibility_state=visibility_state,
visibility_operator=visibility_operator,
)
)
self.storage.add_chore(chore)
chores.append(chore)
@@ -453,7 +481,10 @@ class ChoresMixin:
extra_prefixes.append(f"{prev_name}")
extra_prefixes.append(f"{chore.name}")
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)
# Replacing or clearing the picture orphans the old file; delete it —
@@ -464,9 +495,7 @@ class ChoresMixin:
await self.storage.async_save()
await self.async_refresh()
async def _async_release_image(
self, image_url: str, *, excluding_chore_id: str = ""
) -> None:
async def _async_release_image(self, image_url: str, *, excluding_chore_id: str = "") -> None:
"""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
@@ -493,9 +522,7 @@ class ChoresMixin:
# Nothing sweeps taskmate_images, so the file has to go with the chore —
# unless a clone still shows it (#768).
if existing is not None and getattr(existing, "image_url", ""):
await self._async_release_image(
existing.image_url, excluding_chore_id=chore_id
)
await self._async_release_image(existing.image_url, excluding_chore_id=chore_id)
self.storage.remove_chore(chore_id)
self.storage.remove_completions_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)
# Drop queued scheduled changes (#675) — nothing left to apply them to.
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
for child in self.storage.get_children():
if chore_id in child.chore_order:
@@ -533,9 +563,7 @@ class ChoresMixin:
# Reject skipping a sticky group follower — the group would drift.
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:
raise ValueError(
"Cannot skip a sticky group follower; skip the leader chore instead"
)
raise ValueError("Cannot skip a sticky group follower; skip the leader chore instead")
pool = self._chore_assignment_pool(chore)
if len(pool) <= 1:
@@ -549,6 +577,12 @@ class ChoresMixin:
chore.skip_date = today_iso
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.
# skip_count < pool_size → advance to next child
# 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.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_count = 0
chore.assignment_swap_child_id = ""
chore.assignment_swap_date = ""
chore.assignment_current_child_id = child_id
@@ -623,7 +661,9 @@ class ChoresMixin:
await self.async_refresh()
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.
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
# 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).
assignment_mode = getattr(chore, 'assignment_mode', 'everyone')
if assignment_mode != 'everyone':
assignment_mode = getattr(chore, "assignment_mode", "everyone")
if assignment_mode != "everyone":
if self._is_rotation_done_today(chore):
_LOGGER.debug(
"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
# member — e.g. ticking it off for the off-rotation child. first_come
# 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):
_LOGGER.debug(
"complete_chore no-op: '%s' not assigned to %s today",
chore.name, child.name,
chore.name,
child.name,
)
return None
# 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):
_LOGGER.debug(
"complete_chore no-op: '%s' not available yet (recurrence window)",
@@ -694,7 +735,7 @@ class ChoresMixin:
return None
# 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):
_LOGGER.debug(
"complete_chore no-op: '%s' not available (one-shot done or expired)",
@@ -718,11 +759,13 @@ class ChoresMixin:
if comp_dt.date() == today:
todays_completions_count += 1
daily_limit = getattr(chore, 'daily_limit', 1)
daily_limit = getattr(chore, "daily_limit", 1)
if todays_completions_count >= daily_limit:
_LOGGER.debug(
"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
@@ -781,7 +824,7 @@ class ChoresMixin:
self.storage.set_last_completed(chore_id, child_id, now.isoformat())
# One-shot: if auto-approved, disable for this child immediately
if getattr(chore, 'schedule_mode', 'specific_days') == 'one_shot' and auto_approve:
if getattr(chore, "schedule_mode", "specific_days") == "one_shot" and auto_approve:
if child_id not in chore.disabled_for:
chore.disabled_for.append(child_id)
self._check_one_shot_fully_disabled(chore)
@@ -792,8 +835,11 @@ class ChoresMixin:
# Fire approval notification only if it stays pending
if not auto_approve:
await self._async_notify_pending_approval(
child.name, chore.name, chore.points,
completion_id=completion.id, photo_url=completion.photo_url,
child.name,
chore.name,
chore.points,
completion_id=completion.id,
photo_url=completion.photo_url,
)
await self.async_refresh()
@@ -819,19 +865,17 @@ class ChoresMixin:
if not chore:
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")
schedule_mode = getattr(chore, 'schedule_mode', 'specific_days')
if schedule_mode == 'one_shot':
raise ValueError(
f"Chore '{chore.name}' is a one-shot chore and cannot be parent-completed"
)
schedule_mode = getattr(chore, "schedule_mode", "specific_days")
if schedule_mode == "one_shot":
raise ValueError(f"Chore '{chore.name}' is a one-shot chore and cannot be parent-completed")
now = dt_util.now()
# Determine child pool — empty assigned_to means all children
assigned = getattr(chore, 'assigned_to', []) or []
assigned = getattr(chore, "assigned_to", []) or []
if assigned:
child_ids = list(assigned)
else:
@@ -860,8 +904,12 @@ class ChoresMixin:
# the "Current" column / child-stats card stops pointing at the
# original child. The pointer recomputes at the next midnight refresh.
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 = ""
# 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)
await self.storage.async_save()
@@ -912,9 +960,7 @@ class ChoresMixin:
for c in all_completions
)
if already_done:
raise ValueError(
f"Bonus sub-task '{subtask.name}' already completed today."
)
raise ValueError(f"Bonus sub-task '{subtask.name}' already completed today.")
completion = ChoreCompletion(
chore_id=chore_id,
@@ -960,19 +1006,24 @@ class ChoresMixin:
comp_date = dt_util.as_local(completion.completed_at).date()
is_bonus = bool(completion.bonus_subtask_id)
if is_bonus:
subtask = next(
(b for b in chore.bonus_subtasks if b.id == completion.bonus_subtask_id), None
)
subtask = next((b for b in chore.bonus_subtasks if b.id == completion.bonus_subtask_id), None)
pts = subtask.points if subtask else 0
elif completion.timed_duration_seconds > 0 and chore.task_type == "timed":
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:
pts = self._apply_time_adjustment(
chore, self.effective_chore_points(chore), completion.completed_at
)
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,
)
completion.approved = True
@@ -984,9 +1035,7 @@ class ChoresMixin:
# reviewed (covers single approve AND "approve all", which
# reuses this method per completion).
if getattr(self, "notifications", None):
await self.notifications.clear_approval(
"pending_chore_approval", completion_id
)
await self.notifications.clear_approval("pending_chore_approval", completion_id)
self.hass.bus.async_fire(
"taskmate_chore_approved",
@@ -999,7 +1048,7 @@ class ChoresMixin:
)
# 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:
chore.disabled_for.append(completion.child_id)
self._check_one_shot_fully_disabled(chore)
@@ -1031,13 +1080,17 @@ class ChoresMixin:
{"child_name": child.name, "child_id": child.id},
)
await self._celebrate(
child, "all_chores_done",
f"{child.name} finished every chore today!", tier=1,
child,
"all_chores_done",
f"{child.name} finished every chore today!",
tier=1,
)
else:
_LOGGER.warning(
"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
_LOGGER.warning("Completion %s not found for approval", completion_id)
@@ -1082,9 +1135,7 @@ class ChoresMixin:
and c.child_id == completion.child_id
and not c.bonus_subtask_id
]
child.last_completion_date = (
max(remaining).isoformat() if remaining else None
)
child.last_completion_date = max(remaining).isoformat() if remaining else None
# Reverse any streak milestones this completion unlocked.
# Milestone bonuses are logged as separate transactions
# (not part of points_awarded), so dropping the streak
@@ -1094,24 +1145,15 @@ class ChoresMixin:
if lost:
try:
milestones = self.parse_milestone_setting(
self.storage.get_setting(
"streak_milestones", self.DEFAULT_STREAK_MILESTONES
)
self.storage.get_setting("streak_milestones", self.DEFAULT_STREAK_MILESTONES)
)
except ValueError:
milestones = self.parse_milestone_setting(
self.DEFAULT_STREAK_MILESTONES
)
milestones = self.parse_milestone_setting(self.DEFAULT_STREAK_MILESTONES)
refund = sum(milestones.get(d, 0) for d in lost)
if refund > 0:
child.points = max(0, child.points - refund)
child.total_points_earned = max(
0, child.total_points_earned - refund
)
child.career_score = (
child.total_points_earned
- child.total_penalties_received
)
child.total_points_earned = max(0, child.total_points_earned - refund)
child.career_score = child.total_points_earned - child.total_penalties_received
self.storage.add_points_transaction(
PointsTransaction(
child_id=child.id,
@@ -1120,9 +1162,7 @@ class ChoresMixin:
created_at=dt_util.now(),
)
)
child.streak_milestones_achieved = sorted(
d for d in achieved if d <= child.current_streak
)
child.streak_milestones_achieved = sorted(d for d in achieved if d <= child.current_streak)
self.storage.update_child(child)
@@ -1133,7 +1173,8 @@ class ChoresMixin:
# chore/child on the same day (caller disposes of the records).
comp_date = dt_util.as_local(target_completion.completed_at).date()
bonus_completions = [
c for c in completions
c
for c in completions
if c.chore_id == target_completion.chore_id
and c.child_id == target_completion.child_id
and c.bonus_subtask_id
@@ -1151,13 +1192,11 @@ class ChoresMixin:
self.storage.update_child(child)
# Undo last_completed store so recurrence window resets correctly
self.storage.undo_last_completed(
target_completion.chore_id, target_completion.child_id
)
self.storage.undo_last_completed(target_completion.chore_id, target_completion.child_id)
# One-shot: re-enable for this child
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:
chore.disabled_for.remove(target_completion.child_id)
chore.enabled = True
@@ -1168,14 +1207,10 @@ class ChoresMixin:
async def async_reject_chore(self, completion_id: str) -> None:
"""Reject a chore completion and fully reverse all awards if already granted."""
completions = self.storage.get_completions()
target_completion = next(
(c for c in completions if c.id == completion_id), None
)
target_completion = next((c for c in completions if c.id == completion_id), None)
if target_completion:
bonus_completions = self._reverse_completion_awards(
target_completion, completions
)
bonus_completions = self._reverse_completion_awards(target_completion, completions)
for bc in bonus_completions:
self.storage.remove_completion(bc.id)
@@ -1190,21 +1225,22 @@ class ChoresMixin:
if target_completion:
child = self.get_child(target_completion.child_id)
chore = self.get_chore(target_completion.chore_id)
self.hass.bus.async_fire("taskmate_chore_rejected", {
"child_id": target_completion.child_id,
"child_name": getattr(child, "name", ""),
"chore_id": target_completion.chore_id,
"chore_name": getattr(chore, "name", ""),
"completion_id": completion_id,
"timestamp": dt_util.now().isoformat(),
})
self.hass.bus.async_fire(
"taskmate_chore_rejected",
{
"child_id": target_completion.child_id,
"child_name": getattr(child, "name", ""),
"chore_id": target_completion.chore_id,
"chore_name": getattr(chore, "name", ""),
"completion_id": completion_id,
"timestamp": dt_util.now().isoformat(),
},
)
# Dismiss the mobile approval push for this reviewed completion. Also
# covers undoing an already-approved chore (whose push was cleared at
# approval): re-clearing a stale tag is a harmless no-op.
if getattr(self, "notifications", None):
await self.notifications.clear_approval(
"pending_chore_approval", completion_id
)
await self.notifications.clear_approval("pending_chore_approval", completion_id)
async def async_undo_chore_approval(self, completion_id: str) -> None:
"""Undo an accidental approval: reverse the awards and return the
@@ -1240,14 +1276,17 @@ class ChoresMixin:
child = self.get_child(target.child_id)
chore = self.get_chore(target.chore_id)
self.hass.bus.async_fire("taskmate_chore_approval_undone", {
"child_id": target.child_id,
"child_name": getattr(child, "name", ""),
"chore_id": target.chore_id,
"chore_name": getattr(chore, "name", ""),
"completion_id": completion_id,
"timestamp": dt_util.now().isoformat(),
})
self.hass.bus.async_fire(
"taskmate_chore_approval_undone",
{
"child_id": target.child_id,
"child_name": getattr(child, "name", ""),
"chore_id": target.chore_id,
"chore_name": getattr(chore, "name", ""),
"completion_id": completion_id,
"timestamp": dt_util.now().isoformat(),
},
)
def _check_one_shot_fully_disabled(self, chore) -> None:
"""Check if a one-shot chore should be fully disabled (all children done)."""
@@ -1278,16 +1317,16 @@ class ChoresMixin:
return False
# 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
# 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:
return False
# 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)
if child_id not in active:
return False
@@ -1298,9 +1337,9 @@ class ChoresMixin:
return False
# Check visibility entity first — if not visible, chore is not available
visibility_entity = getattr(chore, 'visibility_entity', '')
visibility_state = getattr(chore, 'visibility_state', 'on')
visibility_operator = getattr(chore, 'visibility_operator', 'equals')
visibility_entity = getattr(chore, "visibility_entity", "")
visibility_state = getattr(chore, "visibility_state", "on")
visibility_operator = getattr(chore, "visibility_operator", "equals")
if not self._is_visibility_entity_active(visibility_entity, visibility_state, visibility_operator):
return False
@@ -1318,7 +1357,7 @@ class ChoresMixin:
# Chore dependencies (FEAT-1): this chore unlocks only once every chore
# it depends on has an approved completion today by this same child.
depends_on = getattr(chore, 'depends_on', []) or []
depends_on = getattr(chore, "depends_on", []) or []
if depends_on:
dep_today = dt_util.as_local(dt_util.now()).date()
completions = self._cached_completions()
@@ -1327,18 +1366,18 @@ class ChoresMixin:
c.chore_id == dep_id
and c.child_id == child_id
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
for c in completions
)
if not satisfied:
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
if schedule_mode == 'one_shot':
created_date = getattr(chore, 'created_date', '')
if schedule_mode == "one_shot":
created_date = getattr(chore, "created_date", "")
if created_date:
today = dt_util.as_local(dt_util.now()).date()
try:
@@ -1348,25 +1387,25 @@ class ChoresMixin:
pass
return True
if schedule_mode != 'recurring':
if schedule_mode != "recurring":
return True
recurrence = getattr(chore, 'recurrence', 'weekly')
first_occurrence_mode = getattr(chore, 'first_occurrence_mode', 'available_immediately')
recurrence_day = getattr(chore, 'recurrence_day', '')
recurrence_start = getattr(chore, 'recurrence_start', '')
recurrence = getattr(chore, "recurrence", "weekly")
first_occurrence_mode = getattr(chore, "first_occurrence_mode", "available_immediately")
recurrence_day = getattr(chore, "recurrence_day", "")
recurrence_start = getattr(chore, "recurrence_start", "")
now = dt_util.now()
today = dt_util.as_local(now).date()
window_days = {
'every_2_days': 2,
'weekly': 7,
'every_2_weeks': 14,
"every_2_days": 2,
"weekly": 7,
"every_2_weeks": 14,
}.get(recurrence, 7)
record = self.storage.get_last_completed(chore.id, child_id)
current_iso = record.get('current')
current_iso = record.get("current")
if not current_iso:
# Never completed — a future recurrence anchor always defers
@@ -1377,7 +1416,7 @@ class ChoresMixin:
return False
except ValueError:
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())
if target_dow is not None and today.weekday() != target_dow:
return False
@@ -1389,7 +1428,7 @@ class ChoresMixin:
return True
# every_2_days with anchor — check alignment
if recurrence == 'every_2_days' and recurrence_start:
if recurrence == "every_2_days" and recurrence_start:
try:
anchor = date.fromisoformat(recurrence_start)
days_since_anchor = (today - anchor).days
@@ -1402,7 +1441,7 @@ class ChoresMixin:
pass
# 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())
if target_dow is not None and today.weekday() != target_dow:
return False
@@ -1477,7 +1516,9 @@ class ChoresMixin:
changed = True
_LOGGER.info(
"Chore '%s' expired (expires_on %s, today %s)",
chore.name, expires_on, today.isoformat(),
chore.name,
expires_on,
today.isoformat(),
)
except ValueError:
continue
@@ -1492,11 +1533,11 @@ class ChoresMixin:
changed = False
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
if not getattr(chore, 'enabled', True):
if not getattr(chore, "enabled", True):
continue
created_date = getattr(chore, 'created_date', '')
created_date = getattr(chore, "created_date", "")
if not created_date:
continue
try:
@@ -1506,7 +1547,9 @@ class ChoresMixin:
changed = True
_LOGGER.info(
"One-shot chore '%s' expired (created %s, today %s)",
chore.name, created_date, today.isoformat(),
chore.name,
created_date,
today.isoformat(),
)
except ValueError:
continue
@@ -1563,18 +1606,15 @@ class ChoresMixin:
if not chore:
raise ValueError(f"Unknown chore: {chore_id}")
if getattr(chore, "assignment_mode", "everyone") == "everyone":
raise ValueError(
f"Chore '{chore.name}' uses 'everyone' mode and cannot join a group"
)
raise ValueError(f"Chore '{chore.name}' uses 'everyone' mode and cannot join a group")
existing_group = self.storage.get_task_group_for_chore(chore_id)
if existing_group and existing_group.id != exclude_group_id:
raise ValueError(
f"Chore '{chore.name}' already belongs to group '{existing_group.name}'"
)
raise ValueError(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):
"""Create a task group."""
from .models import TaskGroup
if policy not in ("sticky", "spread"):
raise ValueError(f"Unknown task group policy: {policy}")
chore_ids = list(chore_ids or [])
+56 -29
View File
@@ -1,4 +1,5 @@
"""Mandatory-chore detection, scheduling, and resolution (#532)."""
from __future__ import annotations
import logging
@@ -64,10 +65,7 @@ class MandatoryMixin:
async def async_detect_mandatory_misses(self, period_id: str, day: date) -> int:
"""Create misses for due+incomplete mandatory chores in `period_id`."""
existing = {
(m.chore_id, m.child_id, m.due_date)
for m in self.storage.get_mandatory_misses()
}
existing = {(m.chore_id, m.child_id, m.due_date) for m in self.storage.get_mandatory_misses()}
created = 0
for chore in self.storage.get_chores():
if not getattr(chore, "mandatory", False):
@@ -94,11 +92,17 @@ class MandatoryMixin:
)
self.storage.add_mandatory_miss(miss)
created += 1
self.hass.bus.async_fire("taskmate_mandatory_missed", {
"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(),
})
self.hass.bus.async_fire(
"taskmate_mandatory_missed",
{
"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:
await self.storage.async_save()
await self.async_refresh()
@@ -132,15 +136,22 @@ class MandatoryMixin:
name = getattr(chore, "name", "chore")
if miss.penalty_points > 0:
await self.async_remove_points(
miss.child_id, miss.penalty_points,
miss.child_id,
miss.penalty_points,
reason=f"Penalty: {name} (missed mandatory)",
)
self.storage.remove_mandatory_miss(miss_id)
await self.storage.async_save()
self.hass.bus.async_fire("taskmate_mandatory_penalty_applied", {
"miss_id": miss_id, "chore_id": miss.chore_id, "child_id": miss.child_id,
"points": miss.penalty_points, "timestamp": dt_util.now().isoformat(),
})
self.hass.bus.async_fire(
"taskmate_mandatory_penalty_applied",
{
"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()
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
self.storage.remove_mandatory_miss(miss_id)
await self.storage.async_save()
self.hass.bus.async_fire("taskmate_mandatory_postponed", {
"miss_id": miss_id, "chore_id": miss.chore_id, "child_id": miss.child_id,
"next_period": nxt or "", "timestamp": dt_util.now().isoformat(),
})
self.hass.bus.async_fire(
"taskmate_mandatory_postponed",
{
"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()
async def async_dismiss_mandatory_chore(self, miss_id: str) -> None:
@@ -168,10 +185,15 @@ class MandatoryMixin:
return
self.storage.remove_mandatory_miss(miss_id)
await self.storage.async_save()
self.hass.bus.async_fire("taskmate_mandatory_dismissed", {
"miss_id": miss_id, "chore_id": miss.chore_id, "child_id": miss.child_id,
"timestamp": dt_util.now().isoformat(),
})
self.hass.bus.async_fire(
"taskmate_mandatory_dismissed",
{
"miss_id": miss_id,
"chore_id": miss.chore_id,
"child_id": miss.child_id,
"timestamp": dt_util.now().isoformat(),
},
)
await self.async_refresh()
# ---- escalation (FEAT-6) ----------------------------------------------
@@ -225,12 +247,14 @@ class MandatoryMixin:
for stage in range(miss.escalation_stage + 1, target + 1):
if stage in (1, 2):
await self.notifications.fire(
NOTIF_TYPE_MANDATORY_REMINDER, ctx,
NOTIF_TYPE_MANDATORY_REMINDER,
ctx,
only_recipients={f"child:{miss.child_id}"},
)
elif stage == 3:
await self.notifications.fire(
NOTIF_TYPE_MANDATORY_PARENT_ALERT, ctx,
NOTIF_TYPE_MANDATORY_PARENT_ALERT,
ctx,
)
miss.escalation_stage = target
self.storage.update_mandatory_miss(miss)
@@ -261,13 +285,17 @@ class MandatoryMixin:
unsub = async_track_time_change(
self.hass,
self._make_mandatory_period_cb(period_id),
hour=hour, minute=minute, second=10,
hour=hour,
minute=minute,
second=10,
)
self._unsub_mandatory.append(unsub)
# Reminder escalation ladder (FEAT-6) — re-evaluate open misses on a tick.
self._unsub_mandatory.append(
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):
@callback
def _cb(now: datetime) -> None:
self.hass.async_create_task(
self.async_detect_mandatory_misses(period_id, dt_util.now().date())
)
self.hass.async_create_task(self.async_detect_mandatory_misses(period_id, dt_util.now().date()))
return _cb
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
once this module is in place. They call self.notifications.fire(...).
"""
from __future__ import annotations
import logging
@@ -59,34 +60,32 @@ def _approval_tag(entry_id: str) -> str:
@dataclass(frozen=True)
class NotificationTypeMeta:
id: str
audience: str # "child" | "parent" | "both"
time_gated: bool # has its own scheduled callback
per_recipient_time: bool # if True, route.time controls the schedule per recipient
actionable: bool # carries Approve/Reject mobile actions
default_enabled: bool # default master_enabled state at install
audience: str # "child" | "parent" | "both"
time_gated: bool # has its own scheduled callback
per_recipient_time: bool # if True, route.time controls the schedule per recipient
actionable: bool # carries Approve/Reject mobile actions
default_enabled: bool # default master_enabled state at install
NOTIFICATION_TYPES: list[NotificationTypeMeta] = [
NotificationTypeMeta(NOTIF_TYPE_BEDTIME_REMINDER, "child", True, True, 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_BADGE_EARNED, "both", False, False, False, 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_STREAK_MILESTONE, "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_CELEBRATION, "both", False, False, False, False),
NotificationTypeMeta(NOTIF_TYPE_MANDATORY_REMINDER, "child", False, False, 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_ALL_CHORES_DONE, "both", False, False, False, False),
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_REWARD_CLAIM, "parent", False, False, True, True),
NotificationTypeMeta(NOTIF_TYPE_STREAK_MILESTONE, "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_CELEBRATION, "both", 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_MONTHLY_REPORT, "parent", False, False, False, False),
NotificationTypeMeta(NOTIF_TYPE_SEASON_CHAMPION, "both", False, False, False, False),
NotificationTypeMeta(NOTIF_TYPE_FAMILY_GOAL_REACHED, "both", False, False, False, False),
NotificationTypeMeta(NOTIF_TYPE_MONTHLY_REPORT, "parent", False, False, False, False),
NotificationTypeMeta(NOTIF_TYPE_SEASON_CHAMPION, "both", False, False, False, False),
NotificationTypeMeta(NOTIF_TYPE_FAMILY_GOAL_REACHED, "both", False, False, False, False),
]
NOTIFICATION_TYPES_BY_ID: dict[str, NotificationTypeMeta] = {
t.id: t for t in NOTIFICATION_TYPES
}
NOTIFICATION_TYPES_BY_ID: dict[str, NotificationTypeMeta] = {t.id: t for t in NOTIFICATION_TYPES}
def _validate_nav_url(value: str) -> str:
@@ -101,11 +100,7 @@ def _validate_nav_url(value: str) -> str:
return value
if value.lower().startswith(("http://", "https://")):
return value
if (
value.startswith("/")
and not value.startswith("//")
and not any(ord(c) <= 32 or ord(c) == 127 for c in value)
):
if value.startswith("/") and not value.startswith("//") and not any(ord(c) <= 32 or ord(c) == 127 for c in value):
return value
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):
"""str.format_map dict that leaves missing keys as `{key}` literal."""
def __missing__(self, key: str) -> str:
return "{" + key + "}"
@@ -155,11 +151,13 @@ class NotificationCoordinator:
def __init__(self, hass: HomeAssistant, storage) -> None:
self.hass = hass
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
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,
) -> None:
"""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.
"""
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 ""
if not entity_id:
@@ -241,13 +240,16 @@ class NotificationCoordinator:
return str(state.state).lower() in ("home", "on", "true", "present")
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]:
"""Which parent recipient ids should receive this notification."""
candidates = [
rid for rid, route in cfg.routes.items()
if rid.startswith("parent:") and route.enabled
and (only_recipients is None or rid in only_recipients)
rid
for rid, route in cfg.routes.items()
if rid.startswith("parent:") and route.enabled and (only_recipients is None or rid in only_recipients)
]
if not candidates:
return set()
@@ -328,20 +330,15 @@ class NotificationCoordinator:
if child is None:
return False
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:
"""Tap target for this notification: per-type override, else global default."""
per_type = (getattr(cfg, "nav_url", "") or "").strip()
if per_type:
return per_type
return str(
self.storage.get_setting(
"notification_nav_url", DEFAULT_NOTIFICATION_NAV_URL
) or ""
).strip()
return str(self.storage.get_setting("notification_nav_url", DEFAULT_NOTIFICATION_NAV_URL) or "").strip()
def _resolve_notify_service(self, recipient_id: str) -> str:
if recipient_id.startswith("child:"):
@@ -358,21 +355,21 @@ class NotificationCoordinator:
# 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.
templates = {
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_ALL_CHORES_DONE: "{child_name} finished every chore today!",
NOTIF_TYPE_BADGE_EARNED: "{child_name} earned the {badge_name} badge!",
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_ALL_CHORES_DONE: "{child_name} finished every chore today!",
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_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_LEVEL_UP: "{child_name} reached level {level}! 🎉",
NOTIF_TYPE_WEEKLY_DIGEST: "TaskMate weekly digest:\n{summary}",
NOTIF_TYPE_CELEBRATION: "🎉 {message}",
NOTIF_TYPE_MANDATORY_REMINDER: "{child_name}, you still need to do '{chore_name}'.",
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_LEVEL_UP: "{child_name} reached level {level}! 🎉",
NOTIF_TYPE_WEEKLY_DIGEST: "TaskMate weekly digest:\n{summary}",
NOTIF_TYPE_CELEBRATION: "🎉 {message}",
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_MONTHLY_REPORT: "TaskMate {month} report:\n{summary}",
NOTIF_TYPE_SEASON_CHAMPION: "🏆 {child_name} won the {month} leaderboard with {points} {points_name}!",
NOTIF_TYPE_FAMILY_GOAL_REACHED: "🎉 Family goal reached: {goal_name}! Time for {goal_reward}.",
NOTIF_TYPE_MONTHLY_REPORT: "TaskMate {month} report:\n{summary}",
NOTIF_TYPE_SEASON_CHAMPION: "🏆 {child_name} won the {month} leaderboard with {points} {points_name}!",
NOTIF_TYPE_FAMILY_GOAL_REACHED: "🎉 Family goal reached: {goal_name}! Time for {goal_reward}.",
}
tpl = context.get("message_template") or templates.get(meta.id, "")
try:
@@ -383,13 +380,14 @@ class NotificationCoordinator:
return tpl
async def _send_to(
self, notify_service: str, message: str,
meta: "NotificationTypeMeta", context: dict[str, Any], nav_url: str = "",
self,
notify_service: str,
message: str,
meta: "NotificationTypeMeta",
context: dict[str, Any],
nav_url: str = "",
) -> None:
domain, service = (
notify_service.split(".", 1) if "." in notify_service
else ("notify", notify_service)
)
domain, service = notify_service.split(".", 1) if "." in notify_service else ("notify", notify_service)
if domain != "notify":
_LOGGER.warning("notify_service must be notify.*, got %s", notify_service)
return
@@ -419,7 +417,7 @@ class NotificationCoordinator:
push["tag"] = _approval_tag(entry_id)
push["actions"] = [
{"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:
data["message"] = f"{message} {_APPROVE_IN_PANEL_HINT}"
@@ -472,10 +470,7 @@ class NotificationCoordinator:
notify_service = self._resolve_notify_service(recipient_id)
if not notify_service:
continue
domain, service = (
notify_service.split(".", 1) if "." in notify_service
else ("notify", notify_service)
)
domain, service = notify_service.split(".", 1) if "." in notify_service else ("notify", notify_service)
if domain != "notify" or not service.startswith("mobile_app"):
continue
if service in cleared_services:
@@ -483,7 +478,8 @@ class NotificationCoordinator:
cleared_services.add(service)
try:
await self.hass.services.async_call(
"notify", service,
"notify",
service,
{"message": "clear_notification", "data": {"tag": tag}},
blocking=False,
)
@@ -492,7 +488,8 @@ class NotificationCoordinator:
async def _fire_persistent_notification(self, type_id: str, message: str) -> None:
await self.hass.services.async_call(
"persistent_notification", "create",
"persistent_notification",
"create",
{
"title": "TaskMate",
"message": message,
@@ -516,7 +513,7 @@ class NotificationCoordinator:
return
if action.startswith("TASKMATE_APPROVE_"):
entry_id = action[len("TASKMATE_APPROVE_"):]
entry_id = action[len("TASKMATE_APPROVE_") :]
try:
await coordinator.async_approve_chore(entry_id)
return
@@ -527,7 +524,7 @@ class NotificationCoordinator:
except (ValueError, KeyError):
_LOGGER.info("Mobile action %s — entry not found", action)
elif action.startswith("TASKMATE_REJECT_"):
entry_id = action[len("TASKMATE_REJECT_"):]
entry_id = action[len("TASKMATE_REJECT_") :]
try:
await coordinator.async_reject_chore(entry_id)
return
@@ -591,7 +588,11 @@ class NotificationCoordinator:
_LOGGER.warning("Invalid time %r — skipping schedule", hhmm)
return
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)
@@ -606,10 +607,12 @@ class NotificationCoordinator:
"bedtime_reminder",
{"child_name": child.name, "child_id": child_id},
)
return _cb
async def _streak_at_risk_callback(self, now) -> None:
from homeassistant.util import dt as dt_util
today = dt_util.now().date().isoformat()
for child in self.storage.get_children():
if (child.current_streak or 0) < 2:
@@ -628,6 +631,7 @@ class NotificationCoordinator:
def _make_custom_callback(self, custom_id: str):
async def _cb(now):
from homeassistant.util import dt as dt_util
n = next(
(c for c in self.storage.get_custom_notifications() if c.id == custom_id),
None,
@@ -659,7 +663,8 @@ class NotificationCoordinator:
message = n.message_template
service_name = notify_service.split(".", 1)[1] if "." in notify_service else notify_service
await self.hass.services.async_call(
"notify", service_name,
"notify",
service_name,
{"title": "TaskMate", "message": message},
blocking=False,
)
@@ -667,6 +672,7 @@ class NotificationCoordinator:
"taskmate_custom_notification",
{"id": n.id, "name": n.name, "recipients": n.recipient_ids},
)
return _cb
# ------------------------------------------------------------------
@@ -702,9 +708,7 @@ class NotificationCoordinator:
if self.storage.get_notification_config(meta.id).routes:
continue # already configured — leave it alone
for p in parents:
self.storage.set_notification_route(
meta.id, p.id, NotificationRoute(enabled=True)
)
self.storage.set_notification_route(meta.id, p.id, NotificationRoute(enabled=True))
changed = True
return changed
@@ -750,13 +754,14 @@ class NotificationCoordinator:
"""Returns True if the child has at least one chore assigned today
that has no approved/pending completion yet."""
from homeassistant.util import dt as dt_util
today = dt_util.now().date()
chores = self.storage.get_chores()
completions = self.storage.get_completions()
completed_today = {
c.chore_id for c in completions
if c.child_id == child_id
and dt_util.as_local(c.completed_at).date() == today
c.chore_id
for c in completions
if c.child_id == child_id and dt_util.as_local(c.completed_at).date() == today
}
for chore in chores:
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."""
from __future__ import annotations
import logging
@@ -72,7 +73,7 @@ class PointsMixin:
return req
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
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.
if all_mode and derived_dates:
satisfied = all(
self._all_due_chores_done(
child.id, date.fromisoformat(d), include_rotation=False
)
self._all_due_chores_done(child.id, date.fromisoformat(d), include_rotation=False)
for d in derived_dates
)
else:
@@ -113,9 +112,7 @@ class PointsMixin:
child.points += perfect_week_bonus
child.total_points_earned += perfect_week_bonus
child.career_score = child.total_points_earned - child.total_penalties_received
self.storage.append_career_score_snapshot(
child.id, today.isoformat(), child.career_score
)
self.storage.append_career_score_snapshot(child.id, today.isoformat(), child.career_score)
self.storage.update_child(child)
transaction = PointsTransaction(
@@ -140,13 +137,17 @@ class PointsMixin:
if getattr(self, "badges", None):
await self.badges.evaluate_for_child(child.id, "perfect_week")
await self._celebrate(
child, "perfect_week",
child,
"perfect_week",
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(
"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:
@@ -213,17 +214,13 @@ class PointsMixin:
if streak_mode == "pause" or getattr(child, "streak_paused", False):
child.streak_paused = True
_LOGGER.info(
"Streak paused for %s (last completion: %s, mode=%s)",
child.name, last_date_str, streak_mode
"Streak paused for %s (last completion: %s, mode=%s)", child.name, last_date_str, streak_mode
)
else:
# Default: reset to 0
child.current_streak = 0
child.streak_paused = False
_LOGGER.info(
"Streak reset for %s (last completion: %s, mode=reset)",
child.name, last_date_str
)
_LOGGER.info("Streak reset for %s (last completion: %s, mode=reset)", child.name, last_date_str)
self.storage.update_child(child)
changed = True
@@ -243,9 +240,7 @@ class PointsMixin:
child.career_score = child.total_points_earned - child.total_penalties_received
await self._maybe_level_up(child)
self.storage.update_child(child)
self.storage.append_career_score_snapshot(
child_id, date.today().isoformat(), child.career_score
)
self.storage.append_career_score_snapshot(child_id, date.today().isoformat(), child.career_score)
# Log the manual transaction
transaction = PointsTransaction(
child_id=child_id,
@@ -279,9 +274,7 @@ class PointsMixin:
if reason.startswith("Penalty: "):
child.total_penalties_received += actual_deducted
child.career_score = child.total_points_earned - child.total_penalties_received
self.storage.append_career_score_snapshot(
child_id, date.today().isoformat(), child.career_score
)
self.storage.append_career_score_snapshot(child_id, date.today().isoformat(), child.career_score)
self.storage.update_child(child)
# Log the manual transaction (negative points)
transaction = PointsTransaction(
@@ -338,9 +331,7 @@ class PointsMixin:
if reason.startswith("Gift to ") or reason.startswith("Gift from "):
link_id = getattr(target, "link_id", "") or ""
if not link_id:
raise ValueError(
"This gift predates undo support and can't be reversed automatically."
)
raise ValueError("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]:
leg_child = self.get_child(leg.child_id)
if leg_child:
@@ -368,9 +359,7 @@ class PointsMixin:
# points, so nothing else to reverse.
child.career_score = child.total_points_earned - child.total_penalties_received
self.storage.update_child(child)
self.storage.append_career_score_snapshot(
child.id, dt_util.now().date().isoformat(), child.career_score
)
self.storage.append_career_score_snapshot(child.id, dt_util.now().date().isoformat(), child.career_score)
self.storage.remove_points_transaction(transaction_id)
await self.storage.async_save()
await self.async_refresh()
@@ -394,9 +383,7 @@ class PointsMixin:
lvl = xp // step + 1
return {"level": lvl, "progress": xp - (lvl - 1) * step, "target": step}
async def _celebrate(
self, child, kind: str, message: str, tier: int = 1, extra: dict | None = None
) -> None:
async def _celebrate(self, child, kind: str, message: str, tier: int = 1, extra: dict | None = None) -> None:
"""Central celebration funnel for notable moments.
Always fires a single ``taskmate_celebration`` event carrying a ``tier``
@@ -452,17 +439,30 @@ class PointsMixin:
if new < old:
return # earned total dropped (e.g. undo); resync quietly
for lvl in range(old + 1, new + 1):
self.hass.bus.async_fire("taskmate_level_up", {
"child_id": child.id, "child_name": child.name,
"level": lvl, "timestamp": dt_util.now().isoformat(),
})
self.hass.bus.async_fire(
"taskmate_level_up",
{
"child_id": child.id,
"child_name": child.name,
"level": lvl,
"timestamp": dt_util.now().isoformat(),
},
)
if getattr(self, "notifications", None):
await self.notifications.fire("level_up", {
"child_name": child.name, "child_id": child.id, "level": lvl,
})
await self.notifications.fire(
"level_up",
{
"child_name": child.name,
"child_id": child.id,
"level": lvl,
},
)
await self._celebrate(
child, "level_up", f"{child.name} reached level {lvl}!",
tier=3 if lvl % 5 == 0 else 2, extra={"level": lvl},
child,
"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:
@@ -481,9 +481,7 @@ class PointsMixin:
if not sender or not recipient:
raise ValueError("Sender or recipient not found")
if (sender.points or 0) < points:
raise ValueError(
f"Not enough points: {sender.name} has {sender.points}, gift {points}"
)
raise ValueError(f"Not enough points: {sender.name} has {sender.points}, gift {points}")
now = dt_util.now()
sender.points -= points
recipient.points += points
@@ -491,19 +489,35 @@ class PointsMixin:
self.storage.update_child(recipient)
# Shared link_id so undo can reverse both legs together.
gift_link = generate_id()
self.storage.add_points_transaction(PointsTransaction(
child_id=sender.id, points=-points,
reason=f"Gift to {recipient.name}", created_at=now, link_id=gift_link,
))
self.storage.add_points_transaction(PointsTransaction(
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(),
})
self.storage.add_points_transaction(
PointsTransaction(
child_id=sender.id,
points=-points,
reason=f"Gift to {recipient.name}",
created_at=now,
link_id=gift_link,
)
)
self.storage.add_points_transaction(
PointsTransaction(
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.async_refresh()
@@ -526,10 +540,7 @@ class PointsMixin:
return
period = self.storage.get_setting("points_decay_period", "monthly")
today = dt_util.now().date()
due = (
(period == "weekly" and today.weekday() == 0)
or (period == "monthly" and today.day == 1)
)
due = (period == "weekly" and today.weekday() == 0) or (period == "monthly" and today.day == 1)
if not due:
return
if self.storage.get_setting("points_decay_last", "") == today.isoformat():
@@ -544,14 +555,23 @@ class PointsMixin:
continue
child.points = max(0, child.points - loss)
self.storage.update_child(child)
self.storage.add_points_transaction(PointsTransaction(
child_id=child.id, points=-loss,
reason=f"Points decay (-{pct:.0f}%)", created_at=now,
))
self.hass.bus.async_fire("taskmate_points_decay", {
"child_id": child.id, "child_name": child.name,
"points": loss, "timestamp": now.isoformat(),
})
self.storage.add_points_transaction(
PointsTransaction(
child_id=child.id,
points=-loss,
reason=f"Points decay (-{pct:.0f}%)",
created_at=now,
)
)
self.hass.bus.async_fire(
"taskmate_points_decay",
{
"child_id": child.id,
"child_name": child.name,
"points": loss,
"timestamp": now.isoformat(),
},
)
changed = True
self.storage.set_setting("points_decay_last", today.isoformat())
if changed:
@@ -576,10 +596,7 @@ class PointsMixin:
return
period = self.storage.get_setting("interest_period", "weekly")
today = dt_util.now().date()
due = (
(period == "weekly" and today.weekday() == 0)
or (period == "monthly" and today.day == 1)
)
due = (period == "weekly" and today.weekday() == 0) or (period == "monthly" and today.day == 1)
if not due:
return
if self.storage.get_setting("interest_last", "") == today.isoformat():
@@ -592,10 +609,15 @@ class PointsMixin:
if interest <= 0:
continue
await self.async_add_points(child.id, interest, reason=f"Savings interest (+{pct:.0f}%)")
self.hass.bus.async_fire("taskmate_interest_paid", {
"child_id": child.id, "child_name": child.name,
"points": interest, "timestamp": dt_util.now().isoformat(),
})
self.hass.bus.async_fire(
"taskmate_interest_paid",
{
"child_id": child.id,
"child_name": child.name,
"points": interest,
"timestamp": dt_util.now().isoformat(),
},
)
self.storage.set_setting("interest_last", today.isoformat())
await self.storage.async_save()
@@ -613,17 +635,13 @@ class PointsMixin:
if not part:
continue
if ":" not in part:
raise ValueError(
f"Invalid format '{part}' — use 'days:points' pairs, e.g. '7:10, 14:20'"
)
raise ValueError(f"Invalid format '{part}' — use 'days:points' pairs, e.g. '7:10, 14:20'")
days_str, points_str = part.split(":", 1)
try:
days = int(days_str.strip())
points = int(points_str.strip())
except ValueError as err:
raise ValueError(
f"Invalid numbers in '{part}' — days and points must be whole numbers"
) from err
raise ValueError(f"Invalid numbers in '{part}' — days and points must be whole numbers") from err
if days < 1:
raise ValueError(f"Days must be at least 1, got {days}")
if points < 1:
@@ -718,7 +736,7 @@ class PointsMixin:
today = now.date()
effective_date = completion_date or today
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 ──────────────────────────────────────────────
# Applied to base chore points only, based on completion date
@@ -740,7 +758,10 @@ class PointsMixin:
if weekend_bonus > 0:
_LOGGER.info(
"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
transaction = PointsTransaction(
@@ -758,9 +779,7 @@ class PointsMixin:
# 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.
if advance_streak and self._setting_enabled("streak_requires_all_chores"):
if not self._all_due_chores_done(
child.id, effective_date, include_rotation=True, extra_done=chore_id
):
if not self._all_due_chores_done(child.id, effective_date, include_rotation=True, extra_done=chore_id):
advance_streak = False
if advance_streak:
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"
if advance_streak and milestones_enabled and child.current_streak > 0:
# Parse custom milestone config
milestone_setting = self.storage.get_setting(
"streak_milestones", self.DEFAULT_STREAK_MILESTONES
)
milestone_setting = self.storage.get_setting("streak_milestones", self.DEFAULT_STREAK_MILESTONES)
try:
milestones = self.parse_milestone_setting(milestone_setting)
except ValueError:
@@ -836,7 +853,9 @@ class PointsMixin:
reached_milestones.append((days, bonus_pts))
_LOGGER.info(
"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)
@@ -853,9 +872,7 @@ class PointsMixin:
)
self.storage.add_points_transaction(transaction)
self.storage.append_career_score_snapshot(
child.id, effective_date.isoformat(), child.career_score
)
self.storage.append_career_score_snapshot(child.id, effective_date.isoformat(), child.career_score)
await self._maybe_level_up(child)
self.storage.update_child(child)
@@ -877,9 +894,11 @@ class PointsMixin:
# A big streak is a celebration moment too — epic at 30+ days.
for days, _bonus_pts in reached_milestones:
await self._celebrate(
child, "streak_milestone",
child,
"streak_milestone",
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
@@ -890,10 +909,7 @@ class PointsMixin:
before = len(all_completions)
# Keep completions newer than cutoff OR unapproved (pending)
to_keep = [
c for c in all_completions
if c.completed_at >= cutoff or not c.approved
]
to_keep = [c for c in all_completions if c.completed_at >= cutoff or not c.approved]
if len(to_keep) < before:
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", ""):
await photos.async_delete_photo(self.hass, c.photo_url)
await self.async_refresh()
_LOGGER.info(
"Pruned %d completions older than %d days",
before - len(to_keep), days
)
_LOGGER.info("Pruned %d completions older than %d days", before - len(to_keep), days)
# Penalty operations
async def async_add_penalty(
@@ -954,12 +967,17 @@ class PointsMixin:
if not child:
raise ValueError(f"Child {child_id} not found")
await self.async_remove_points(child_id, penalty.points, reason=f"Penalty: {penalty.name}")
self.hass.bus.async_fire("taskmate_penalty_applied", {
"child_id": child.id, "child_name": child.name,
"penalty_id": penalty.id, "penalty_name": penalty.name,
"points": penalty.points,
"timestamp": dt_util.now().isoformat(),
})
self.hass.bus.async_fire(
"taskmate_penalty_applied",
{
"child_id": child.id,
"child_name": child.name,
"penalty_id": penalty.id,
"penalty_name": penalty.name,
"points": penalty.points,
"timestamp": dt_util.now().isoformat(),
},
)
# Bonus operations
async def async_add_bonus(
@@ -1004,16 +1022,25 @@ class PointsMixin:
if not child:
raise ValueError(f"Child {child_id} not found")
await self.async_add_points(child_id, bonus.points, reason=f"Bonus: {bonus.name}")
self.hass.bus.async_fire("taskmate_bonus_applied", {
"child_id": child.id, "child_name": child.name,
"bonus_id": bonus.id, "bonus_name": bonus.name,
"points": bonus.points,
"timestamp": dt_util.now().isoformat(),
})
self.hass.bus.async_fire(
"taskmate_bonus_applied",
{
"child_id": child.id,
"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(
self, child_name: str, chore_name: str, points: int,
completion_id: str | None = None, photo_url: str = "",
self,
child_name: str,
chore_name: str,
points: int,
completion_id: str | None = None,
photo_url: str = "",
) -> None:
await self.notifications.fire(
"pending_chore_approval",
@@ -1030,7 +1057,10 @@ class PointsMixin:
)
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,
) -> None:
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
resets progress (repeatable quests) or marks the quest complete for that child.
"""
from __future__ import annotations
import logging
@@ -72,17 +73,19 @@ class QuestsMixin:
step = int(prog.get("step", 0))
total = len(quest.steps)
done = step >= total
out.append({
"quest_id": quest.id,
"name": quest.name,
"icon": quest.icon,
"total_steps": total,
"step": min(step, total),
"done": done,
"times_completed": int(prog.get("completed_count", 0)),
"bonus_points": quest.bonus_points,
"next_chore_id": quest.steps[step] if not done and step < total else "",
})
out.append(
{
"quest_id": quest.id,
"name": quest.name,
"icon": quest.icon,
"total_steps": total,
"step": min(step, total),
"done": done,
"times_completed": int(prog.get("completed_count", 0)),
"bonus_points": quest.bonus_points,
"next_chore_id": quest.steps[step] if not done and step < total else "",
}
)
return out
# ── Progression ──────────────────────────────────────────────────────
@@ -129,24 +132,36 @@ class QuestsMixin:
child.points += bonus
child.total_points_earned += bonus
child.career_score = child.total_points_earned - child.total_penalties_received
self.storage.add_points_transaction(PointsTransaction(
child_id=child.id, points=bonus,
reason=f"Quest complete: {quest.name}", created_at=dt_util.now(),
))
self.storage.add_points_transaction(
PointsTransaction(
child_id=child.id,
points=bonus,
reason=f"Quest complete: {quest.name}",
created_at=dt_util.now(),
)
)
if hasattr(self, "_maybe_level_up"):
await self._maybe_level_up(child)
self.storage.update_child(child)
self.hass.bus.async_fire("taskmate_quest_completed", {
"child_id": child.id, "child_name": child.name,
"quest_id": quest.id, "quest_name": quest.name,
"bonus": bonus, "timestamp": dt_util.now().isoformat(),
})
self.hass.bus.async_fire(
"taskmate_quest_completed",
{
"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"):
await self._celebrate(
child, "quest_completed",
child,
"quest_completed",
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.
+65 -69
View File
@@ -1,4 +1,5 @@
"""Reward operations mixin for TaskMateCoordinator."""
from __future__ import annotations
import logging
@@ -82,15 +83,11 @@ class RewardsMixin:
if old and reward.cost < old.cost:
self._refund_pool_excess(reward, "Pool refund (reward cost reduced)")
became_unavailable = (
self._reward_is_unavailable(reward)
and old is not None
and not self._reward_is_unavailable(old)
self._reward_is_unavailable(reward) and old is not None and not self._reward_is_unavailable(old)
)
if became_unavailable:
reason = (
"Pool refund (reward expired)"
if self._reward_is_expired(reward)
else "Pool refund (reward sold out)"
"Pool refund (reward expired)" if self._reward_is_expired(reward) else "Pool refund (reward sold out)"
)
self._refund_all_pool_allocations(reward, reason)
await self.storage.async_save()
@@ -140,8 +137,7 @@ class RewardsMixin:
stays consistent with cost-reduction refunds.
"""
allocations = [
a for a in self.storage.get_pool_allocations()
if a.reward_id == reward.id and a.allocated_points > 0
a for a in self.storage.get_pool_allocations() if a.reward_id == reward.id and a.allocated_points > 0
]
for alloc in allocations:
self._apply_pool_refund(alloc, alloc.allocated_points, reward, reason)
@@ -154,8 +150,7 @@ class RewardsMixin:
until the combined total matches the cost.
"""
allocations = [
a for a in self.storage.get_pool_allocations()
if a.reward_id == reward.id and a.allocated_points > 0
a for a in self.storage.get_pool_allocations() if a.reward_id == reward.id and a.allocated_points > 0
]
if not allocations:
return
@@ -173,13 +168,9 @@ class RewardsMixin:
else:
for alloc in allocations:
if alloc.allocated_points > reward.cost:
self._apply_pool_refund(
alloc, alloc.allocated_points - reward.cost, reward, reason
)
self._apply_pool_refund(alloc, alloc.allocated_points - reward.cost, reward, reason)
def _apply_pool_refund(
self, allocation: PoolAllocation, refund: int, reward: Reward, reason: str
) -> None:
def _apply_pool_refund(self, allocation: PoolAllocation, refund: int, reward: Reward, reason: str) -> None:
"""Refund `refund` points from `allocation` back to the child's wallet.
Updates or removes the allocation record and writes an audit transaction.
@@ -196,19 +187,23 @@ class RewardsMixin:
if remaining <= 0:
self.storage.remove_pool_allocation(allocation.child_id, allocation.reward_id)
else:
self.storage.upsert_pool_allocation(PoolAllocation(
child_id=allocation.child_id,
reward_id=allocation.reward_id,
allocated_points=remaining,
id=allocation.id,
))
self.storage.upsert_pool_allocation(
PoolAllocation(
child_id=allocation.child_id,
reward_id=allocation.reward_id,
allocated_points=remaining,
id=allocation.id,
)
)
self.storage.add_points_transaction(PointsTransaction(
child_id=allocation.child_id,
points=refund,
reason=f"{reason}: {reward.name}",
created_at=dt_util.now(),
))
self.storage.add_points_transaction(
PointsTransaction(
child_id=allocation.child_id,
points=refund,
reason=f"{reason}: {reward.name}",
created_at=dt_util.now(),
)
)
async def async_claim_reward(self, reward_id: str, child_id: str) -> RewardClaim:
"""Child claims a reward — creates a pending claim awaiting parent approval.
@@ -261,9 +256,7 @@ class RewardsMixin:
available_points = child.points - committed
if available_points < effective_cost:
raise ValueError(
f"Not enough points. Need {effective_cost}, have {available_points} available"
)
raise ValueError(f"Not enough points. Need {effective_cost}, have {available_points} available")
claim = RewardClaim(
reward_id=reward_id,
@@ -287,7 +280,10 @@ class RewardsMixin:
await self.storage.async_save()
await self.async_refresh()
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
@@ -297,6 +293,7 @@ class RewardsMixin:
if period == "monthly":
return today.replace(day=1)
from datetime import timedelta
return today - timedelta(days=today.weekday()) # Monday of this week
def _spent_in_period(self, child_id: str) -> int:
@@ -324,9 +321,7 @@ class RewardsMixin:
if cap <= 0:
return
if self._spent_in_period(child_id) + cost > cap:
raise ValueError(
f"Spending cap reached: {cap} per period already used"
)
raise ValueError(f"Spending cap reached: {cap} per period already used")
async def async_approve_reward(self, claim_id: str) -> None:
"""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")
if reward.is_jackpot:
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
]
for alloc in jackpot_allocs:
@@ -381,9 +377,7 @@ class RewardsMixin:
else:
# Wallet mode: deduct directly from child.points
if child.points < effective_cost:
raise ValueError(
f"Not enough points to approve. Need {effective_cost}, have {child.points}"
)
raise ValueError(f"Not enough points to approve. Need {effective_cost}, have {child.points}")
child.points -= effective_cost
self.storage.update_child(child)
@@ -393,9 +387,7 @@ class RewardsMixin:
if reward.quantity == 0:
# Last unit claimed — refund any points other children
# still have earmarked for this reward's pool.
self._refund_all_pool_allocations(
reward, "Pool refund (reward sold out)"
)
self._refund_all_pool_allocations(reward, "Pool refund (reward sold out)")
claim.approved = True
claim.approved_at = dt_util.now()
@@ -405,20 +397,23 @@ class RewardsMixin:
# Dismiss the mobile approval push now this claim is reviewed.
if getattr(self, "notifications", None):
await self.notifications.clear_approval(
"pending_reward_claim", claim_id
)
await self.notifications.clear_approval("pending_reward_claim", claim_id)
# Timed unlock (#678): allowlisted entity on, auto-off later.
await self.async_start_unlock(reward, child)
self.hass.bus.async_fire("taskmate_reward_approved", {
"child_id": child.id, "child_name": child.name,
"reward_id": reward.id, "reward_name": reward.name,
"claim_id": claim.id,
"cost": effective_cost,
"timestamp": dt_util.now().isoformat(),
})
self.hass.bus.async_fire(
"taskmate_reward_approved",
{
"child_id": child.id,
"child_name": child.name,
"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):
await self.badges.evaluate_for_child(claim.child_id, "reward_redeemed")
@@ -435,23 +430,22 @@ class RewardsMixin:
if claim:
reward = self.get_reward(claim.reward_id)
child = self.get_child(claim.child_id)
self.hass.bus.async_fire("taskmate_reward_rejected", {
"child_id": claim.child_id,
"child_name": getattr(child, "name", ""),
"reward_id": claim.reward_id,
"reward_name": getattr(reward, "name", ""),
"claim_id": claim.id,
"timestamp": dt_util.now().isoformat(),
})
self.hass.bus.async_fire(
"taskmate_reward_rejected",
{
"child_id": claim.child_id,
"child_name": getattr(child, "name", ""),
"reward_id": claim.reward_id,
"reward_name": getattr(reward, "name", ""),
"claim_id": claim.id,
"timestamp": dt_util.now().isoformat(),
},
)
# Dismiss the mobile approval push for this reviewed claim.
if getattr(self, "notifications", None):
await self.notifications.clear_approval(
"pending_reward_claim", claim_id
)
await self.notifications.clear_approval("pending_reward_claim", claim_id)
async def async_allocate_points_to_pool(
self, child_id: str, reward_id: str, points: int
) -> PoolAllocation:
async def async_allocate_points_to_pool(self, child_id: str, reward_id: str, points: int) -> PoolAllocation:
"""Move `points` from a child's spendable balance into a reward pool.
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.
"""
from homeassistant.util import dt as dt_util
today = dt_util.now().date()
today_iso = today.isoformat()
changed = False
@@ -577,8 +572,7 @@ class RewardsMixin:
if not self._reward_is_expired(reward):
continue
allocations_before = [
a for a in self.storage.get_pool_allocations()
if a.reward_id == reward.id and a.allocated_points > 0
a for a in self.storage.get_pool_allocations() if a.reward_id == reward.id and a.allocated_points > 0
]
if not allocations_before:
continue
@@ -586,7 +580,9 @@ class RewardsMixin:
changed = True
_LOGGER.info(
"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:
+22 -21
View File
@@ -1,4 +1,5 @@
"""Template operations mixin for TaskMateCoordinator."""
from __future__ import annotations
import logging
@@ -70,9 +71,7 @@ class TemplatesMixin:
await self.async_refresh()
return created_ids
async def async_save_template_from_chores(
self, chore_ids: list[str], name: str, icon: str
) -> str:
async def async_save_template_from_chores(self, chore_ids: list[str], name: str, icon: str) -> str:
"""Save existing chores as a custom template pack."""
if not chore_ids:
raise ValueError("At least one chore must be selected")
@@ -97,9 +96,7 @@ class TemplatesMixin:
await self.storage.async_save()
return tpl_id
async def async_create_template(
self, name: str, icon: str, chores: list[dict]
) -> str:
async def async_create_template(self, name: str, icon: str, chores: list[dict]) -> str:
"""Create a new custom template from scratch."""
if not chores:
raise ValueError("Template must have at least one chore")
@@ -156,16 +153,19 @@ class TemplatesMixin:
for tpl in self.storage.get_custom_templates():
if wanted and tpl.get("id") not in wanted:
continue
packed.append({
"name": tpl.get("name", ""),
"icon": tpl.get("icon", "mdi:clipboard-list-outline"),
"chores": [
{k: v for k, v in chore.items() if k in TEMPLATE_CHORE_FIELDS}
for chore in tpl.get("chores", [])
],
})
packed.append(
{
"name": tpl.get("name", ""),
"icon": tpl.get("icon", "mdi:clipboard-list-outline"),
"chores": [
{k: v for k, v in chore.items() if k in TEMPLATE_CHORE_FIELDS}
for chore in tpl.get("chores", [])
],
}
)
from homeassistant.util import dt as dt_util
return {
"format": self.PACK_FORMAT,
"version": self.PACK_VERSION,
@@ -191,8 +191,7 @@ class TemplatesMixin:
raise ValueError("Pack version is not a number") from err
if version > self.PACK_VERSION:
raise ValueError(
f"This pack needs a newer TaskMate (pack version {version}, "
f"this one understands {self.PACK_VERSION})"
f"This pack needs a newer TaskMate (pack version {version}, this one understands {self.PACK_VERSION})"
)
templates = pack.get("templates")
@@ -227,11 +226,13 @@ class TemplatesMixin:
cleaned["name"] = chore_name[:200]
chores.append(cleaned)
clean.append({
"name": name[:120],
"icon": str(entry.get("icon", "") or "mdi:clipboard-list-outline"),
"chores": chores,
})
clean.append(
{
"name": name[:120],
"icon": str(entry.get("icon", "") or "mdi:clipboard-list-outline"),
"chores": chores,
}
)
return clean
async def async_import_pack(self, pack: dict) -> dict:
+7 -7
View File
@@ -1,4 +1,5 @@
"""Timed task operations mixin for TaskMateCoordinator."""
from __future__ import annotations
import logging
@@ -41,9 +42,7 @@ class TimedMixin:
# Check daily cap before resuming
if chore.timed_max_daily_minutes > 0:
if existing.total_seconds_today >= chore.timed_max_daily_minutes * 60:
raise ValueError(
f"Daily cap reached ({chore.timed_max_daily_minutes} min)"
)
raise ValueError(f"Daily cap reached ({chore.timed_max_daily_minutes} min)")
existing.state = "running"
existing.segments.append({"start": now.isoformat(), "end": None})
self.storage.save_timed_session(existing)
@@ -52,9 +51,7 @@ class TimedMixin:
if chore.timed_max_daily_minutes > 0:
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:
raise ValueError(
f"Daily cap reached ({chore.timed_max_daily_minutes} min)"
)
raise ValueError(f"Daily cap reached ({chore.timed_max_daily_minutes} min)")
session = TimedSession(
chore_id=chore_id,
child_id=child_id,
@@ -139,7 +136,10 @@ class TimedMixin:
if chore.requires_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()
+116 -72
View File
@@ -1,4 +1,5 @@
"""Data coordinator for TaskMate integration."""
from __future__ import annotations
import logging
@@ -107,11 +108,7 @@ class TaskMateCoordinator(
resolved = tier if tier in DEFAULT_DIFFICULTY_MULTIPLIERS else DEFAULT_DIFFICULTY
default = DEFAULT_DIFFICULTY_MULTIPLIERS[resolved]
try:
return float(
self.storage.get_setting(
f"difficulty_multiplier_{resolved}", str(default)
)
)
return float(self.storage.get_setting(f"difficulty_multiplier_{resolved}", str(default)))
except (ValueError, TypeError):
return default
@@ -145,12 +142,14 @@ class TaskMateCoordinator(
continue
if end < start:
start, end = end, start
periods.append({
"id": str(entry.get("id") or "").strip() or start.isoformat(),
"name": str(entry.get("name") or "").strip(),
"start": start.isoformat(),
"end": end.isoformat(),
})
periods.append(
{
"id": str(entry.get("id") or "").strip() or start.isoformat(),
"name": str(entry.get("name") or "").strip(),
"start": start.isoformat(),
"end": end.isoformat(),
}
)
return sorted(periods, key=lambda p: p["start"])
def active_vacation(self, on: date | None = None) -> dict | None:
@@ -253,20 +252,20 @@ class TaskMateCoordinator(
await self.async_refresh()
# ── Admin audit log ──────────────────────────────────────────────────
async def async_record_audit(
self, user_id: str, user_name: str, action: str, target: str = ""
) -> None:
async def async_record_audit(self, user_id: str, user_name: str, action: str, target: str = "") -> None:
"""Record an admin config action in the audit log and persist it."""
from .models import generate_id
self.storage.add_audit_entry({
"id": generate_id(),
"ts": dt_util.now().isoformat(),
"user_id": user_id or "",
"user_name": user_name or "",
"action": action,
"target": target or "",
})
self.storage.add_audit_entry(
{
"id": generate_id(),
"ts": dt_util.now().isoformat(),
"user_id": user_id or "",
"user_name": user_name or "",
"action": action,
"target": target or "",
}
)
await self.storage.async_save()
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
)
# Schedule daily history pruning at 00:01:00
self._unsub_prune = async_track_time_change(
self.hass, self._async_scheduled_prune, hour=0, minute=1, second=0
)
self._unsub_prune = async_track_time_change(self.hass, self._async_scheduled_prune, hour=0, minute=1, second=0)
# Re-evaluate availability-aware chore assignments when any HA entity
# state changes. The callback filters cheaply on entity id so only
# relevant flips trigger a recompute.
self._refresh_tracked_availability_entities()
self._unsub_availability = self.hass.bus.async_listen(
"state_changed", self._availability_state_changed
)
self._unsub_availability = self.hass.bus.async_listen("state_changed", self._availability_state_changed)
# Surprise-bonus daily roll at 16:00 (opt-in; no-op unless enabled)
self._unsub_surprise = async_track_time_change(
self.hass, self._async_surprise_bonus_check, hour=16, minute=0, second=0
@@ -353,10 +348,7 @@ class TaskMateCoordinator(
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)
pts = self.storage.get_points_name()
lines = [
f"{c.name}: {done.get(c.id, 0)} chores, {earned.get(c.id, 0)} {pts} earned"
for c in children
]
lines = [f"{c.name}: {done.get(c.id, 0)} chores, {earned.get(c.id, 0)} {pts} earned" for c in children]
return "\n".join(lines)
async def _async_send_monthly_report(self) -> None:
@@ -367,10 +359,13 @@ class TaskMateCoordinator(
summary = self._build_monthly_report(month_start, month_end)
if not summary:
return
await self.notifications.fire("monthly_report", {
"summary": summary,
"month": month_start.strftime("%B %Y"),
})
await self.notifications.fire(
"monthly_report",
{
"summary": summary,
"month": month_start.strftime("%B %Y"),
},
)
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."""
@@ -403,8 +398,7 @@ class TaskMateCoordinator(
ym = dt_util.now().strftime("%Y-%m")
pts = self.storage.get_season_points(ym)
rows = [
{"child_id": c.id, "name": c.name, "points": int(pts.get(c.id, 0))}
for c in self.storage.get_children()
{"child_id": c.id, "name": c.name, "points": int(pts.get(c.id, 0))} for c in self.storage.get_children()
]
rows.sort(key=lambda r: (-r["points"], r["name"].lower()))
for i, r in enumerate(rows):
@@ -434,13 +428,22 @@ class TaskMateCoordinator(
await self.storage.async_save()
name = str(self.storage.get_setting("family_goal_name", "") or "Family goal")
reward = str(self.storage.get_setting("family_goal_reward", "") or "a treat")
self.hass.bus.async_fire("taskmate_family_goal_reached", {
"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,
})
self.hass.bus.async_fire(
"taskmate_family_goal_reached",
{
"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) ─────────────────────────────────
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")
from .models import generate_id
entry = {
"id": generate_id(),
"child_id": child_id,
@@ -490,6 +494,7 @@ class TaskMateCoordinator(
token = self.storage.get_setting("ics_token", "")
if not token:
import secrets
token = secrets.token_urlsafe(24)
self.storage.set_setting("ics_token", token)
await self.storage.async_save()
@@ -498,6 +503,7 @@ class TaskMateCoordinator(
async def async_regenerate_ics_token(self) -> str:
"""Rotate the ICS feed token (invalidates existing subscriptions)."""
import secrets
token = secrets.token_urlsafe(24)
self.storage.set_setting("ics_token", token)
await self.storage.async_save()
@@ -514,22 +520,34 @@ class TaskMateCoordinator(
if not winners:
return
top = winners[0]
self.storage.add_season_champion({
"month": ym,
"child_id": top["child_id"],
"child_name": top["name"],
"points": top["points"],
})
self.storage.add_season_champion(
{
"month": ym,
"child_id": top["child_id"],
"child_name": top["name"],
"points": top["points"],
}
)
await self.storage.async_save()
self.hass.bus.async_fire("taskmate_season_champion", {
"month": ym, "child_id": top["child_id"], "child_name": top["name"],
"points": top["points"], "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(),
})
self.hass.bus.async_fire(
"taskmate_season_champion",
{
"month": ym,
"child_id": top["child_id"],
"child_name": top["name"],
"points": top["points"],
"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
def _async_surprise_bonus_check(self, now: datetime) -> None:
@@ -565,10 +583,15 @@ class TaskMateCoordinator(
if pts <= 0:
continue
await self.async_add_points(child.id, pts, reason="Surprise bonus 🎉")
self.hass.bus.async_fire("taskmate_surprise_bonus", {
"child_id": child.id, "child_name": child.name,
"points": pts, "timestamp": dt_util.now().isoformat(),
})
self.hass.bus.async_fire(
"taskmate_surprise_bonus",
{
"child_id": child.id,
"child_name": child.name,
"points": pts,
"timestamp": dt_util.now().isoformat(),
},
)
async def _async_backfill_career_history(self) -> None:
"""Backfill career_score_history from completions and transactions.
@@ -618,13 +641,12 @@ class TaskMateCoordinator(
running = start_score
for day in sorted_days:
running += daily_net[day]
self.storage.append_career_score_snapshot(
child.id, day, running
)
self.storage.append_career_score_snapshot(child.id, day, running)
needs_save = True
_LOGGER.info(
"Backfilled %d career history entries for %s",
len(sorted_days), child.name,
len(sorted_days),
child.name,
)
if needs_save:
@@ -701,9 +723,7 @@ class TaskMateCoordinator(
async def _async_sweep_orphan_photos(self) -> None:
"""Delete evidence photos not referenced by any completion (SEC-2)."""
referenced = [
getattr(c, "photo_url", "")
for c in self.storage.get_completions()
if getattr(c, "photo_url", "")
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)
if removed:
@@ -804,10 +824,34 @@ class TaskMateCoordinator(
self.storage.remove_career_score_history_for_child(child_id)
self.storage.remove_quest_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():
dirty = False
if child_id in chore.assigned_to:
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)
await self.storage.async_save()
await self.async_refresh()
+22 -17
View File
@@ -1,4 +1,5 @@
"""Frontend registration for TaskMate custom cards."""
from __future__ import annotations
import json
@@ -56,8 +57,8 @@ CARDS: Final = [
# why blanket stale-cleanup was removed from async_register_cards).
RETIRED_CARDS: Final = [
"taskmate-task-groups-card.js", # removed #452
"taskmate-templates-card.js", # removed #448
"taskmate-reminders-card.js", # removed #450
"taskmate-templates-card.js", # removed #448
"taskmate-reminders-card.js", # removed #450
]
# 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)."""
manifest_path = Path(__file__).parent / "manifest.json"
try:
content = await hass.async_add_executor_job(
manifest_path.read_text, "utf-8"
)
content = await hass.async_add_executor_job(manifest_path.read_text, "utf-8")
return json.loads(content).get("version", "1.0.0")
except (OSError, json.JSONDecodeError, AttributeError):
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)
return
# Register the www folder as a static path
await hass.http.async_register_static_paths(
[StaticPathConfig(URL_BASE, str(www_path), False)]
)
# Register the www folder as a static path.
#
# 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)
# Authenticated upload/serve endpoints for chore evidence photos.
from .http_photos import async_register_photo_views
async_register_photo_views(hass)
# Admin-gated upload / authenticated serve for chore pictures (#750).
from .http_images import async_register_image_views
async_register_image_views(hass)
# Token-gated ICS calendar feed (FEAT-10).
from .http_calendar import async_register_calendar_view
async_register_calendar_view(hass)
# 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:
# Card not registered yet — add it
await resources.async_create_item(
{"url": versioned_url, "res_type": "module"}
)
await resources.async_create_item({"url": versioned_url, "res_type": "module"})
_LOGGER.info("TaskMate: added resource: %s", versioned_url)
else:
item = existing[card_url]
@@ -220,7 +225,8 @@ async def async_register_cards(hass: HomeAssistant) -> None:
)
_LOGGER.info(
"TaskMate: updated resource: %s -> %s",
current_url, versioned_url,
current_url,
versioned_url,
)
else:
_LOGGER.debug("TaskMate: resource up to date: %s", versioned_url)
@@ -237,13 +243,12 @@ async def async_register_cards(hass: HomeAssistant) -> None:
continue
try:
await resources.async_delete_item(item["id"])
_LOGGER.info(
"TaskMate: removed retired resource: %s", item.get("url")
)
_LOGGER.info("TaskMate: removed retired resource: %s", item.get("url"))
except (AttributeError, KeyError, TypeError, OSError) as err:
_LOGGER.warning(
"TaskMate: could not remove retired resource %s: %s",
item.get("url"), err,
item.get("url"),
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
of upcoming chores; no mutation is possible.
"""
from __future__ import annotations
import hmac
@@ -28,6 +29,7 @@ HTTP_CAL_REGISTERED = "calendar_http_registered"
def _get_coordinator(hass: HomeAssistant):
from .coordinator import TaskMateCoordinator
for value in hass.data.get(DOMAIN, {}).values():
if isinstance(value, TaskMateCoordinator):
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
the thin aiohttp wrapper, verified on the dev HA instance.
"""
from __future__ import annotations
import logging
@@ -37,9 +38,7 @@ class TaskMatePhotoUploadView(HomeAssistantView):
async def post(self, request: web.Request) -> web.Response:
# Cheap pre-check on the declared length before reading the body.
if request.content_length and request.content_length > photos.MAX_UPLOAD_BYTES:
return self.json_message(
"File too large", HTTPStatus.REQUEST_ENTITY_TOO_LARGE
)
return self.json_message("File too large", HTTPStatus.REQUEST_ENTITY_TOO_LARGE)
try:
reader = await request.multipart()
@@ -61,22 +60,16 @@ class TaskMatePhotoUploadView(HomeAssistantView):
break
data.extend(chunk)
if len(data) > photos.MAX_UPLOAD_BYTES:
return self.json_message(
"File too large", HTTPStatus.REQUEST_ENTITY_TOO_LARGE
)
return self.json_message("File too large", HTTPStatus.REQUEST_ENTITY_TOO_LARGE)
ext = photos.detect_image_ext(bytes(data))
if ext is None:
return self.json_message("Not a valid image", HTTPStatus.BAD_REQUEST)
# DoS guard: reject if the photo store is already at its disk budget.
used = await self.hass.async_add_executor_job(
photos.total_photos_bytes, self.hass
)
used = await self.hass.async_add_executor_job(photos.total_photos_bytes, self.hass)
if used + len(data) > photos.MAX_TOTAL_BYTES:
return self.json_message(
"Photo storage full", HTTPStatus.INSUFFICIENT_STORAGE
)
return self.json_message("Photo storage full", HTTPStatus.INSUFFICIENT_STORAGE)
name = f"{uuid.uuid4().hex}.{ext}"
directory = photos.photos_path(self.hass)
@@ -90,9 +83,7 @@ class TaskMatePhotoUploadView(HomeAssistantView):
await self.hass.async_add_executor_job(_write)
except OSError as err:
_LOGGER.error("Failed to store evidence photo: %s", err)
return self.json_message(
"Could not store photo", HTTPStatus.INTERNAL_SERVER_ERROR
)
return self.json_message("Could not store photo", HTTPStatus.INTERNAL_SERVER_ERROR)
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
live in ``http_calendar.py``; this module only builds text.
"""
from __future__ import annotations
import hashlib
@@ -14,13 +15,7 @@ PRODID = "-//TaskMate//Chores//EN"
def _escape(text: str) -> str:
"""Escape a value per RFC 5545 (backslash, comma, semicolon, newline)."""
return (
str(text)
.replace("\\", "\\\\")
.replace("\n", "\\n")
.replace(",", "\\,")
.replace(";", "\\;")
)
return str(text).replace("\\", "\\\\").replace("\n", "\\n").replace(",", "\\,").replace(";", "\\;")
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:
if not _chore_applies_to_child(coordinator, chore, child.id, day):
continue
window = coordinator._time_category_window(
getattr(chore, "time_category", "anytime"), day
)
window = coordinator._time_category_window(getattr(chore, "time_category", "anytime"), day)
summary = f"{chore.name}{child.name}"
desc = _chore_description(chore)
if window is None:
events.append({
"uid": make_uid(chore.id, child.id, day.isoformat(), "allday"),
"summary": summary, "description": desc,
"start": day, "end": day + timedelta(days=1), "all_day": True,
})
events.append(
{
"uid": make_uid(chore.id, child.id, day.isoformat(), "allday"),
"summary": summary,
"description": desc,
"start": day,
"end": day + timedelta(days=1),
"all_day": True,
}
)
else:
start_dt, end_dt = window
events.append({
"uid": make_uid(chore.id, child.id, day.isoformat(), "timed"),
"summary": summary, "description": desc,
"start": _ensure_aware(start_dt),
"end": _ensure_aware(end_dt), "all_day": False,
})
events.append(
{
"uid": make_uid(chore.id, child.id, day.isoformat(), "timed"),
"summary": summary,
"description": desc,
"start": _ensure_aware(start_dt),
"end": _ensure_aware(end_dt),
"all_day": False,
}
)
day += timedelta(days=1)
return events
def _ensure_aware(dt: datetime) -> datetime:
from homeassistant.util import dt as dt_util
if dt.tzinfo is None:
return dt.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE)
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
pure helpers so it is unit-testable without the conversation stack.
"""
from __future__ import annotations
import logging
@@ -27,6 +28,7 @@ INTENT_POINTS = "TaskMatePoints"
def _get_coordinator(hass: HomeAssistant):
from .coordinator import TaskMateCoordinator
for value in hass.data.get(DOMAIN, {}).values():
if isinstance(value, TaskMateCoordinator):
return value
+1 -1
View File
@@ -17,5 +17,5 @@
"iot_class": "calculated",
"issue_tracker": "https://github.com/tempus2016/taskmate/issues",
"requirements": [],
"version": "5.1.0"
"version": "5.1.1"
}
+32 -16
View File
@@ -1,4 +1,5 @@
"""Data models for TaskMate integration."""
from __future__ import annotations
import logging
@@ -24,6 +25,7 @@ def generate_id() -> str:
def dt_util_now_iso() -> str:
"""Current local time as an ISO string (module-level so dataclass defaults can use it)."""
from homeassistant.util import dt as dt_util
return dt_util.now().isoformat()
@@ -172,7 +174,7 @@ class Child:
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
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)
# Guest profiles (#690): a visiting cousin gets a temporary child that
# 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
# every render site; stored as a /api/taskmate/image/<name> URL.
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
# schedule_mode: "specific_days" = show on selected days of week (Mode A)
# "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
# Mode B fields
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
first_occurrence_mode: str = "available_immediately" # available_immediately | wait_for_first_occurrence
# Dynamic visibility
@@ -293,7 +297,9 @@ class Chore:
# One-shot chore fields
enabled: bool = True # False = soft-disabled (completed or expired)
disabled_for: list[str] = field(default_factory=list) # Child IDs this chore is disabled for
depends_on: list[str] = field(default_factory=list) # Chore IDs that must be approved-completed today before this is available
depends_on: list[str] = field(
default_factory=list
) # Chore IDs that must be approved-completed today before this is available
created_date: str = "" # ISO date for one-shot expiry, e.g. "2026-04-16"
expires_on: str = "" # optional ISO end date; chore auto-disables the day after
# Reactive chores (#674): a short-lived chore raised by an automation, e.g.
@@ -316,11 +322,20 @@ class Chore:
# Dynamic assignment (sibling rotation)
assignment_mode: str = "everyone" # everyone | alternating | random
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
# Skip state (ephemeral: cleared at midnight when skip_date != today)
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
# 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.
publish_calendar_entities: list[str] = field(default_factory=list)
# ISO dates already written to the configured calendars. Used for both
@@ -392,6 +407,8 @@ class Chore:
require_availability=data.get("require_availability", False),
skip_date=data.get("skip_date", ""),
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", [])),
# Back-compat: old records stored a single ISO date in
# `publish_calendar_last_date`. Seed the new list with it so we
@@ -456,6 +473,8 @@ class Chore:
"require_availability": self.require_availability,
"skip_date": self.skip_date,
"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_published_dates": self.publish_calendar_published_dates,
"bonus_subtasks": [b.to_dict() for b in self.bonus_subtasks],
@@ -561,7 +580,7 @@ class Quest:
name: str
description: str = ""
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
assigned_to: list[str] = field(default_factory=list) # child IDs; empty = all
repeatable: bool = False
@@ -609,8 +628,8 @@ class Challenge:
name: str
description: str = ""
icon: str = "mdi:trophy-outline"
scope: str = "daily" # daily | weekly
metric: str = "chores" # chores | points
scope: str = "daily" # daily | weekly
metric: str = "chores" # chores | points
target: int = 3
bonus_points: int = 15
assigned_to: list[str] = field(default_factory=list) # child IDs; empty = all
@@ -708,8 +727,8 @@ class MandatoryMiss:
chore_id: str
child_id: str
due_date: str # ISO date the chore was missed
period_id: str # the window that closed ("anytime" for all-day)
due_date: str # ISO date the chore was missed
period_id: str # the window that closed ("anytime" for all-day)
penalty_points: int = 0
postpone_count: int = 0
escalation_stage: int = 0 # 0=none 1=nudged 2=reminded 3=parent-alerted (FEAT-6)
@@ -1207,10 +1226,7 @@ class NotificationConfig:
return cls(
type_id=data.get("type_id", ""),
master_enabled=bool(data.get("master_enabled", False)),
routes={
rid: NotificationRoute.from_dict(rdata)
for rid, rdata in raw_routes.items()
},
routes={rid: NotificationRoute.from_dict(rdata) for rid, rdata in raw_routes.items()},
nav_url=data.get("nav_url", "") or "",
)
@@ -1231,8 +1247,8 @@ class CustomNotification:
name: str
message_template: str
time: str # "HH:MM"
day_mask: int = 0b1111111 # bit0=Mon … bit6=Sun
time: str # "HH:MM"
day_mask: int = 0b1111111 # bit0=Mon … bit6=Sun
recipient_ids: list[str] = field(default_factory=list)
enabled: bool = True
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
settings store the panel uses, so panel and entity stay in sync.
"""
from __future__ import annotations
from homeassistant.components.number import NumberEntity, NumberMode
@@ -23,9 +24,7 @@ _NUMBERS = [
]
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None:
"""Set up the TaskMate setting-number entities."""
coordinator: TaskMateCoordinator = hass.data[DOMAIN][entry.entry_id]
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."""
from __future__ import annotations
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
served (auth-gated) at ``/api/taskmate/photo/<name>``.
"""
from __future__ import annotations
import logging
@@ -86,7 +87,7 @@ def is_taskmate_photo_url(photo_url: str) -> bool:
prefix = URL_PREFIX + "/"
if not photo_url.startswith(prefix):
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:
@@ -101,7 +102,7 @@ def photo_file_for_url(hass, photo_url: str) -> Path | None:
prefix = URL_PREFIX + "/"
if not photo_url.startswith(prefix):
return None
name = photo_url[len(prefix):]
name = photo_url[len(prefix) :]
if not FILENAME_RE.match(name):
return None
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.
"""
prefix = URL_PREFIX + "/"
referenced = {
url[len(prefix):] for url in referenced_urls
if url and url.startswith(prefix)
}
referenced = {url[len(prefix) :] for url in referenced_urls if url and url.startswith(prefix)}
directory = photos_path(hass)
def _sweep() -> int:
+9 -4
View File
@@ -1,4 +1,5 @@
"""Select platform — expose key choice TaskMate settings as entities (FEAT-9)."""
from __future__ import annotations
from homeassistant.components.select import SelectEntity
@@ -14,13 +15,17 @@ from .coordinator import TaskMateCoordinator
# (setting_key, translation_key, options, default, icon)
_SELECTS = [
("streak_reset_mode", "streak_reset_mode", ["reset", "pause"], "reset", "mdi:restart"),
("card_design", "card_design", ["classic", "playroom", "console", "cleanpro", "accessible"], "classic", "mdi:palette"),
(
"card_design",
"card_design",
["classic", "playroom", "console", "cleanpro", "accessible"],
"classic",
"mdi:palette",
),
]
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None:
"""Set up the TaskMate setting-select entities."""
coordinator: TaskMateCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(TaskMateSettingSelect(coordinator, entry, *cfg) for cfg in _SELECTS)
+299 -269
View File
@@ -1,4 +1,5 @@
"""Sensor platform for TaskMate integration."""
from __future__ import annotations
import logging
@@ -85,9 +86,7 @@ def _compute_common(coordinator: TaskMateCoordinator) -> dict:
for comp in pending_completions:
chore = chore_lookup.get(comp.chore_id)
if chore:
pending_points_by_child[comp.child_id] = (
pending_points_by_child.get(comp.child_id, 0) + chore.points
)
pending_points_by_child[comp.child_id] = pending_points_by_child.get(comp.child_id, 0) + chore.points
# Committed points per child (reward claims awaiting approval = points reserved).
# Pool-mode pending claims are skipped because their cost was already deducted
@@ -98,9 +97,7 @@ def _compute_common(coordinator: TaskMateCoordinator) -> dict:
continue
reward = reward_lookup.get(rc.reward_id)
if reward:
committed_points_by_child[rc.child_id] = (
committed_points_by_child.get(rc.child_id, 0) + reward.cost
)
committed_points_by_child[rc.child_id] = committed_points_by_child.get(rc.child_id, 0) + reward.cost
# Pool allocation lookups for v3.0 pool mode.
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] = {}
for pa in pool_alloc_objs:
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.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
)
pool_total_by_reward[pa.reward_id] = 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
common = {
"data_id": data_id,
@@ -151,51 +144,57 @@ def _build_children_summary(coordinator: TaskMateCoordinator, common: dict) -> l
for c in children:
committed_amount = committed.get(c.id, 0)
lvl = coordinator.level_info(c)
summary.append({
"level": lvl["level"],
"level_progress": lvl["progress"],
"level_target": lvl["target"],
"id": c.id,
"name": c.name,
"points": c.points,
"pending_points": pending.get(c.id, 0),
# Guest profiles (#690): cards filter these out of competitive views.
**({"is_guest": True, "guest_expires_on": getattr(c, "guest_expires_on", "")}
if getattr(c, "is_guest", False) else {}),
# Chore roulette (#677): today's pick + spins left, so the card can
# show the result and disable the button once the allowance is used.
**(
{
"roulette": {
**(coordinator.roulette_selection(c.id) or {}),
"spins_left": coordinator.roulette_spins_left(c.id),
summary.append(
{
"level": lvl["level"],
"level_progress": lvl["progress"],
"level_target": lvl["target"],
"id": c.id,
"name": c.name,
"points": c.points,
"pending_points": pending.get(c.id, 0),
# Guest profiles (#690): cards filter these out of competitive views.
**(
{"is_guest": True, "guest_expires_on": getattr(c, "guest_expires_on", "")}
if getattr(c, "is_guest", False)
else {}
),
# Chore roulette (#677): today's pick + spins left, so the card can
# show the result and disable the button once the allowance is used.
**(
{
"roulette": {
**(coordinator.roulette_selection(c.id) or {}),
"spins_left": coordinator.roulette_spins_left(c.id),
}
}
}
if coordinator.roulette_enabled() else {}
),
"committed_points": committed_amount,
"allocated_points": allocated.get(c.id, 0),
# Allocations were deducted from child.points already, so spendable
# only needs to account for pending-claim commitments.
"spendable_balance": max(0, c.points - committed_amount),
"chore_order": c.chore_order,
"current_streak": getattr(c, 'current_streak', 0) or 0,
"best_streak": getattr(c, 'best_streak', 0) or 0,
"season_points": int(season.get(c.id, 0)),
"total_points_earned": getattr(c, 'total_points_earned', 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',
"last_completion_date": getattr(c, 'last_completion_date', None),
"streak_paused": getattr(c, 'streak_paused', False),
"on_vacation": coordinator._is_child_on_vacation(c),
"streak_milestones_achieved": getattr(c, 'streak_milestones_achieved', None) or [],
"awarded_perfect_weeks": getattr(c, 'awarded_perfect_weeks', None) or [],
"career_score": getattr(c, 'career_score', 0) or 0,
"total_penalties_received": getattr(c, 'total_penalties_received', 0) or 0,
"quests": coordinator.quest_progress_for_child(c.id),
"avatar_options": coordinator.avatar_options_for_child(c),
"challenges": coordinator.challenge_progress_for_child(c.id),
})
if coordinator.roulette_enabled()
else {}
),
"committed_points": committed_amount,
"allocated_points": allocated.get(c.id, 0),
# Allocations were deducted from child.points already, so spendable
# only needs to account for pending-claim commitments.
"spendable_balance": max(0, c.points - committed_amount),
"chore_order": c.chore_order,
"current_streak": getattr(c, "current_streak", 0) or 0,
"best_streak": getattr(c, "best_streak", 0) or 0,
"season_points": int(season.get(c.id, 0)),
"total_points_earned": getattr(c, "total_points_earned", 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",
"last_completion_date": getattr(c, "last_completion_date", None),
"streak_paused": getattr(c, "streak_paused", False),
"on_vacation": coordinator._is_child_on_vacation(c),
"streak_milestones_achieved": getattr(c, "streak_milestones_achieved", None) or [],
"awarded_perfect_weeks": getattr(c, "awarded_perfect_weeks", None) or [],
"career_score": getattr(c, "career_score", 0) or 0,
"total_penalties_received": getattr(c, "total_penalties_received", 0) or 0,
"quests": coordinator.quest_progress_for_child(c.id),
"avatar_options": coordinator.avatar_options_for_child(c),
"challenges": coordinator.challenge_progress_for_child(c.id),
}
)
return summary
@@ -223,61 +222,61 @@ def _build_chores_list(coordinator: TaskMateCoordinator, common: dict) -> list[d
"time_category": c.time_category,
"assigned_to": assigned_to,
"depends_on": depends_on,
"schedule_mode": getattr(c, 'schedule_mode', 'specific_days'),
"enabled": getattr(c, 'enabled', True),
"assignment_mode": getattr(c, 'assignment_mode', 'everyone'),
"schedule_mode": getattr(c, "schedule_mode", "specific_days"),
"enabled": getattr(c, "enabled", True),
"assignment_mode": getattr(c, "assignment_mode", "everyone"),
}
# Difficulty tier + the points it actually awards. Emitted only when
# non-default (medium / ×1.0) so simple chores stay compact.
difficulty = getattr(c, 'difficulty', 'medium') or 'medium'
if difficulty != 'medium':
difficulty = getattr(c, "difficulty", "medium") or "medium"
if difficulty != "medium":
record["difficulty"] = difficulty
effective_points = coordinator.effective_chore_points(c)
if effective_points != c.points:
record["effective_points"] = effective_points
# Optional fields — emit only when non-default to save bytes.
description = getattr(c, 'description', '') or ''
description = getattr(c, "description", "") or ""
if description:
record["description"] = description
daily_limit = getattr(c, 'daily_limit', 1)
daily_limit = getattr(c, "daily_limit", 1)
if daily_limit != 1:
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:
record["claim_allowance_minutes"] = claim_allowance_minutes
due_days = getattr(c, 'due_days', []) or []
due_days = getattr(c, "due_days", []) or []
if 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:
record["requires_approval"] = False
# Mandatory chores (#532): emit only when set so the child card can
# show the mandatory styling/badge. penalty rides along when non-zero.
if getattr(c, 'mandatory', False):
if getattr(c, "mandatory", False):
record["mandatory"] = True
penalty = getattr(c, 'mandatory_penalty_points', 0) or 0
penalty = getattr(c, "mandatory_penalty_points", 0) or 0
if penalty:
record["mandatory_penalty_points"] = penalty
if getattr(c, 'require_photo', False):
if getattr(c, "require_photo", False):
record["require_photo"] = True
recurrence = getattr(c, 'recurrence', 'weekly')
if recurrence != 'weekly':
recurrence = getattr(c, "recurrence", "weekly")
if recurrence != "weekly":
record["recurrence"] = recurrence
recurrence_day = getattr(c, 'recurrence_day', '')
recurrence_day = getattr(c, "recurrence_day", "")
if recurrence_day:
record["recurrence_day"] = recurrence_day
recurrence_start = getattr(c, 'recurrence_start', '')
recurrence_start = getattr(c, "recurrence_start", "")
if recurrence_start:
record["recurrence_start"] = recurrence_start
visibility_entity = getattr(c, 'visibility_entity', '')
visibility_entity = getattr(c, "visibility_entity", "")
if visibility_entity:
record["visibility_entity"] = visibility_entity
record["visibility_operator"] = getattr(c, 'visibility_operator', 'equals')
record["visibility_state"] = getattr(c, 'visibility_state', 'on')
weather_entity = getattr(c, 'weather_entity', '')
record["visibility_operator"] = getattr(c, "visibility_operator", "equals")
record["visibility_state"] = getattr(c, "visibility_state", "on")
weather_entity = getattr(c, "weather_entity", "")
if weather_entity:
record["weather_entity"] = weather_entity
record["weather_block_conditions"] = list(getattr(c, 'weather_block_conditions', []) or [])
record["weather_block_conditions"] = list(getattr(c, "weather_block_conditions", []) or [])
for limit in ("weather_temp_min", "weather_temp_max", "weather_wind_max"):
value = getattr(c, limit, None)
if value is not None:
@@ -287,44 +286,41 @@ def _build_chores_list(coordinator: TaskMateCoordinator, common: dict) -> list[d
reason = coordinator.weather_block_reason(c)
if reason:
record["weather_blocked"] = reason
deadline_at = getattr(c, 'deadline_at', '')
deadline_at = getattr(c, "deadline_at", "")
if 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:
record["speed_bonus_points"] = speed_bonus
disabled_for = getattr(c, 'disabled_for', [])
disabled_for = getattr(c, "disabled_for", [])
if disabled_for:
record["disabled_for"] = disabled_for
created_date = getattr(c, 'created_date', '')
created_date = getattr(c, "created_date", "")
if 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:
record["assignment_current_child_id"] = assignment_current_child_id
icon = getattr(c, 'icon', '')
icon = getattr(c, "icon", "")
if icon:
record["icon"] = icon
# Signed so the card's <img> loads; emitted only when set, matching
# `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:
record["image_url"] = images.sign_image_url(common["hass"], image_url)
completion_sound = getattr(c, 'completion_sound', 'coin')
if completion_sound and completion_sound != 'coin':
completion_sound = getattr(c, "completion_sound", "coin")
if completion_sound and completion_sound != "coin":
record["completion_sound"] = completion_sound
task_type = getattr(c, 'task_type', 'standard')
task_type = getattr(c, "task_type", "standard")
if task_type == "timed":
record["task_type"] = "timed"
record["timed_rate_points"] = getattr(c, 'timed_rate_points', 10)
record["timed_rate_minutes"] = getattr(c, 'timed_rate_minutes', 5)
record["timed_max_daily_minutes"] = getattr(c, 'timed_max_daily_minutes', 0)
bonus_subtasks = getattr(c, 'bonus_subtasks', [])
record["timed_rate_points"] = getattr(c, "timed_rate_points", 10)
record["timed_rate_minutes"] = getattr(c, "timed_rate_minutes", 5)
record["timed_max_daily_minutes"] = getattr(c, "timed_max_daily_minutes", 0)
bonus_subtasks = getattr(c, "bonus_subtasks", [])
if bonus_subtasks:
record["bonus_subtasks"] = [
{"id": b.id, "name": b.name, "points": b.points}
for b in bonus_subtasks
]
record["bonus_subtasks"] = [{"id": b.id, "name": b.name, "points": b.points} for b in bonus_subtasks]
chores_list.append(record)
return chores_list
@@ -356,9 +352,9 @@ def _build_todays_completions(common: dict) -> list[dict]:
out = []
for comp in common["all_completions"]:
comp_dt = comp.completed_at
if hasattr(comp_dt, 'astimezone'):
if hasattr(comp_dt, "astimezone"):
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:
continue
matched_chore = chore_lookup.get(comp.chore_id)
@@ -379,11 +375,15 @@ def _build_todays_completions(common: dict) -> list[dict]:
"completion_id": comp.id,
"chore_id": comp.chore_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,
"points": display_points,
"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,
}
if timed_secs > 0:
@@ -402,7 +402,7 @@ def _build_active_timed_sessions(coordinator: TaskMateCoordinator) -> list[dict]
sessions = coordinator.data.get("timed_sessions", [])
out = []
for s in sessions:
if hasattr(s, 'state'):
if hasattr(s, "state"):
state = s.state
if state not in ("running", "paused"):
continue
@@ -412,13 +412,15 @@ def _build_active_timed_sessions(coordinator: TaskMateCoordinator) -> list[dict]
last_seg = segments[-1]
if isinstance(last_seg, dict) and last_seg.get("end") is None:
current_segment_start = last_seg.get("start", "")
out.append({
"chore_id": s.chore_id,
"child_id": s.child_id,
"state": state,
"total_seconds_today": s.total_seconds_today,
"current_segment_start": current_segment_start,
})
out.append(
{
"chore_id": s.chore_id,
"child_id": s.child_id,
"state": state,
"total_seconds_today": s.total_seconds_today,
"current_segment_start": current_segment_start,
}
)
else:
state = s.get("state", "stopped") if isinstance(s, dict) else "stopped"
if state not in ("running", "paused"):
@@ -429,13 +431,15 @@ def _build_active_timed_sessions(coordinator: TaskMateCoordinator) -> list[dict]
last_seg = segments[-1]
if isinstance(last_seg, dict) and last_seg.get("end") is None:
current_segment_start = last_seg.get("start", "")
out.append({
"chore_id": s.get("chore_id", "") if isinstance(s, dict) else "",
"child_id": s.get("child_id", "") if isinstance(s, dict) else "",
"state": state,
"total_seconds_today": s.get("total_seconds_today", 0) if isinstance(s, dict) else 0,
"current_segment_start": current_segment_start,
})
out.append(
{
"chore_id": s.get("chore_id", "") if isinstance(s, dict) else "",
"child_id": s.get("child_id", "") if isinstance(s, dict) else "",
"state": state,
"total_seconds_today": s.get("total_seconds_today", 0) if isinstance(s, dict) else 0,
"current_segment_start": current_segment_start,
}
)
return out
@@ -448,20 +452,12 @@ def _build_rewards_list(common: dict) -> list[dict]:
today = dt_util.now().date()
out = []
for r in rewards:
assigned = (
r.assigned_to
if isinstance(r.assigned_to, list) and r.assigned_to
else [c.id for c in children]
)
assigned = 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}
reward_pool_allocations = {
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)
expires_at = getattr(r, 'expires_at', None)
reward_pool_allocations = {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)
expires_at = getattr(r, "expires_at", None)
is_sold_out = quantity is not None and quantity <= 0
is_expired = False
days_until_expiry: int | None = None
@@ -472,25 +468,27 @@ def _build_rewards_list(common: dict) -> list[dict]:
days_until_expiry = (deadline - today).days
except (TypeError, ValueError):
pass
out.append({
"id": r.id,
"name": r.name,
"cost": r.cost,
"description": getattr(r, 'description', ''),
"icon": r.icon,
"assigned_to": r.assigned_to if isinstance(r.assigned_to, list) else [],
"is_jackpot": getattr(r, 'is_jackpot', False),
"pool_enabled": getattr(r, 'pool_enabled', False),
"calculated_costs": calculated_costs,
"pool_allocations": reward_pool_allocations,
"jackpot_pool_total": jackpot_pool_total,
"quantity": quantity,
"expires_at": expires_at,
"is_sold_out": is_sold_out,
"is_expired": is_expired,
"is_available": not (is_sold_out or is_expired),
"days_until_expiry": days_until_expiry,
})
out.append(
{
"id": r.id,
"name": r.name,
"cost": r.cost,
"description": getattr(r, "description", ""),
"icon": r.icon,
"assigned_to": r.assigned_to if isinstance(r.assigned_to, list) else [],
"is_jackpot": getattr(r, "is_jackpot", False),
"pool_enabled": getattr(r, "pool_enabled", False),
"calculated_costs": calculated_costs,
"pool_allocations": reward_pool_allocations,
"jackpot_pool_total": jackpot_pool_total,
"quantity": quantity,
"expires_at": expires_at,
"is_sold_out": is_sold_out,
"is_expired": is_expired,
"is_available": not (is_sold_out or is_expired),
"days_until_expiry": days_until_expiry,
}
)
return out
@@ -504,17 +502,19 @@ def _build_pending_reward_claims(common: dict) -> list[dict]:
child = child_lookup.get(rc.child_id)
if not reward or not child:
continue
out.append({
"claim_id": rc.id,
"reward_id": rc.reward_id,
"child_id": rc.child_id,
"child_name": child.name,
"child_avatar": getattr(child, 'avatar', 'mdi:account-circle') or 'mdi:account-circle',
"reward_name": reward.name,
"reward_icon": reward.icon or 'mdi:gift',
"cost": reward.cost,
"claimed_at": rc.claimed_at.isoformat() if hasattr(rc.claimed_at, 'isoformat') else str(rc.claimed_at),
})
out.append(
{
"claim_id": rc.id,
"reward_id": rc.reward_id,
"child_id": rc.child_id,
"child_name": child.name,
"child_avatar": getattr(child, "avatar", "mdi:account-circle") or "mdi:account-circle",
"reward_name": reward.name,
"reward_icon": reward.icon or "mdi:gift",
"cost": reward.cost,
"claimed_at": rc.claimed_at.isoformat() if hasattr(rc.claimed_at, "isoformat") else str(rc.claimed_at),
}
)
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
if rate_seconds > 0:
display_points = (timed_secs // rate_seconds) * matched_chore.timed_rate_points
out.append({
"completion_id": comp.id,
"chore_id": comp.chore_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 ""),
"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),
})
out.append(
{
"completion_id": comp.id,
"chore_id": comp.chore_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 ""),
"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
@@ -558,21 +564,23 @@ def _build_photo_gallery(common: dict, limit: int = 40) -> list[dict]:
"""
child_lookup = common["child_lookup"]
chore_lookup = common["chore_lookup"]
with_photos = [
c for c in common["all_completions"] if getattr(c, "photo_url", "")
]
with_photos = [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]
out = []
for comp in recent:
chore = chore_lookup.get(comp.chore_id)
out.append({
"completion_id": comp.id,
"child_name": child_lookup[comp.child_id].name if comp.child_id in child_lookup else "",
"chore_name": chore.name if chore else "",
"approved": comp.approved,
"completed_at": comp.completed_at.isoformat() if hasattr(comp.completed_at, "isoformat") else str(comp.completed_at),
"photo_url": comp.photo_url,
})
out.append(
{
"completion_id": comp.id,
"child_name": child_lookup[comp.child_id].name if comp.child_id in child_lookup else "",
"chore_name": chore.name if chore else "",
"approved": comp.approved,
"completed_at": comp.completed_at.isoformat()
if hasattr(comp.completed_at, "isoformat")
else str(comp.completed_at),
"photo_url": comp.photo_url,
}
)
return out
@@ -593,15 +601,17 @@ def _build_recent_transactions(common: dict, limit: int = 20) -> list[dict]:
child = child_lookup.get(t.child_id)
if not child:
continue
events.append({
"transaction_id": t.id,
"type": "points_added" if t.points > 0 else "points_removed",
"child_id": t.child_id,
"child_name": child.name,
"points": t.points,
"reason": t.reason or "",
"created_at": t.created_at.isoformat() if hasattr(t.created_at, 'isoformat') else str(t.created_at),
})
events.append(
{
"transaction_id": t.id,
"type": "points_added" if t.points > 0 else "points_removed",
"child_id": t.child_id,
"child_name": child.name,
"points": t.points,
"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:
child = child_lookup.get(rc.child_id)
@@ -610,43 +620,51 @@ def _build_recent_transactions(common: dict, limit: int = 20) -> list[dict]:
continue
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
events.append({
"transaction_id": rc.id,
"type": event_type,
"child_id": rc.child_id,
"child_name": child.name,
"reward_id": rc.reward_id,
"reward_name": reward.name,
"reward_icon": reward.icon or "mdi:gift",
"points": -reward.cost,
"approved": rc.approved,
"created_at": timestamp.isoformat() if hasattr(timestamp, 'isoformat') else str(timestamp),
})
events.append(
{
"transaction_id": rc.id,
"type": event_type,
"child_id": rc.child_id,
"child_name": child.name,
"reward_id": rc.reward_id,
"reward_name": reward.name,
"reward_icon": reward.icon or "mdi:gift",
"points": -reward.cost,
"approved": rc.approved,
"created_at": timestamp.isoformat() if hasattr(timestamp, "isoformat") else str(timestamp),
}
)
events.sort(key=lambda e: e["created_at"], reverse=True)
return events[:limit]
def _build_penalties_list(common: dict) -> list[dict]:
return [{
"id": p.id,
"name": p.name,
"points": p.points,
"description": p.description,
"icon": p.icon,
"assigned_to": p.assigned_to or [],
} for p in common["data"].get("penalties", [])]
return [
{
"id": p.id,
"name": p.name,
"points": p.points,
"description": p.description,
"icon": p.icon,
"assigned_to": p.assigned_to or [],
}
for p in common["data"].get("penalties", [])
]
def _build_bonuses_list(common: dict) -> list[dict]:
return [{
"id": b.id,
"name": b.name,
"points": b.points,
"description": b.description,
"icon": b.icon,
"assigned_to": b.assigned_to or [],
} for b in common["data"].get("bonuses", [])]
return [
{
"id": b.id,
"name": b.name,
"points": b.points,
"description": b.description,
"icon": b.icon,
"assigned_to": b.assigned_to or [],
}
for b in common["data"].get("bonuses", [])
]
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_defaults = {
"morning": ("06:00", "12:00"), "afternoon": ("12:00", "17:00"),
"evening": ("17:00", "21:00"), "night": ("21:00", "23:59"),
"morning": ("06:00", "12:00"),
"afternoon": ("12:00", "17:00"),
"evening": ("17:00", "21:00"),
"night": ("21:00", "23:59"),
}
time_boundaries = {}
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_bonus": _safe_int(settings.get("perfect_week_bonus"), 50),
"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_chores": len(chores),
"total_rewards": len(rewards),
@@ -1117,6 +1138,7 @@ class ChildStatsSensor(TaskMateBaseSensor):
# drop it once any pool member has completed it today (so a parent
# crediting the off-rotation child clears the chore for everyone).
chores = self.coordinator.data.get("chores", [])
def _included(c):
if not (child.id in c.assigned_to or not c.assigned_to):
return False
@@ -1128,6 +1150,7 @@ class ChildStatsSensor(TaskMateBaseSensor):
if self.coordinator._is_rotation_done_today(c):
return False
return True
assigned_chores = [c for c in chores if _included(c)]
return {
@@ -1141,7 +1164,10 @@ class ChildStatsSensor(TaskMateBaseSensor):
"best_streak": child.best_streak,
"career_score": child.career_score,
"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,
}
@@ -1166,9 +1192,7 @@ class ChildBadgesSensor(TaskMateBaseSensor):
@property
def native_value(self) -> int:
"""Number of badges earned by this child."""
return len(
self.coordinator.storage.get_awarded_badges_for_child(self.child_id)
)
return len(self.coordinator.storage.get_awarded_badges_for_child(self.child_id))
@property
def extra_state_attributes(self) -> dict:
@@ -1180,10 +1204,7 @@ class ChildBadgesSensor(TaskMateBaseSensor):
return {"earned": [], "available": [], "total_badges": 0}
all_badges = [b for b in storage.get_badges() if b.enabled]
applicable = [
b for b in all_badges
if not b.assigned_to or self.child_id in b.assigned_to
]
applicable = [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)
record_by_id = {a.badge_id: a for a in awarded_records}
@@ -1193,35 +1214,46 @@ class ChildBadgesSensor(TaskMateBaseSensor):
for b in applicable:
if b.id in record_by_id:
rec = record_by_id[b.id]
earned.append({
"badge_id": b.id,
"name": b.name,
"icon": b.icon,
"tier": b.tier,
"earned_at": rec.earned_at.isoformat() if rec.earned_at else None,
"manually_awarded": rec.manually_awarded,
"silent": rec.silent,
})
earned.append(
{
"badge_id": b.id,
"name": b.name,
"icon": b.icon,
"tier": b.tier,
"earned_at": rec.earned_at.isoformat() if rec.earned_at else None,
"manually_awarded": rec.manually_awarded,
"silent": rec.silent,
}
)
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
closest = None
else:
pcts = []
for c in b.criteria:
cur = resolve_metric(c.metric, child, storage)
target = max(c.value, 1)
pcts.append(min(100, int(100 * cur / target)))
progress_pct = min(pcts) if pcts else 0
available.append({
"badge_id": b.id,
"name": b.name,
"icon": b.icon,
"tier": b.tier,
"progress_pct": progress_pct,
"criteria_summary": ", ".join(
f"{c.metric} >= {c.value}" for c in b.criteria
),
})
pick = max(scored) if combinator == "OR" else min(scored)
progress_pct = pick[0]
closest = {"metric": pick[1], "current": pick[2], "target": pick[3]}
available.append(
{
"badge_id": b.id,
"name": b.name,
"icon": b.icon,
"tier": b.tier,
"progress_pct": progress_pct,
"closest_criterion": closest,
"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)
@@ -1274,9 +1306,7 @@ class PendingApprovalsSensor(TaskMateBaseSensor):
# list render them identically.
bonus_subtask_id = getattr(comp, "bonus_subtask_id", "") or ""
if bonus_subtask_id:
subtask = next(
(b for b in chore.bonus_subtasks if b.id == bonus_subtask_id), None
)
subtask = next((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
pts = subtask.points if subtask else 0
else:
@@ -1303,9 +1333,7 @@ class PendingApprovalsSensor(TaskMateBaseSensor):
detail["timed_duration_seconds"] = timed_secs
photo = getattr(comp, "photo_url", "") or ""
if photo:
detail["photo_url"] = photos.sign_photo_url(
self.coordinator.hass, photo
)
detail["photo_url"] = photos.sign_photo_url(self.coordinator.hass, photo)
completion_details.append(detail)
reward_details = []
@@ -1313,16 +1341,18 @@ class PendingApprovalsSensor(TaskMateBaseSensor):
child = self.coordinator.get_child(claim.child_id)
reward = self.coordinator.get_reward(claim.reward_id)
if child and reward:
reward_details.append({
"claim_id": claim.id,
"type": "reward",
"child_name": child.name,
"child_id": child.id,
"reward_name": reward.name,
"reward_id": reward.id,
"cost": reward.cost,
"claimed_at": claim.claimed_at.isoformat(),
})
reward_details.append(
{
"claim_id": claim.id,
"type": "reward",
"child_name": child.name,
"child_id": child.id,
"reward_name": reward.name,
"reward_id": reward.id,
"cost": reward.cost,
"claimed_at": claim.claimed_at.isoformat(),
}
)
mandatory_misses = self.coordinator.mandatory_misses_state()
return {
+125 -97
View File
@@ -1,4 +1,5 @@
"""Storage management for TaskMate integration."""
from __future__ import annotations
import logging
@@ -120,6 +121,15 @@ class TaskMateStorage:
if "scheduled_changes" not in self._data:
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)
if "parent_recipients" not in self._data:
self._data["parent_recipients"] = []
@@ -182,8 +192,8 @@ class TaskMateStorage:
self._data["_pool_semantics_version"] = 2
if adjusted:
_LOGGER.info(
"TaskMate: migrated %d pool allocation(s) to beta2 semantics "
"(points now deducted at allocation time)", adjusted
"TaskMate: migrated %d pool allocation(s) to beta2 semantics (points now deducted at allocation time)",
adjusted,
)
await self.async_save()
@@ -232,15 +242,13 @@ class TaskMateStorage:
"Migrating chore '%s' assigned_to: '%s' -> '%s' (name to ID)",
chore.get("name", "unknown"),
assignment,
name_to_id[assignment]
name_to_id[assignment],
)
else:
# Unknown value, keep it but log a warning
new_assigned_to.append(assignment)
_LOGGER.warning(
"Chore '%s' has unknown assigned_to value: '%s'",
chore.get("name", "unknown"),
assignment
"Chore '%s' has unknown assigned_to value: '%s'", chore.get("name", "unknown"), assignment
)
if chore_modified:
@@ -269,10 +277,7 @@ class TaskMateStorage:
self._data["_career_score_initialized"] = True
if children:
_LOGGER.info(
"TaskMate: initialized career_score for %d child(ren) "
"from total_points_earned", len(children)
)
_LOGGER.info("TaskMate: initialized career_score for %d child(ren) from total_points_earned", len(children))
await self.async_save()
async def async_save(self) -> None:
@@ -336,9 +341,7 @@ class TaskMateStorage:
def remove_child(self, child_id: str) -> None:
"""Remove a child and cascade-delete their awarded badges."""
self._data["children"] = [
c for c in self._data.get("children", []) if c.get("id") != child_id
]
self._data["children"] = [c for c in self._data.get("children", []) if c.get("id") != child_id]
self.remove_awards_for_child(child_id)
# Chores management
@@ -370,9 +373,7 @@ class TaskMateStorage:
def remove_chore(self, chore_id: str) -> None:
"""Remove a chore."""
self._data["chores"] = [
c for c in self._data.get("chores", []) if c.get("id") != chore_id
]
self._data["chores"] = [c for c in self._data.get("chores", []) if c.get("id") != chore_id]
order = self._data.get("chore_display_order", [])
if chore_id in order:
order.remove(chore_id)
@@ -414,9 +415,7 @@ class TaskMateStorage:
def remove_reward(self, reward_id: str) -> None:
"""Remove a reward."""
self._data["rewards"] = [
r for r in self._data.get("rewards", []) if r.get("id") != reward_id
]
self._data["rewards"] = [r for r in self._data.get("rewards", []) if r.get("id") != reward_id]
# Completions management
def get_completions(self) -> list[ChoreCompletion]:
@@ -447,9 +446,7 @@ class TaskMateStorage:
def remove_completion(self, completion_id: str) -> None:
"""Remove a completion record."""
self._data["completions"] = [
c for c in self._data.get("completions", []) if c.get("id") != completion_id
]
self._data["completions"] = [c for c in self._data.get("completions", []) if c.get("id") != completion_id]
# Mandatory-miss management (#532)
def get_mandatory_misses(self) -> list[MandatoryMiss]:
@@ -470,9 +467,7 @@ class TaskMateStorage:
def remove_mandatory_miss(self, miss_id: str) -> None:
"""Remove a mandatory-miss item by id."""
self._data["mandatory_misses"] = [
m for m in self._data.get("mandatory_misses", []) if m.get("id") != miss_id
]
self._data["mandatory_misses"] = [m for m in self._data.get("mandatory_misses", []) if m.get("id") != miss_id]
def replace_mandatory_misses(self, misses: list[MandatoryMiss]) -> None:
"""Replace the whole mandatory-miss collection."""
@@ -507,9 +502,7 @@ class TaskMateStorage:
def remove_reward_claim(self, claim_id: str) -> None:
"""Remove a reward claim."""
self._data["reward_claims"] = [
c for c in self._data.get("reward_claims", []) if c.get("id") != claim_id
]
self._data["reward_claims"] = [c for c in self._data.get("reward_claims", []) if c.get("id") != claim_id]
# Penalties management
def get_penalties(self) -> list[Penalty]:
@@ -538,9 +531,7 @@ class TaskMateStorage:
def remove_penalty(self, penalty_id: str) -> None:
"""Remove a penalty."""
self._data["penalties"] = [
p for p in self._data.get("penalties", []) if p.get("id") != penalty_id
]
self._data["penalties"] = [p for p in self._data.get("penalties", []) if p.get("id") != penalty_id]
# Bonuses management
def get_bonuses(self) -> list[Bonus]:
@@ -569,9 +560,7 @@ class TaskMateStorage:
def remove_bonus(self, bonus_id: str) -> None:
"""Remove a bonus."""
self._data["bonuses"] = [
b for b in self._data.get("bonuses", []) if b.get("id") != bonus_id
]
self._data["bonuses"] = [b for b in self._data.get("bonuses", []) if b.get("id") != bonus_id]
# Badges management
def get_badges(self) -> list[Badge]:
@@ -600,9 +589,7 @@ class TaskMateStorage:
def remove_badge(self, badge_id: str) -> None:
"""Remove a badge and cascade-delete its awards."""
self._data["badges"] = [
b for b in self._data.get("badges", []) if b.get("id") != badge_id
]
self._data["badges"] = [b for b in self._data.get("badges", []) if b.get("id") != badge_id]
self.remove_awards_for_badge(badge_id)
# Awarded badges management
@@ -620,9 +607,7 @@ class TaskMateStorage:
def remove_awarded_badge(self, awarded_id: str) -> None:
"""Remove an awarded-badge record by id."""
self._data["awarded_badges"] = [
a for a in self._data.get("awarded_badges", []) if a.get("id") != awarded_id
]
self._data["awarded_badges"] = [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:
"""Cascade-delete all awards referencing a badge id."""
@@ -705,9 +690,7 @@ class TaskMateStorage:
cfg = NotificationConfig(
type_id=tid,
master_enabled=True,
routes={
seeded_parent_id: NotificationRoute(enabled=True)
} if seeded_parent_id else {},
routes={seeded_parent_id: NotificationRoute(enabled=True)} if seeded_parent_id else {},
)
nc[tid] = cfg.to_dict()
@@ -728,8 +711,7 @@ class TaskMateStorage:
def delete_parent_recipient(self, parent_id: str) -> None:
self._data["parent_recipients"] = [
r for r in self._data.get("parent_recipients", [])
if r.get("id") != parent_id
r for r in self._data.get("parent_recipients", []) if r.get("id") != parent_id
]
# --- notification config ---
@@ -749,9 +731,7 @@ class TaskMateStorage:
cfg.nav_url = nav_url
self._data.setdefault("notification_config", {})[type_id] = cfg.to_dict()
def set_notification_route(
self, type_id: str, recipient_id: str, route: NotificationRoute
) -> None:
def set_notification_route(self, type_id: str, recipient_id: str, route: NotificationRoute) -> None:
cfg = self.get_notification_config(type_id)
cfg.routes[recipient_id] = route
self._data.setdefault("notification_config", {})[type_id] = cfg.to_dict()
@@ -764,10 +744,7 @@ class TaskMateStorage:
# --- custom notifications ---
def get_custom_notifications(self) -> list[CustomNotification]:
return [
CustomNotification.from_dict(d)
for d in self._data.get("custom_notifications", [])
]
return [CustomNotification.from_dict(d) for d in self._data.get("custom_notifications", [])]
def upsert_custom_notification(self, n: CustomNotification) -> None:
rows = self._data.setdefault("custom_notifications", [])
@@ -779,15 +756,12 @@ class TaskMateStorage:
def delete_custom_notification(self, custom_id: str) -> None:
self._data["custom_notifications"] = [
r for r in self._data.get("custom_notifications", [])
if r.get("id") != custom_id
r for r in self._data.get("custom_notifications", []) if r.get("id") != custom_id
]
# --- streak-at-risk cutoff ---
def get_streak_at_risk_cutoff(self) -> str:
return (self._data.get("settings", {}) or {}).get(
"streak_at_risk_cutoff_time", "20:00"
)
return (self._data.get("settings", {}) or {}).get("streak_at_risk_cutoff_time", "20:00")
def set_streak_at_risk_cutoff(self, hhmm: str) -> None:
self._data.setdefault("settings", {})["streak_at_risk_cutoff_time"] = hhmm
@@ -811,16 +785,14 @@ class TaskMateStorage:
def get_escalation_reminder_minutes(self) -> int:
"""Minutes after a mandatory miss before the child reminder escalates."""
try:
return max(1, int((self._data.get("settings", {}) or {}).get(
"mandatory_escalation_reminder_minutes", 30)))
return max(1, int((self._data.get("settings", {}) or {}).get("mandatory_escalation_reminder_minutes", 30)))
except (TypeError, ValueError):
return 30
def get_escalation_parent_minutes(self) -> int:
"""Minutes after a mandatory miss before the parent alert escalates."""
try:
return max(1, int((self._data.get("settings", {}) or {}).get(
"mandatory_escalation_parent_minutes", 120)))
return max(1, int((self._data.get("settings", {}) or {}).get("mandatory_escalation_parent_minutes", 120)))
except (TypeError, ValueError):
return 120
@@ -863,9 +835,7 @@ class TaskMateStorage:
def remove_task_group(self, group_id: str) -> None:
"""Remove a task group."""
self._data["task_groups"] = [
g for g in self._data.get("task_groups", []) if g.get("id") != group_id
]
self._data["task_groups"] = [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:
"""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
# transactions makes them unreliable for a monthly total (FEAT-2).
if transaction.points > 0:
self.record_season_points(
transaction.child_id, transaction.points, transaction.created_at
)
self.record_season_points(transaction.child_id, transaction.points, transaction.created_at)
# Keep only the last 200 transactions to avoid unbounded storage growth
if len(self._data["points_transactions"]) > 200:
@@ -1019,6 +987,57 @@ class TaskMateStorage:
return True
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) ────────────────────────────────────────────
def get_quests(self) -> list[Quest]:
return [Quest.from_dict(q) for q in self._data.get("quests", [])]
@@ -1041,9 +1060,7 @@ class TaskMateStorage:
self.add_quest(quest)
def remove_quest(self, quest_id: str) -> None:
self._data["quests"] = [
q for q in self._data.get("quests", []) if q.get("id") != quest_id
]
self._data["quests"] = [q for q in self._data.get("quests", []) if q.get("id") != quest_id]
# Drop any progress tracked for this quest
prog = self._data.get("quest_progress", {})
prog.pop(quest_id, None)
@@ -1084,9 +1101,7 @@ class TaskMateStorage:
self.add_challenge(challenge)
def remove_challenge(self, challenge_id: str) -> None:
self._data["challenges"] = [
c for c in self._data.get("challenges", []) if c.get("id") != challenge_id
]
self._data["challenges"] = [c for c in self._data.get("challenges", []) if c.get("id") != challenge_id]
self._data.get("challenge_progress", {}).pop(challenge_id, None)
def get_challenge_progress(self) -> dict:
@@ -1107,6 +1122,7 @@ class TaskMateStorage:
def export_data(self) -> dict:
"""Return a deep copy of the full stored data (for backup/export)."""
import copy
return copy.deepcopy(self._data)
def import_data(self, data: dict) -> None:
@@ -1116,14 +1132,29 @@ class TaskMateStorage:
a partial import.
"""
import copy
if not isinstance(data, dict):
raise ValueError("import data must be an object")
self._data = copy.deepcopy(data)
list_keys = (
"children", "chores", "rewards", "penalties", "bonuses",
"task_groups", "completions", "mandatory_misses", "reward_claims", "points_transactions",
"pool_allocations", "badges", "awarded_badges", "parent_recipients",
"audit_log", "timed_sessions", "quests", "challenges",
"children",
"chores",
"rewards",
"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:
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.
"""
from .photos import is_taskmate_photo_url
for comp in self._data.get("completions", []):
if not isinstance(comp, dict):
continue
@@ -1163,21 +1195,15 @@ class TaskMateStorage:
def remove_completions_for_child(self, child_id: str) -> None:
"""Remove all completions for a given child."""
self._data["completions"] = [
c for c in self._data.get("completions", []) if c.get("child_id") != child_id
]
self._data["completions"] = [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:
"""Remove all completions for a given chore."""
self._data["completions"] = [
c for c in self._data.get("completions", []) if c.get("chore_id") != chore_id
]
self._data["completions"] = [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:
"""Remove all reward claims for a given child."""
self._data["reward_claims"] = [
c for c in self._data.get("reward_claims", []) if c.get("child_id") != child_id
]
self._data["reward_claims"] = [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:
"""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:
"""Remove a pool allocation for a specific (child, reward) pair."""
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)
]
@@ -1309,18 +1336,22 @@ class TaskMateStorage:
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."""
for s in self._data.get("timed_sessions", []):
if (s.get("chore_id") == chore_id
and s.get("child_id") == child_id
and s.get("session_date") == session_date):
if (
s.get("chore_id") == chore_id
and s.get("child_id") == child_id
and s.get("session_date") == session_date
):
return TimedSession.from_dict(s)
return 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."""
for s in self._data.get("timed_sessions", []):
if (s.get("chore_id") == chore_id
and s.get("child_id") == child_id
and s.get("state") in ("running", "paused")):
if (
s.get("chore_id") == chore_id
and s.get("child_id") == child_id
and s.get("state") in ("running", "paused")
):
return TimedSession.from_dict(s)
return None
@@ -1335,10 +1366,7 @@ class TaskMateStorage:
def remove_timed_session(self, session_id: str) -> None:
"""Remove a timed session."""
self._data["timed_sessions"] = [
s for s in self._data.get("timed_sessions", [])
if s.get("id") != session_id
]
self._data["timed_sessions"] = [s for s in self._data.get("timed_sessions", []) if s.get("id") != session_id]
# Generic settings
def get_setting(self, key: str, default: Any = "") -> Any:
+281 -31
View File
@@ -1,15 +1,35 @@
"""Built-in chore template packs for TaskMate."""
from __future__ import annotations
TEMPLATE_CHORE_FIELDS = (
"name", "points", "description", "requires_approval", "time_category",
"daily_limit", "completion_sound", "schedule_mode", "due_days",
"recurrence", "recurrence_day", "recurrence_start", "first_occurrence_mode",
"assignment_mode", "require_availability", "visibility_entity",
"visibility_state", "visibility_operator",
"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",
"name",
"points",
"description",
"requires_approval",
"time_category",
"daily_limit",
"completion_sound",
"schedule_mode",
"due_days",
"recurrence",
"recurrence_day",
"recurrence_start",
"first_occurrence_mode",
"assignment_mode",
"require_availability",
"visibility_entity",
"visibility_state",
"visibility_operator",
"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"]
@@ -22,10 +42,50 @@ BUILT_IN_TEMPLATES: list[dict] = [
"icon": "mdi:weather-sunny",
"builtin": True,
"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": "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"},
{
"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": "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",
"builtin": True,
"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": "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"},
{
"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": "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",
"builtin": True,
"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": "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"},
{
"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": "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",
"builtin": True,
"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": "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"},
{
"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": "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",
"builtin": True,
"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": "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"},
{
"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": "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",
"builtin": True,
"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": "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"},
{
"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": "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
without the custom cards.
"""
from __future__ import annotations
from homeassistant.components.todo import (
@@ -23,9 +24,7 @@ from .const import DOMAIN
from .coordinator import TaskMateCoordinator
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None:
"""Set up a to-do list per child, adding new children as they appear."""
coordinator: TaskMateCoordinator = hass.data[DOMAIN][entry.entry_id]
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.entity_not_found": "{entity} nicht gefunden",
"badges.label": "Abzeichen",
"badges.next_up": "Als Nächstes",
"badges.title_with_name": "Abzeichen von {name}",
"bonuses.add_bonus": "Bonus hinzufügen",
"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.entity_not_found": "{entity} not found",
"badges.label": "Badges",
"badges.next_up": "Next up",
"badges.title_with_name": "{name}'s Badges",
"bonuses.add_bonus": "Add Bonus",
"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.entity_not_found": "{entity} not found",
"badges.label": "Badges",
"badges.next_up": "Next up",
"badges.title_with_name": "{name}'s Badges",
"bonuses.add_bonus": "Add Bonus",
"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.entity_not_found": "{entity} introuvable",
"badges.label": "Badges",
"badges.next_up": "Prochain",
"badges.title_with_name": "Badges de {name}",
"bonuses.add_bonus": "Ajouter un bonus",
"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.entity_not_found": "{entity} ikke funnet",
"badges.label": "Merker",
"badges.next_up": "Neste",
"badges.title_with_name": "{name}s merker",
"bonuses.add_bonus": "Legg til bonus",
"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.entity_not_found": "{entity} ikkje funne",
"badges.label": "Merke",
"badges.next_up": "Neste",
"badges.title_with_name": "Merka til {name}",
"bonuses.add_bonus": "Legg til bonus",
"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.entity_not_found": "{entity} não encontrado",
"badges.label": "Conquistas",
"badges.next_up": "A seguir",
"badges.title_with_name": "Conquistas de {name}",
"bonuses.add_bonus": "Adicionar Bónus",
"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.entity_not_found": "{entity} não encontrado",
"badges.label": "Conquistas",
"badges.next_up": "A seguir",
"badges.title_with_name": "Conquistas de {name}",
"bonuses.add_bonus": "Adicionar Bónus",
"bonuses.applying_to": "A aplicar a {childName} — saldo atual: {points} {pointsName}",
@@ -160,6 +160,13 @@ class TaskMateChildCard extends LitElement {
}[tier] || '#888';
}
_badgeKeydown(e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this._openBadgesView();
}
}
_openBadgesView() {
const slug = this.config?.child_id
? String(this.config.child_id).toLowerCase().replace(/\s+/g, '_')
@@ -1906,6 +1913,74 @@ class TaskMateChildCard extends LitElement {
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)
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-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 */
.tmd-vacation {
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_due_days_only: true, // Whether to apply due_days filtering at all
show_badges: true, // Show badge strip between points and chores
show_next_badge: true, // Show progress toward the closest unearned badge
header_color: '#9b59b6',
...config,
};
@@ -2172,6 +2291,7 @@ class TaskMateChildCard extends LitElement {
const badgesEntity = this._resolveBadgesEntity(child);
const earnedBadges = (badgesEntity?.attributes?.earned) || [];
const showBadges = this.config.show_badges !== false && earnedBadges.length > 0;
const nextBadge = this._nextBadge(badgesEntity);
// Get pending points for this child
const pendingPoints = child.pending_points || 0;
@@ -2256,6 +2376,28 @@ class TaskMateChildCard extends LitElement {
</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">
${childChores.length === 0
? this._renderEmptyState()
@@ -2346,6 +2488,39 @@ class TaskMateChildCard extends LitElement {
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})`; }
_av(child, tone, size) {
@@ -2489,6 +2664,7 @@ class TaskMateChildCard extends LitElement {
const badgesEntity = this._resolveBadgesEntity(child);
const earnedBadges = (badgesEntity?.attributes?.earned) || [];
const showBadges = this.config.show_badges !== false && earnedBadges.length > 0;
const nextBadge = this._nextBadge(badgesEntity);
const pendingPoints = child.pending_points || 0;
const countdown = this.config.show_countdown !== false ? this._getMidnightCountdown() : null;
@@ -2532,6 +2708,26 @@ class TaskMateChildCard extends LitElement {
</div>`)}
${earnedBadges.length > 5 ? html`<span class="more">+${earnedBadges.length - 5} →</span>` : ""}
</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}
${this._renderRoulette(child, childChores, pointsIcon)}
${body}
@@ -4668,4 +4864,4 @@ console.info(
"%c TASKMATE CHILD CARD %c v" + _tmVersion + " ",
"background:#9b59b6;color:white;font-weight:bold;padding:2px 4px;border-radius:4px 0 0 4px;",
"background:#2c3e50;color:white;font-weight:bold;padding:2px 4px;border-radius:0 4px 4px 0;"
);
);
+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_release_notes": null
},
"power": 0.5,
"power": 0.4,
"linkquality": 116,
"current": 1.23,
"current": 0.03,
"power_on_behavior": "on"
},
"0xffffb40e0607af27": {
@@ -27,7 +27,7 @@
"led_brightness": 100,
"countdown_to_turn_off": 0,
"countdown_to_turn_on": 0,
"power": 2.1,
"power": 2.7,
"current": 0.12,
"energy": 28.37,
"power_factor": 0.18,
@@ -45,10 +45,10 @@
"state": "ON",
"led_brightness": 100,
"countdown_to_turn_off": 0,
"voltage": 120.7,
"voltage": 121.1,
"countdown_to_turn_on": 0,
"energy": 55.33,
"power_factor": 0.89,
"energy": 55.35,
"power_factor": 0.2,
"ac_frequency": 60,
"update": {
"state": "idle",
@@ -58,8 +58,8 @@
"latest_release_notes": null
},
"linkquality": 134,
"power": 84.6,
"current": 0.8,
"power": 0.5,
"current": 0.04,
"power_on_behavior": "on"
},
"0xb40e060fffe031e3": {
@@ -74,13 +74,13 @@
"led_brightness": 100,
"countdown_to_turn_off": 0,
"countdown_to_turn_on": 0,
"voltage": 120.5,
"voltage": 120.3,
"state": "ON",
"ac_frequency": 60,
"energy": 113.64,
"energy": 113.67,
"power": 0.8,
"current": 0.05,
"power_factor": 0.41,
"current": 0.87,
"power_factor": 0.34,
"update": {
"state": "idle",
"installed_version": 268513381,
@@ -96,12 +96,12 @@
"countdown_to_turn_off": 0,
"countdown_to_turn_on": 0,
"voltage": 121.3,
"energy": 50.5,
"energy": 50.52,
"state": "ON",
"power": 44.2,
"current": 0.53,
"power": 38.6,
"current": 0.42,
"ac_frequency": 60,
"power_factor": 0.76,
"power_factor": 0.74,
"update": {
"state": "idle",
"installed_version": 268513381,
@@ -114,11 +114,11 @@
},
"0xffffb40e060895b3": {
"state": "ON",
"voltage": 121,
"voltage": 121.4,
"ac_frequency": 60,
"energy": 7.14,
"current": 0.01,
"power": 0.2,
"power": 0.1,
"power_factor": 0.11,
"linkquality": 123,
"update": {
@@ -172,7 +172,7 @@
"0xffffb40e060893d8": {
"state": "ON",
"led_brightness": 100,
"voltage": 121.2,
"voltage": 121.7,
"countdown_to_turn_off": 0,
"countdown_to_turn_on": 0,
"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_release_notes": null
},
"power_factor": 0,
"power": 0
"power_factor": 0.1,
"power": 0.1
},
"0xa4c1380d0679ffff": {
"battery": 100,