46 files
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
"""TaskMate - Family Chore Manager for Home Assistant."""
|
"""TaskMate - Family Chore Manager for Home Assistant."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import copy
|
import copy
|
||||||
@@ -121,7 +122,15 @@ from .websocket import async_register_websocket_commands
|
|||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.BUTTON, Platform.BINARY_SENSOR, Platform.CALENDAR, Platform.NUMBER, Platform.SELECT, Platform.TODO]
|
PLATFORMS: list[Platform] = [
|
||||||
|
Platform.SENSOR,
|
||||||
|
Platform.BUTTON,
|
||||||
|
Platform.BINARY_SENSOR,
|
||||||
|
Platform.CALENDAR,
|
||||||
|
Platform.NUMBER,
|
||||||
|
Platform.SELECT,
|
||||||
|
Platform.TODO,
|
||||||
|
]
|
||||||
|
|
||||||
# Track if services are registered
|
# Track if services are registered
|
||||||
SERVICES_REGISTERED = "services_registered"
|
SERVICES_REGISTERED = "services_registered"
|
||||||
@@ -152,7 +161,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
hass.async_create_task(coordinator.notifications.handle_mobile_action(event))
|
hass.async_create_task(coordinator.notifications.handle_mobile_action(event))
|
||||||
|
|
||||||
coordinator._unsub_mobile_action = hass.bus.async_listen(
|
coordinator._unsub_mobile_action = hass.bus.async_listen(
|
||||||
"mobile_app_notification_action", _on_mobile_action,
|
"mobile_app_notification_action",
|
||||||
|
_on_mobile_action,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Register frontend static paths
|
# Register frontend static paths
|
||||||
@@ -171,6 +181,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
# stack never blocks setup.
|
# stack never blocks setup.
|
||||||
try:
|
try:
|
||||||
from .intents import async_setup_intents
|
from .intents import async_setup_intents
|
||||||
|
|
||||||
async_setup_intents(hass)
|
async_setup_intents(hass)
|
||||||
except Exception as err: # noqa: BLE001
|
except Exception as err: # noqa: BLE001
|
||||||
_LOGGER.debug("TaskMate intents not registered: %s", err)
|
_LOGGER.debug("TaskMate intents not registered: %s", err)
|
||||||
@@ -187,9 +198,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
await hass.async_add_executor_job(_load_base_descriptions)
|
await hass.async_add_executor_job(_load_base_descriptions)
|
||||||
|
|
||||||
_async_update_service_descriptions(hass)
|
_async_update_service_descriptions(hass)
|
||||||
coordinator.async_add_listener(
|
coordinator.async_add_listener(lambda: _async_update_service_descriptions(hass))
|
||||||
lambda: _async_update_service_descriptions(hass)
|
|
||||||
)
|
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -207,10 +216,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
|
|
||||||
# If no more entries, unregister services. Count only coordinator
|
# If no more entries, unregister services. Count only coordinator
|
||||||
# instances — hass.data[DOMAIN] also holds bookkeeping flags.
|
# instances — hass.data[DOMAIN] also holds bookkeeping flags.
|
||||||
remaining_entries = [
|
remaining_entries = [value for value in hass.data[DOMAIN].values() if isinstance(value, TaskMateCoordinator)]
|
||||||
value for value in hass.data[DOMAIN].values()
|
|
||||||
if isinstance(value, TaskMateCoordinator)
|
|
||||||
]
|
|
||||||
if not remaining_entries:
|
if not remaining_entries:
|
||||||
_async_unregister_services(hass)
|
_async_unregister_services(hass)
|
||||||
hass.data[DOMAIN][SERVICES_REGISTERED] = False
|
hass.data[DOMAIN][SERVICES_REGISTERED] = False
|
||||||
@@ -329,8 +335,16 @@ async def _async_require_parent(hass: HomeAssistant, call: ServiceCall) -> None:
|
|||||||
|
|
||||||
|
|
||||||
_AUDIT_TARGET_KEYS = (
|
_AUDIT_TARGET_KEYS = (
|
||||||
"chore_id", "reward_id", "penalty_id", "bonus_id", "badge_id",
|
"chore_id",
|
||||||
"task_group_id", "miss_id", "claim_id", "transaction_id", "type_id",
|
"reward_id",
|
||||||
|
"penalty_id",
|
||||||
|
"bonus_id",
|
||||||
|
"badge_id",
|
||||||
|
"task_group_id",
|
||||||
|
"miss_id",
|
||||||
|
"claim_id",
|
||||||
|
"transaction_id",
|
||||||
|
"type_id",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -363,9 +377,7 @@ async def _async_record_service_audit(hass: HomeAssistant, call: ServiceCall) ->
|
|||||||
target = f"{key}={call.data[key]}"
|
target = f"{key}={call.data[key]}"
|
||||||
break
|
break
|
||||||
try:
|
try:
|
||||||
await coordinator.async_record_audit(
|
await coordinator.async_record_audit(user_id, user_name, f"service.{call.service}", target)
|
||||||
user_id, user_name, f"service.{call.service}", target
|
|
||||||
)
|
|
||||||
except Exception: # noqa: BLE001 - audit must never break the action
|
except Exception: # noqa: BLE001 - audit must never break the action
|
||||||
_LOGGER.debug("Failed to record service audit for %s", call.service, exc_info=True)
|
_LOGGER.debug("Failed to record service audit for %s", call.service, exc_info=True)
|
||||||
|
|
||||||
@@ -382,18 +394,18 @@ def _safe(handler):
|
|||||||
``ServiceValidationError`` is not a ``ValueError``, so a handler that already
|
``ServiceValidationError`` is not a ``ValueError``, so a handler that already
|
||||||
raises it (e.g. complete_chore) passes through untouched.
|
raises it (e.g. complete_chore) passes through untouched.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@wraps(handler)
|
@wraps(handler)
|
||||||
async def wrapped(call: ServiceCall) -> None:
|
async def wrapped(call: ServiceCall) -> None:
|
||||||
try:
|
try:
|
||||||
await handler(call)
|
await handler(call)
|
||||||
except ValueError as err:
|
except ValueError as err:
|
||||||
raise ServiceValidationError(str(err)) from err
|
raise ServiceValidationError(str(err)) from err
|
||||||
|
|
||||||
return wrapped
|
return wrapped
|
||||||
|
|
||||||
|
|
||||||
async def _async_require_linked_child(
|
async def _async_require_linked_child(hass: HomeAssistant, call: ServiceCall, coordinator, child_id: str) -> None:
|
||||||
hass: HomeAssistant, call: ServiceCall, coordinator, child_id: str
|
|
||||||
) -> None:
|
|
||||||
"""Restrict a child's self-service call to that child's linked HA user.
|
"""Restrict a child's self-service call to that child's linked HA user.
|
||||||
|
|
||||||
Opt-in: only enforced when the child has a ``linked_user_id`` set. Children
|
Opt-in: only enforced when the child has a ``linked_user_id`` set. Children
|
||||||
@@ -426,11 +438,13 @@ async def _async_register_services(hass: HomeAssistant) -> None:
|
|||||||
|
|
||||||
def _admin(handler):
|
def _admin(handler):
|
||||||
"""Wrap a service handler so only admins (or context-less calls) run it."""
|
"""Wrap a service handler so only admins (or context-less calls) run it."""
|
||||||
|
|
||||||
@wraps(handler)
|
@wraps(handler)
|
||||||
async def wrapped(call: ServiceCall) -> None:
|
async def wrapped(call: ServiceCall) -> None:
|
||||||
await _async_require_admin(hass, call)
|
await _async_require_admin(hass, call)
|
||||||
await handler(call)
|
await handler(call)
|
||||||
await _async_record_service_audit(hass, call)
|
await _async_record_service_audit(hass, call)
|
||||||
|
|
||||||
# Compose with _safe so admin handlers also convert coordinator
|
# Compose with _safe so admin handlers also convert coordinator
|
||||||
# ValueErrors into clean validation errors. The admin gate raises
|
# ValueErrors into clean validation errors. The admin gate raises
|
||||||
# Unauthorized (not ValueError), so it is unaffected and still 401s.
|
# Unauthorized (not ValueError), so it is unaffected and still 401s.
|
||||||
@@ -441,11 +455,13 @@ async def _async_register_services(hass: HomeAssistant) -> None:
|
|||||||
|
|
||||||
Used for day-to-day parent actions. Structural config keeps _admin.
|
Used for day-to-day parent actions. Structural config keeps _admin.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@wraps(handler)
|
@wraps(handler)
|
||||||
async def wrapped(call: ServiceCall) -> None:
|
async def wrapped(call: ServiceCall) -> None:
|
||||||
await _async_require_parent(hass, call)
|
await _async_require_parent(hass, call)
|
||||||
await handler(call)
|
await handler(call)
|
||||||
await _async_record_service_audit(hass, call)
|
await _async_record_service_audit(hass, call)
|
||||||
|
|
||||||
return _safe(wrapped)
|
return _safe(wrapped)
|
||||||
|
|
||||||
async def handle_complete_chore(call: ServiceCall) -> None:
|
async def handle_complete_chore(call: ServiceCall) -> None:
|
||||||
@@ -468,7 +484,9 @@ async def _async_register_services(hass: HomeAssistant) -> None:
|
|||||||
await _async_require_linked_child(hass, call, coordinator, child_id)
|
await _async_require_linked_child(hass, call, coordinator, child_id)
|
||||||
try:
|
try:
|
||||||
await coordinator.async_complete_chore(
|
await coordinator.async_complete_chore(
|
||||||
chore_id, child_id, as_parent=as_parent,
|
chore_id,
|
||||||
|
child_id,
|
||||||
|
as_parent=as_parent,
|
||||||
photo_url=call.data.get("photo_url", ""),
|
photo_url=call.data.get("photo_url", ""),
|
||||||
)
|
)
|
||||||
except ValueError as err:
|
except ValueError as err:
|
||||||
@@ -497,9 +515,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
|
|||||||
_LOGGER.error("No TaskMate coordinator available")
|
_LOGGER.error("No TaskMate coordinator available")
|
||||||
return
|
return
|
||||||
await _async_require_linked_child(hass, call, coordinator, call.data[ATTR_CHILD_ID])
|
await _async_require_linked_child(hass, call, coordinator, call.data[ATTR_CHILD_ID])
|
||||||
await coordinator.async_start_timed_task(
|
await coordinator.async_start_timed_task(call.data[ATTR_CHORE_ID], call.data[ATTR_CHILD_ID])
|
||||||
call.data[ATTR_CHORE_ID], call.data[ATTR_CHILD_ID]
|
|
||||||
)
|
|
||||||
|
|
||||||
async def handle_pause_timed_task(call: ServiceCall) -> None:
|
async def handle_pause_timed_task(call: ServiceCall) -> None:
|
||||||
"""Handle the pause_timed_task service call."""
|
"""Handle the pause_timed_task service call."""
|
||||||
@@ -508,9 +524,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
|
|||||||
_LOGGER.error("No TaskMate coordinator available")
|
_LOGGER.error("No TaskMate coordinator available")
|
||||||
return
|
return
|
||||||
await _async_require_linked_child(hass, call, coordinator, call.data[ATTR_CHILD_ID])
|
await _async_require_linked_child(hass, call, coordinator, call.data[ATTR_CHILD_ID])
|
||||||
await coordinator.async_pause_timed_task(
|
await coordinator.async_pause_timed_task(call.data[ATTR_CHORE_ID], call.data[ATTR_CHILD_ID])
|
||||||
call.data[ATTR_CHORE_ID], call.data[ATTR_CHILD_ID]
|
|
||||||
)
|
|
||||||
|
|
||||||
async def handle_stop_timed_task(call: ServiceCall) -> None:
|
async def handle_stop_timed_task(call: ServiceCall) -> None:
|
||||||
"""Handle the stop_timed_task service call."""
|
"""Handle the stop_timed_task service call."""
|
||||||
@@ -519,9 +533,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
|
|||||||
_LOGGER.error("No TaskMate coordinator available")
|
_LOGGER.error("No TaskMate coordinator available")
|
||||||
return
|
return
|
||||||
await _async_require_linked_child(hass, call, coordinator, call.data[ATTR_CHILD_ID])
|
await _async_require_linked_child(hass, call, coordinator, call.data[ATTR_CHILD_ID])
|
||||||
await coordinator.async_stop_timed_task(
|
await coordinator.async_stop_timed_task(call.data[ATTR_CHORE_ID], call.data[ATTR_CHILD_ID])
|
||||||
call.data[ATTR_CHORE_ID], call.data[ATTR_CHILD_ID]
|
|
||||||
)
|
|
||||||
|
|
||||||
async def handle_approve_chore(call: ServiceCall) -> None:
|
async def handle_approve_chore(call: ServiceCall) -> None:
|
||||||
"""Handle the approve_chore service call."""
|
"""Handle the approve_chore service call."""
|
||||||
@@ -604,7 +616,9 @@ async def _async_register_services(hass: HomeAssistant) -> None:
|
|||||||
_LOGGER.error("No TaskMate coordinator available")
|
_LOGGER.error("No TaskMate coordinator available")
|
||||||
return
|
return
|
||||||
await coordinator.async_gift_points(
|
await coordinator.async_gift_points(
|
||||||
call.data["from_child_id"], call.data["to_child_id"], call.data["points"],
|
call.data["from_child_id"],
|
||||||
|
call.data["to_child_id"],
|
||||||
|
call.data["points"],
|
||||||
)
|
)
|
||||||
|
|
||||||
async def handle_record_allowance_payout(call: ServiceCall) -> None:
|
async def handle_record_allowance_payout(call: ServiceCall) -> None:
|
||||||
@@ -614,7 +628,8 @@ async def _async_register_services(hass: HomeAssistant) -> None:
|
|||||||
_LOGGER.error("No TaskMate coordinator available")
|
_LOGGER.error("No TaskMate coordinator available")
|
||||||
return
|
return
|
||||||
await coordinator.async_record_allowance_payout(
|
await coordinator.async_record_allowance_payout(
|
||||||
call.data["child_id"], call.data["points"],
|
call.data["child_id"],
|
||||||
|
call.data["points"],
|
||||||
)
|
)
|
||||||
|
|
||||||
async def handle_request_swap(call: ServiceCall) -> None:
|
async def handle_request_swap(call: ServiceCall) -> None:
|
||||||
@@ -867,9 +882,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
|
|||||||
_LOGGER.error("No TaskMate coordinator available")
|
_LOGGER.error("No TaskMate coordinator available")
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
await coordinator.async_set_chore_manual_start(
|
await coordinator.async_set_chore_manual_start(call.data[ATTR_CHORE_ID], call.data[ATTR_CHILD_ID])
|
||||||
call.data[ATTR_CHORE_ID], call.data[ATTR_CHILD_ID]
|
|
||||||
)
|
|
||||||
except ValueError as err:
|
except ValueError as err:
|
||||||
_LOGGER.warning("set_chore_manual_start rejected: %s", err)
|
_LOGGER.warning("set_chore_manual_start rejected: %s", err)
|
||||||
raise
|
raise
|
||||||
@@ -1154,16 +1167,22 @@ async def _async_register_services(hass: HomeAssistant) -> None:
|
|||||||
|
|
||||||
_miss_schema = vol.Schema({vol.Required("miss_id"): cv.string})
|
_miss_schema = vol.Schema({vol.Required("miss_id"): cv.string})
|
||||||
hass.services.async_register(
|
hass.services.async_register(
|
||||||
DOMAIN, SERVICE_APPLY_MANDATORY_PENALTY,
|
DOMAIN,
|
||||||
_parent(handle_apply_mandatory_penalty), schema=_miss_schema,
|
SERVICE_APPLY_MANDATORY_PENALTY,
|
||||||
|
_parent(handle_apply_mandatory_penalty),
|
||||||
|
schema=_miss_schema,
|
||||||
)
|
)
|
||||||
hass.services.async_register(
|
hass.services.async_register(
|
||||||
DOMAIN, SERVICE_POSTPONE_MANDATORY_CHORE,
|
DOMAIN,
|
||||||
_parent(handle_postpone_mandatory_chore), schema=_miss_schema,
|
SERVICE_POSTPONE_MANDATORY_CHORE,
|
||||||
|
_parent(handle_postpone_mandatory_chore),
|
||||||
|
schema=_miss_schema,
|
||||||
)
|
)
|
||||||
hass.services.async_register(
|
hass.services.async_register(
|
||||||
DOMAIN, SERVICE_DISMISS_MANDATORY_CHORE,
|
DOMAIN,
|
||||||
_parent(handle_dismiss_mandatory_chore), schema=_miss_schema,
|
SERVICE_DISMISS_MANDATORY_CHORE,
|
||||||
|
_parent(handle_dismiss_mandatory_chore),
|
||||||
|
schema=_miss_schema,
|
||||||
)
|
)
|
||||||
|
|
||||||
hass.services.async_register(
|
hass.services.async_register(
|
||||||
@@ -1240,12 +1259,14 @@ async def _async_register_services(hass: HomeAssistant) -> None:
|
|||||||
DOMAIN,
|
DOMAIN,
|
||||||
SERVICE_READ_ALOUD,
|
SERVICE_READ_ALOUD,
|
||||||
_safe(handle_read_aloud),
|
_safe(handle_read_aloud),
|
||||||
schema=vol.Schema({
|
schema=vol.Schema(
|
||||||
vol.Required(ATTR_CHILD_ID): cv.string,
|
{
|
||||||
vol.Optional("media_player", default=""): cv.string,
|
vol.Required(ATTR_CHILD_ID): cv.string,
|
||||||
vol.Optional("tts_entity", default=""): cv.string,
|
vol.Optional("media_player", default=""): cv.string,
|
||||||
vol.Optional("message", default=""): cv.string,
|
vol.Optional("tts_entity", default=""): cv.string,
|
||||||
}),
|
vol.Optional("message", default=""): cv.string,
|
||||||
|
}
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
hass.services.async_register(
|
hass.services.async_register(
|
||||||
@@ -1283,7 +1304,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
|
|||||||
DOMAIN,
|
DOMAIN,
|
||||||
SERVICE_REJECT_REWARD,
|
SERVICE_REJECT_REWARD,
|
||||||
_parent(handle_reject_reward),
|
_parent(handle_reject_reward),
|
||||||
schema=vol.Schema({ vol.Required("claim_id"): cv.string }),
|
schema=vol.Schema({vol.Required("claim_id"): cv.string}),
|
||||||
)
|
)
|
||||||
|
|
||||||
hass.services.async_register(
|
hass.services.async_register(
|
||||||
@@ -1342,11 +1363,28 @@ async def _async_register_services(hass: HomeAssistant) -> None:
|
|||||||
_safe(handle_preview_sound),
|
_safe(handle_preview_sound),
|
||||||
schema=vol.Schema(
|
schema=vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Required(ATTR_SOUND): vol.In([
|
vol.Required(ATTR_SOUND): vol.In(
|
||||||
"none", "coin", "levelup", "fanfare", "chime", "powerup", "undo",
|
[
|
||||||
"fart1", "fart2", "fart3", "fart4", "fart5", "fart6", "fart7",
|
"none",
|
||||||
"fart8", "fart9", "fart10", "fart_random",
|
"coin",
|
||||||
]),
|
"levelup",
|
||||||
|
"fanfare",
|
||||||
|
"chime",
|
||||||
|
"powerup",
|
||||||
|
"undo",
|
||||||
|
"fart1",
|
||||||
|
"fart2",
|
||||||
|
"fart3",
|
||||||
|
"fart4",
|
||||||
|
"fart5",
|
||||||
|
"fart6",
|
||||||
|
"fart7",
|
||||||
|
"fart8",
|
||||||
|
"fart9",
|
||||||
|
"fart10",
|
||||||
|
"fart_random",
|
||||||
|
]
|
||||||
|
),
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -1363,32 +1401,35 @@ async def _async_register_services(hass: HomeAssistant) -> None:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
hass.services.async_register(
|
hass.services.async_register(
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
SERVICE_ADD_PENALTY,
|
SERVICE_ADD_PENALTY,
|
||||||
_admin(handle_add_penalty),
|
_admin(handle_add_penalty),
|
||||||
schema=vol.Schema({
|
schema=vol.Schema(
|
||||||
vol.Required(ATTR_PENALTY_NAME): cv.string,
|
{
|
||||||
vol.Required(ATTR_PENALTY_POINTS): cv.positive_int,
|
vol.Required(ATTR_PENALTY_NAME): cv.string,
|
||||||
vol.Optional(ATTR_PENALTY_DESCRIPTION, default=""): cv.string,
|
vol.Required(ATTR_PENALTY_POINTS): cv.positive_int,
|
||||||
vol.Optional(ATTR_PENALTY_ICON, default="mdi:alert-circle-outline"): cv.string,
|
vol.Optional(ATTR_PENALTY_DESCRIPTION, default=""): cv.string,
|
||||||
vol.Optional(ATTR_PENALTY_ASSIGNED_TO, default=[]): vol.All(cv.ensure_list, [cv.string]),
|
vol.Optional(ATTR_PENALTY_ICON, default="mdi:alert-circle-outline"): cv.string,
|
||||||
}),
|
vol.Optional(ATTR_PENALTY_ASSIGNED_TO, default=[]): vol.All(cv.ensure_list, [cv.string]),
|
||||||
|
}
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
hass.services.async_register(
|
hass.services.async_register(
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
SERVICE_UPDATE_PENALTY,
|
SERVICE_UPDATE_PENALTY,
|
||||||
_admin(handle_update_penalty),
|
_admin(handle_update_penalty),
|
||||||
schema=vol.Schema({
|
schema=vol.Schema(
|
||||||
vol.Required(ATTR_PENALTY_ID): cv.string,
|
{
|
||||||
vol.Optional(ATTR_PENALTY_NAME): cv.string,
|
vol.Required(ATTR_PENALTY_ID): cv.string,
|
||||||
vol.Optional(ATTR_PENALTY_POINTS): cv.positive_int,
|
vol.Optional(ATTR_PENALTY_NAME): cv.string,
|
||||||
vol.Optional(ATTR_PENALTY_DESCRIPTION): cv.string,
|
vol.Optional(ATTR_PENALTY_POINTS): cv.positive_int,
|
||||||
vol.Optional(ATTR_PENALTY_ICON): cv.string,
|
vol.Optional(ATTR_PENALTY_DESCRIPTION): cv.string,
|
||||||
vol.Optional(ATTR_PENALTY_ASSIGNED_TO): vol.All(cv.ensure_list, [cv.string]),
|
vol.Optional(ATTR_PENALTY_ICON): cv.string,
|
||||||
}),
|
vol.Optional(ATTR_PENALTY_ASSIGNED_TO): vol.All(cv.ensure_list, [cv.string]),
|
||||||
|
}
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
hass.services.async_register(
|
hass.services.async_register(
|
||||||
@@ -1402,37 +1443,43 @@ async def _async_register_services(hass: HomeAssistant) -> None:
|
|||||||
DOMAIN,
|
DOMAIN,
|
||||||
SERVICE_APPLY_PENALTY,
|
SERVICE_APPLY_PENALTY,
|
||||||
_parent(handle_apply_penalty),
|
_parent(handle_apply_penalty),
|
||||||
schema=vol.Schema({
|
schema=vol.Schema(
|
||||||
vol.Required(ATTR_PENALTY_ID): cv.string,
|
{
|
||||||
vol.Required(ATTR_CHILD_ID): cv.string,
|
vol.Required(ATTR_PENALTY_ID): cv.string,
|
||||||
}),
|
vol.Required(ATTR_CHILD_ID): cv.string,
|
||||||
|
}
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
hass.services.async_register(
|
hass.services.async_register(
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
SERVICE_ADD_BONUS,
|
SERVICE_ADD_BONUS,
|
||||||
_admin(handle_add_bonus),
|
_admin(handle_add_bonus),
|
||||||
schema=vol.Schema({
|
schema=vol.Schema(
|
||||||
vol.Required(ATTR_BONUS_NAME): cv.string,
|
{
|
||||||
vol.Required(ATTR_BONUS_POINTS): cv.positive_int,
|
vol.Required(ATTR_BONUS_NAME): cv.string,
|
||||||
vol.Optional(ATTR_BONUS_DESCRIPTION, default=""): cv.string,
|
vol.Required(ATTR_BONUS_POINTS): cv.positive_int,
|
||||||
vol.Optional(ATTR_BONUS_ICON, default="mdi:star-circle-outline"): cv.string,
|
vol.Optional(ATTR_BONUS_DESCRIPTION, default=""): cv.string,
|
||||||
vol.Optional(ATTR_BONUS_ASSIGNED_TO, default=[]): vol.All(cv.ensure_list, [cv.string]),
|
vol.Optional(ATTR_BONUS_ICON, default="mdi:star-circle-outline"): cv.string,
|
||||||
}),
|
vol.Optional(ATTR_BONUS_ASSIGNED_TO, default=[]): vol.All(cv.ensure_list, [cv.string]),
|
||||||
|
}
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
hass.services.async_register(
|
hass.services.async_register(
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
SERVICE_UPDATE_BONUS,
|
SERVICE_UPDATE_BONUS,
|
||||||
_admin(handle_update_bonus),
|
_admin(handle_update_bonus),
|
||||||
schema=vol.Schema({
|
schema=vol.Schema(
|
||||||
vol.Required(ATTR_BONUS_ID): cv.string,
|
{
|
||||||
vol.Optional(ATTR_BONUS_NAME): cv.string,
|
vol.Required(ATTR_BONUS_ID): cv.string,
|
||||||
vol.Optional(ATTR_BONUS_POINTS): cv.positive_int,
|
vol.Optional(ATTR_BONUS_NAME): cv.string,
|
||||||
vol.Optional(ATTR_BONUS_DESCRIPTION): cv.string,
|
vol.Optional(ATTR_BONUS_POINTS): cv.positive_int,
|
||||||
vol.Optional(ATTR_BONUS_ICON): cv.string,
|
vol.Optional(ATTR_BONUS_DESCRIPTION): cv.string,
|
||||||
vol.Optional(ATTR_BONUS_ASSIGNED_TO): vol.All(cv.ensure_list, [cv.string]),
|
vol.Optional(ATTR_BONUS_ICON): cv.string,
|
||||||
}),
|
vol.Optional(ATTR_BONUS_ASSIGNED_TO): vol.All(cv.ensure_list, [cv.string]),
|
||||||
|
}
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
hass.services.async_register(
|
hass.services.async_register(
|
||||||
@@ -1446,30 +1493,32 @@ async def _async_register_services(hass: HomeAssistant) -> None:
|
|||||||
DOMAIN,
|
DOMAIN,
|
||||||
SERVICE_APPLY_BONUS,
|
SERVICE_APPLY_BONUS,
|
||||||
_parent(handle_apply_bonus),
|
_parent(handle_apply_bonus),
|
||||||
schema=vol.Schema({
|
schema=vol.Schema(
|
||||||
vol.Required(ATTR_BONUS_ID): cv.string,
|
{
|
||||||
vol.Required(ATTR_CHILD_ID): cv.string,
|
vol.Required(ATTR_BONUS_ID): cv.string,
|
||||||
}),
|
vol.Required(ATTR_CHILD_ID): cv.string,
|
||||||
|
}
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
hass.services.async_register(
|
hass.services.async_register(
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
SERVICE_ADD_CHORE,
|
SERVICE_ADD_CHORE,
|
||||||
_admin(handle_add_chore),
|
_admin(handle_add_chore),
|
||||||
schema=vol.Schema({
|
schema=vol.Schema(
|
||||||
vol.Required(ATTR_CHORE_NAME): cv.string,
|
{
|
||||||
vol.Optional(ATTR_CHORE_DESCRIPTION, default=""): cv.string,
|
vol.Required(ATTR_CHORE_NAME): cv.string,
|
||||||
vol.Optional(ATTR_CHORE_POINTS, default=10): cv.positive_int,
|
vol.Optional(ATTR_CHORE_DESCRIPTION, default=""): cv.string,
|
||||||
vol.Optional(ATTR_CHORE_ASSIGNED_TO, default=[]): vol.All(cv.ensure_list, [cv.string]),
|
vol.Optional(ATTR_CHORE_POINTS, default=10): cv.positive_int,
|
||||||
vol.Optional(ATTR_CHORE_TIME_CATEGORY, default="anytime"): vol.In(TIME_CATEGORIES),
|
vol.Optional(ATTR_CHORE_ASSIGNED_TO, default=[]): vol.All(cv.ensure_list, [cv.string]),
|
||||||
vol.Optional("difficulty", default=DEFAULT_DIFFICULTY): vol.In(DIFFICULTY_TIERS),
|
vol.Optional(ATTR_CHORE_TIME_CATEGORY, default="anytime"): vol.In(TIME_CATEGORIES),
|
||||||
vol.Optional(ATTR_CHORE_ONE_SHOT, default=False): cv.boolean,
|
vol.Optional("difficulty", default=DEFAULT_DIFFICULTY): vol.In(DIFFICULTY_TIERS),
|
||||||
vol.Optional(ATTR_CHORE_REQUIRES_APPROVAL, default=True): cv.boolean,
|
vol.Optional(ATTR_CHORE_ONE_SHOT, default=False): cv.boolean,
|
||||||
vol.Optional(ATTR_CHORE_EXPIRES_IN_MINUTES, default=0): vol.All(
|
vol.Optional(ATTR_CHORE_REQUIRES_APPROVAL, default=True): cv.boolean,
|
||||||
cv.positive_int, vol.Range(max=10080)
|
vol.Optional(ATTR_CHORE_EXPIRES_IN_MINUTES, default=0): vol.All(cv.positive_int, vol.Range(max=10080)),
|
||||||
),
|
vol.Optional(ATTR_CHORE_SPEED_BONUS_POINTS, default=0): cv.positive_int,
|
||||||
vol.Optional(ATTR_CHORE_SPEED_BONUS_POINTS, default=0): cv.positive_int,
|
}
|
||||||
}),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
hass.services.async_register(
|
hass.services.async_register(
|
||||||
@@ -1483,33 +1532,39 @@ async def _async_register_services(hass: HomeAssistant) -> None:
|
|||||||
DOMAIN,
|
DOMAIN,
|
||||||
SERVICE_SET_CHORE_MANUAL_START,
|
SERVICE_SET_CHORE_MANUAL_START,
|
||||||
_admin(handle_set_chore_manual_start),
|
_admin(handle_set_chore_manual_start),
|
||||||
schema=vol.Schema({
|
schema=vol.Schema(
|
||||||
vol.Required(ATTR_CHORE_ID): cv.string,
|
{
|
||||||
vol.Required(ATTR_CHILD_ID): cv.string,
|
vol.Required(ATTR_CHORE_ID): cv.string,
|
||||||
}),
|
vol.Required(ATTR_CHILD_ID): cv.string,
|
||||||
|
}
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
hass.services.async_register(
|
hass.services.async_register(
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
SERVICE_ADD_TASK_GROUP,
|
SERVICE_ADD_TASK_GROUP,
|
||||||
_admin(handle_add_task_group),
|
_admin(handle_add_task_group),
|
||||||
schema=vol.Schema({
|
schema=vol.Schema(
|
||||||
vol.Required(CONF_TASK_GROUP_NAME): cv.string,
|
{
|
||||||
vol.Required(CONF_TASK_GROUP_POLICY): vol.In(TASK_GROUP_POLICIES),
|
vol.Required(CONF_TASK_GROUP_NAME): cv.string,
|
||||||
vol.Optional(CONF_TASK_GROUP_CHORE_IDS, default=[]): vol.All(cv.ensure_list, [cv.string]),
|
vol.Required(CONF_TASK_GROUP_POLICY): vol.In(TASK_GROUP_POLICIES),
|
||||||
}),
|
vol.Optional(CONF_TASK_GROUP_CHORE_IDS, default=[]): vol.All(cv.ensure_list, [cv.string]),
|
||||||
|
}
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
hass.services.async_register(
|
hass.services.async_register(
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
SERVICE_UPDATE_TASK_GROUP,
|
SERVICE_UPDATE_TASK_GROUP,
|
||||||
_admin(handle_update_task_group),
|
_admin(handle_update_task_group),
|
||||||
schema=vol.Schema({
|
schema=vol.Schema(
|
||||||
vol.Required(CONF_TASK_GROUP_ID): cv.string,
|
{
|
||||||
vol.Optional(CONF_TASK_GROUP_NAME): cv.string,
|
vol.Required(CONF_TASK_GROUP_ID): cv.string,
|
||||||
vol.Optional(CONF_TASK_GROUP_POLICY): vol.In(TASK_GROUP_POLICIES),
|
vol.Optional(CONF_TASK_GROUP_NAME): cv.string,
|
||||||
vol.Optional(CONF_TASK_GROUP_CHORE_IDS): vol.All(cv.ensure_list, [cv.string]),
|
vol.Optional(CONF_TASK_GROUP_POLICY): vol.In(TASK_GROUP_POLICIES),
|
||||||
}),
|
vol.Optional(CONF_TASK_GROUP_CHORE_IDS): vol.All(cv.ensure_list, [cv.string]),
|
||||||
|
}
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
hass.services.async_register(
|
hass.services.async_register(
|
||||||
@@ -1523,36 +1578,40 @@ async def _async_register_services(hass: HomeAssistant) -> None:
|
|||||||
DOMAIN,
|
DOMAIN,
|
||||||
"add_badge",
|
"add_badge",
|
||||||
_admin(handle_add_badge),
|
_admin(handle_add_badge),
|
||||||
schema=vol.Schema({
|
schema=vol.Schema(
|
||||||
vol.Required(ATTR_BADGE_NAME): cv.string,
|
{
|
||||||
vol.Optional(ATTR_BADGE_DESCRIPTION, default=""): cv.string,
|
vol.Required(ATTR_BADGE_NAME): cv.string,
|
||||||
vol.Optional(ATTR_BADGE_ICON, default="mdi:trophy"): cv.string,
|
vol.Optional(ATTR_BADGE_DESCRIPTION, default=""): cv.string,
|
||||||
vol.Optional(ATTR_BADGE_TIER, default="bronze"): vol.In(["bronze", "silver", "gold", "platinum"]),
|
vol.Optional(ATTR_BADGE_ICON, default="mdi:trophy"): cv.string,
|
||||||
vol.Optional(ATTR_BADGE_POINT_BONUS, default=0): vol.Coerce(int),
|
vol.Optional(ATTR_BADGE_TIER, default="bronze"): vol.In(["bronze", "silver", "gold", "platinum"]),
|
||||||
vol.Optional(ATTR_BADGE_CRITERIA, default=[]): list,
|
vol.Optional(ATTR_BADGE_POINT_BONUS, default=0): vol.Coerce(int),
|
||||||
vol.Optional(ATTR_BADGE_COMBINATOR, default="AND"): cv.string,
|
vol.Optional(ATTR_BADGE_CRITERIA, default=[]): list,
|
||||||
vol.Optional(ATTR_BADGE_ASSIGNED_TO, default=[]): vol.All(cv.ensure_list, [cv.string]),
|
vol.Optional(ATTR_BADGE_COMBINATOR, default="AND"): cv.string,
|
||||||
vol.Optional(ATTR_BADGE_NOTIFY_ON_EARN, default=True): cv.boolean,
|
vol.Optional(ATTR_BADGE_ASSIGNED_TO, default=[]): vol.All(cv.ensure_list, [cv.string]),
|
||||||
}),
|
vol.Optional(ATTR_BADGE_NOTIFY_ON_EARN, default=True): cv.boolean,
|
||||||
|
}
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
hass.services.async_register(
|
hass.services.async_register(
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
"update_badge",
|
"update_badge",
|
||||||
_admin(handle_update_badge),
|
_admin(handle_update_badge),
|
||||||
schema=vol.Schema({
|
schema=vol.Schema(
|
||||||
vol.Required(ATTR_BADGE_ID): cv.string,
|
{
|
||||||
vol.Optional(ATTR_BADGE_NAME): cv.string,
|
vol.Required(ATTR_BADGE_ID): cv.string,
|
||||||
vol.Optional(ATTR_BADGE_DESCRIPTION): cv.string,
|
vol.Optional(ATTR_BADGE_NAME): cv.string,
|
||||||
vol.Optional(ATTR_BADGE_ICON): cv.string,
|
vol.Optional(ATTR_BADGE_DESCRIPTION): cv.string,
|
||||||
vol.Optional(ATTR_BADGE_TIER): vol.In(["bronze", "silver", "gold", "platinum"]),
|
vol.Optional(ATTR_BADGE_ICON): cv.string,
|
||||||
vol.Optional(ATTR_BADGE_POINT_BONUS): vol.Coerce(int),
|
vol.Optional(ATTR_BADGE_TIER): vol.In(["bronze", "silver", "gold", "platinum"]),
|
||||||
vol.Optional(ATTR_BADGE_CRITERIA): list,
|
vol.Optional(ATTR_BADGE_POINT_BONUS): vol.Coerce(int),
|
||||||
vol.Optional(ATTR_BADGE_COMBINATOR): cv.string,
|
vol.Optional(ATTR_BADGE_CRITERIA): list,
|
||||||
vol.Optional(ATTR_BADGE_ASSIGNED_TO): vol.All(cv.ensure_list, [cv.string]),
|
vol.Optional(ATTR_BADGE_COMBINATOR): cv.string,
|
||||||
vol.Optional(ATTR_BADGE_ENABLED): cv.boolean,
|
vol.Optional(ATTR_BADGE_ASSIGNED_TO): vol.All(cv.ensure_list, [cv.string]),
|
||||||
vol.Optional(ATTR_BADGE_NOTIFY_ON_EARN): cv.boolean,
|
vol.Optional(ATTR_BADGE_ENABLED): cv.boolean,
|
||||||
}),
|
vol.Optional(ATTR_BADGE_NOTIFY_ON_EARN): cv.boolean,
|
||||||
|
}
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
hass.services.async_register(
|
hass.services.async_register(
|
||||||
@@ -1566,10 +1625,12 @@ async def _async_register_services(hass: HomeAssistant) -> None:
|
|||||||
DOMAIN,
|
DOMAIN,
|
||||||
"award_badge_manually",
|
"award_badge_manually",
|
||||||
_parent(handle_award_badge_manually),
|
_parent(handle_award_badge_manually),
|
||||||
schema=vol.Schema({
|
schema=vol.Schema(
|
||||||
vol.Required(ATTR_BADGE_ID): cv.string,
|
{
|
||||||
vol.Required(ATTR_CHILD_ID): cv.string,
|
vol.Required(ATTR_BADGE_ID): cv.string,
|
||||||
}),
|
vol.Required(ATTR_CHILD_ID): cv.string,
|
||||||
|
}
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
hass.services.async_register(
|
hass.services.async_register(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Binary sensor platform for TaskMate integration."""
|
"""Binary sensor platform for TaskMate integration."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from homeassistant.components.binary_sensor import BinarySensorEntity
|
from homeassistant.components.binary_sensor import BinarySensorEntity
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Button platform for TaskMate integration."""
|
"""Button platform for TaskMate integration."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -38,15 +39,11 @@ async def async_setup_entry(
|
|||||||
if getattr(chore, "assignment_mode", "everyone") == "unassigned":
|
if getattr(chore, "assignment_mode", "everyone") == "unassigned":
|
||||||
continue
|
continue
|
||||||
if not chore.assigned_to or child.id in chore.assigned_to:
|
if not chore.assigned_to or child.id in chore.assigned_to:
|
||||||
entities.append(
|
entities.append(CompleteChoreButton(coordinator, entry, child, chore))
|
||||||
CompleteChoreButton(coordinator, entry, child, chore)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Reward claim buttons
|
# Reward claim buttons
|
||||||
for reward in rewards:
|
for reward in rewards:
|
||||||
entities.append(
|
entities.append(ClaimRewardButton(coordinator, entry, child, reward))
|
||||||
ClaimRewardButton(coordinator, entry, child, reward)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Track which entity combos already exist
|
# Track which entity combos already exist
|
||||||
tracked_combos: set[str] = set()
|
tracked_combos: set[str] = set()
|
||||||
@@ -77,16 +74,12 @@ async def async_setup_entry(
|
|||||||
if not chore.assigned_to or child.id in chore.assigned_to:
|
if not chore.assigned_to or child.id in chore.assigned_to:
|
||||||
key = f"{child.id}_{chore.id}_complete"
|
key = f"{child.id}_{chore.id}_complete"
|
||||||
if key not in tracked_combos:
|
if key not in tracked_combos:
|
||||||
new_entities.append(
|
new_entities.append(CompleteChoreButton(coordinator, entry, child, chore))
|
||||||
CompleteChoreButton(coordinator, entry, child, chore)
|
|
||||||
)
|
|
||||||
tracked_combos.add(key)
|
tracked_combos.add(key)
|
||||||
for reward in current_rewards:
|
for reward in current_rewards:
|
||||||
key = f"{child.id}_{reward.id}_claim"
|
key = f"{child.id}_{reward.id}_claim"
|
||||||
if key not in tracked_combos:
|
if key not in tracked_combos:
|
||||||
new_entities.append(
|
new_entities.append(ClaimRewardButton(coordinator, entry, child, reward))
|
||||||
ClaimRewardButton(coordinator, entry, child, reward)
|
|
||||||
)
|
|
||||||
tracked_combos.add(key)
|
tracked_combos.add(key)
|
||||||
|
|
||||||
if new_entities:
|
if new_entities:
|
||||||
@@ -142,7 +135,7 @@ class CompleteChoreButton(TaskMateBaseButton):
|
|||||||
# Chores gained an optional icon in #683, defaulting to "". Fall back on
|
# Chores gained an optional icon in #683, defaulting to "". Fall back on
|
||||||
# falsiness, not on the attribute being absent — otherwise every chore
|
# falsiness, not on the attribute being absent — otherwise every chore
|
||||||
# without a picture gets a blank button icon.
|
# without a picture gets a blank button icon.
|
||||||
return (getattr(chore, 'icon', "") or "mdi:check-circle") if chore else "mdi:check-circle"
|
return (getattr(chore, "icon", "") or "mdi:check-circle") if chore else "mdi:check-circle"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def extra_state_attributes(self) -> dict:
|
def extra_state_attributes(self) -> dict:
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ to keep in sync:
|
|||||||
Read-only for now: completing a chore from the calendar is intentionally not
|
Read-only for now: completing a chore from the calendar is intentionally not
|
||||||
supported.
|
supported.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -67,9 +68,7 @@ async def async_setup_entry(
|
|||||||
coordinator.async_add_listener(_async_add_new)
|
coordinator.async_add_listener(_async_add_new)
|
||||||
|
|
||||||
|
|
||||||
def _chore_applies_to_child(
|
def _chore_applies_to_child(coordinator: TaskMateCoordinator, chore: Chore, child_id: str, day: date) -> bool:
|
||||||
coordinator: TaskMateCoordinator, chore: Chore, child_id: str, day: date
|
|
||||||
) -> bool:
|
|
||||||
"""True if ``chore`` is scheduled for ``child_id`` on ``day``.
|
"""True if ``chore`` is scheduled for ``child_id`` on ``day``.
|
||||||
|
|
||||||
Combines the recurrence schedule with the assignment engine so the calendar
|
Combines the recurrence schedule with the assignment engine so the calendar
|
||||||
@@ -165,9 +164,7 @@ class TaskMateCalendar(CoordinatorEntity, CalendarEntity):
|
|||||||
return []
|
return []
|
||||||
return self._build_events(child, start_date.date(), end_date.date())
|
return self._build_events(child, start_date.date(), end_date.date())
|
||||||
|
|
||||||
def _build_events(
|
def _build_events(self, child: Child, start_day: date, end_day: date) -> list[CalendarEvent]:
|
||||||
self, child: Child, start_day: date, end_day: date
|
|
||||||
) -> list[CalendarEvent]:
|
|
||||||
coord = self.coordinator
|
coord = self.coordinator
|
||||||
events: list[CalendarEvent] = []
|
events: list[CalendarEvent] = []
|
||||||
|
|
||||||
@@ -197,25 +194,27 @@ class TaskMateCalendar(CoordinatorEntity, CalendarEntity):
|
|||||||
for chore in chores:
|
for chore in chores:
|
||||||
if not _chore_applies_to_child(coord, chore, child.id, day):
|
if not _chore_applies_to_child(coord, chore, child.id, day):
|
||||||
continue
|
continue
|
||||||
window = coord._time_category_window(
|
window = coord._time_category_window(getattr(chore, "time_category", "anytime"), day)
|
||||||
getattr(chore, "time_category", "anytime"), day
|
|
||||||
)
|
|
||||||
desc = _chore_description(chore)
|
desc = _chore_description(chore)
|
||||||
if window is None:
|
if window is None:
|
||||||
events.append(CalendarEvent(
|
events.append(
|
||||||
start=day,
|
CalendarEvent(
|
||||||
end=day + timedelta(days=1),
|
start=day,
|
||||||
summary=chore.name,
|
end=day + timedelta(days=1),
|
||||||
description=desc,
|
summary=chore.name,
|
||||||
))
|
description=desc,
|
||||||
|
)
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
start_dt, end_dt = window
|
start_dt, end_dt = window
|
||||||
events.append(CalendarEvent(
|
events.append(
|
||||||
start=start_dt.replace(tzinfo=tz),
|
CalendarEvent(
|
||||||
end=end_dt.replace(tzinfo=tz),
|
start=start_dt.replace(tzinfo=tz),
|
||||||
summary=chore.name,
|
end=end_dt.replace(tzinfo=tz),
|
||||||
description=desc,
|
summary=chore.name,
|
||||||
))
|
description=desc,
|
||||||
|
)
|
||||||
|
)
|
||||||
day += timedelta(days=1)
|
day += timedelta(days=1)
|
||||||
|
|
||||||
return events
|
return events
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ is handled entirely by the dedicated TaskMate admin panel at
|
|||||||
all of its functionality is available in the panel, and all data is stored in
|
all of its functionality is available in the panel, and all data is stored in
|
||||||
the integration's own ``Store`` rather than in ``config_entry.options``.
|
the integration's own ``Store`` rather than in ``config_entry.options``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -24,9 +25,7 @@ class TaskMateConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
|
|
||||||
VERSION = 1
|
VERSION = 1
|
||||||
|
|
||||||
async def async_step_user(
|
async def async_step_user(self, user_input: dict[str, Any] | None = None) -> FlowResult:
|
||||||
self, user_input: dict[str, Any] | None = None
|
|
||||||
) -> FlowResult:
|
|
||||||
"""Handle the initial step."""
|
"""Handle the initial step."""
|
||||||
errors: dict[str, str] = {}
|
errors: dict[str, str] = {}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Constants for TaskMate integration."""
|
"""Constants for TaskMate integration."""
|
||||||
|
|
||||||
from typing import Final
|
from typing import Final
|
||||||
|
|
||||||
DOMAIN: Final = "taskmate"
|
DOMAIN: Final = "taskmate"
|
||||||
@@ -131,10 +132,10 @@ TIME_CATEGORY_ICONS: Final = {
|
|||||||
# always available) and never appears in this list. An empty label means
|
# always available) and never appears in this list. An empty label means
|
||||||
# "use the translated built-in name for this id".
|
# "use the translated built-in name for this id".
|
||||||
DEFAULT_TIME_PERIODS: Final = [
|
DEFAULT_TIME_PERIODS: Final = [
|
||||||
{"id": "morning", "label": "", "start": "06:00", "end": "12:00", "icon": "mdi:weather-sunny"},
|
{"id": "morning", "label": "", "start": "06:00", "end": "12:00", "icon": "mdi:weather-sunny"},
|
||||||
{"id": "afternoon", "label": "", "start": "12:00", "end": "17:00", "icon": "mdi:white-balance-sunny"},
|
{"id": "afternoon", "label": "", "start": "12:00", "end": "17:00", "icon": "mdi:white-balance-sunny"},
|
||||||
{"id": "evening", "label": "", "start": "17:00", "end": "21:00", "icon": "mdi:weather-sunset"},
|
{"id": "evening", "label": "", "start": "17:00", "end": "21:00", "icon": "mdi:weather-sunset"},
|
||||||
{"id": "night", "label": "", "start": "21:00", "end": "23:59", "icon": "mdi:weather-night"},
|
{"id": "night", "label": "", "start": "21:00", "end": "23:59", "icon": "mdi:weather-night"},
|
||||||
]
|
]
|
||||||
|
|
||||||
MAX_TIME_PERIODS: Final = 24
|
MAX_TIME_PERIODS: Final = 24
|
||||||
@@ -340,24 +341,24 @@ STATE_CLAIMED: Final = "claimed"
|
|||||||
# Most sounds are synthesized via Web Audio API
|
# Most sounds are synthesized via Web Audio API
|
||||||
# Fart sounds are CC0 audio files from BigSoundBank.com and GfxSounds.com
|
# Fart sounds are CC0 audio files from BigSoundBank.com and GfxSounds.com
|
||||||
COMPLETION_SOUND_OPTIONS: Final = [
|
COMPLETION_SOUND_OPTIONS: Final = [
|
||||||
"none", # No sound
|
"none", # No sound
|
||||||
"coin", # Coin collect sound
|
"coin", # Coin collect sound
|
||||||
"levelup", # Level up / success sound
|
"levelup", # Level up / success sound
|
||||||
"fanfare", # Celebratory fanfare
|
"fanfare", # Celebratory fanfare
|
||||||
"chime", # Simple chime
|
"chime", # Simple chime
|
||||||
"powerup", # Power up sound
|
"powerup", # Power up sound
|
||||||
"undo", # Sad/descending "womp womp" for undo actions
|
"undo", # Sad/descending "womp womp" for undo actions
|
||||||
"fart1", # Flatulence 1 (short)
|
"fart1", # Flatulence 1 (short)
|
||||||
"fart2", # Flatulence 2 (short)
|
"fart2", # Flatulence 2 (short)
|
||||||
"fart3", # Flatulence 3 (short)
|
"fart3", # Flatulence 3 (short)
|
||||||
"fart4", # Pony flatulence 2 (~3 sec)
|
"fart4", # Pony flatulence 2 (~3 sec)
|
||||||
"fart5", # Flatulence 4 - discreet (short)
|
"fart5", # Flatulence 4 - discreet (short)
|
||||||
"fart6", # Prout'cochons 1 - pig game sound (short)
|
"fart6", # Prout'cochons 1 - pig game sound (short)
|
||||||
"fart7", # Prout'cochons 2 - pig game sound (short)
|
"fart7", # Prout'cochons 2 - pig game sound (short)
|
||||||
"fart8", # Prout'cochons 3 - pig game sound (short)
|
"fart8", # Prout'cochons 3 - pig game sound (short)
|
||||||
"fart9", # Pony flatulence 1 (short)
|
"fart9", # Pony flatulence 1 (short)
|
||||||
"fart10", # Baby fart (short)
|
"fart10", # Baby fart (short)
|
||||||
"fart_random", # Random fart - picks a random fart sound each time!
|
"fart_random", # Random fart - picks a random fart sound each time!
|
||||||
]
|
]
|
||||||
|
|
||||||
# Default completion sound
|
# Default completion sound
|
||||||
@@ -377,22 +378,22 @@ DEFAULT_DIFFICULTY: Final = "medium"
|
|||||||
DEFAULT_DIFFICULTY_MULTIPLIERS: Final = {"easy": 0.5, "medium": 1.0, "hard": 2.0}
|
DEFAULT_DIFFICULTY_MULTIPLIERS: Final = {"easy": 0.5, "medium": 1.0, "hard": 2.0}
|
||||||
|
|
||||||
# --- Notification type IDs (v3.9.0) ---
|
# --- Notification type IDs (v3.9.0) ---
|
||||||
NOTIF_TYPE_BEDTIME_REMINDER: Final = "bedtime_reminder"
|
NOTIF_TYPE_BEDTIME_REMINDER: Final = "bedtime_reminder"
|
||||||
NOTIF_TYPE_STREAK_AT_RISK: Final = "streak_at_risk"
|
NOTIF_TYPE_STREAK_AT_RISK: Final = "streak_at_risk"
|
||||||
NOTIF_TYPE_ALL_CHORES_DONE: Final = "all_chores_done"
|
NOTIF_TYPE_ALL_CHORES_DONE: Final = "all_chores_done"
|
||||||
NOTIF_TYPE_BADGE_EARNED: Final = "badge_earned"
|
NOTIF_TYPE_BADGE_EARNED: Final = "badge_earned"
|
||||||
NOTIF_TYPE_PENDING_CHORE_APPROVAL: Final = "pending_chore_approval"
|
NOTIF_TYPE_PENDING_CHORE_APPROVAL: Final = "pending_chore_approval"
|
||||||
NOTIF_TYPE_PENDING_REWARD_CLAIM: Final = "pending_reward_claim"
|
NOTIF_TYPE_PENDING_REWARD_CLAIM: Final = "pending_reward_claim"
|
||||||
NOTIF_TYPE_STREAK_MILESTONE: Final = "streak_milestone"
|
NOTIF_TYPE_STREAK_MILESTONE: Final = "streak_milestone"
|
||||||
NOTIF_TYPE_LEVEL_UP: Final = "level_up"
|
NOTIF_TYPE_LEVEL_UP: Final = "level_up"
|
||||||
NOTIF_TYPE_WEEKLY_DIGEST: Final = "weekly_digest"
|
NOTIF_TYPE_WEEKLY_DIGEST: Final = "weekly_digest"
|
||||||
NOTIF_TYPE_CELEBRATION: Final = "celebration"
|
NOTIF_TYPE_CELEBRATION: Final = "celebration"
|
||||||
NOTIF_TYPE_MANDATORY_REMINDER: Final = "mandatory_reminder"
|
NOTIF_TYPE_MANDATORY_REMINDER: Final = "mandatory_reminder"
|
||||||
NOTIF_TYPE_MANDATORY_PARENT_ALERT: Final = "mandatory_parent_alert"
|
NOTIF_TYPE_MANDATORY_PARENT_ALERT: Final = "mandatory_parent_alert"
|
||||||
NOTIF_TYPE_MONTHLY_REPORT: Final = "monthly_report"
|
NOTIF_TYPE_MONTHLY_REPORT: Final = "monthly_report"
|
||||||
NOTIF_TYPE_SEASON_CHAMPION: Final = "season_champion"
|
NOTIF_TYPE_SEASON_CHAMPION: Final = "season_champion"
|
||||||
NOTIF_TYPE_FAMILY_GOAL_REACHED: Final = "family_goal_reached"
|
NOTIF_TYPE_FAMILY_GOAL_REACHED: Final = "family_goal_reached"
|
||||||
|
|
||||||
# Default notification tap target. Must match PANEL_URL_PATH in panel.py —
|
# Default notification tap target. Must match PANEL_URL_PATH in panel.py —
|
||||||
# a bare /taskmate is the static-files prefix and returns 403, not the panel.
|
# a bare /taskmate is the static-files prefix and returns 403, not the panel.
|
||||||
DEFAULT_NOTIFICATION_NAV_URL: Final = "/taskmate-admin"
|
DEFAULT_NOTIFICATION_NAV_URL: Final = "/taskmate-admin"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Assignment operations mixin for TaskMateCoordinator."""
|
"""Assignment operations mixin for TaskMateCoordinator."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -118,9 +119,15 @@ class AssignmentsMixin:
|
|||||||
await self.storage.async_save()
|
await self.storage.async_save()
|
||||||
await self.async_refresh()
|
await self.async_refresh()
|
||||||
|
|
||||||
_AVAILABLE_STATES: frozenset[str] = frozenset({
|
_AVAILABLE_STATES: frozenset[str] = frozenset(
|
||||||
"on", "home", "available", "present", "true",
|
{
|
||||||
})
|
"on",
|
||||||
|
"home",
|
||||||
|
"available",
|
||||||
|
"present",
|
||||||
|
"true",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
def _is_visibility_entity_active(
|
def _is_visibility_entity_active(
|
||||||
self, visibility_entity: str, visibility_state: str, visibility_operator: str = "equals"
|
self, visibility_entity: str, visibility_state: str, visibility_operator: str = "equals"
|
||||||
@@ -201,7 +208,7 @@ class AssignmentsMixin:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
# Check attributes for a matching value
|
# Check attributes for a matching value
|
||||||
if hasattr(state_obj, 'attributes') and state_obj.attributes:
|
if hasattr(state_obj, "attributes") and state_obj.attributes:
|
||||||
for attr_value in state_obj.attributes.values():
|
for attr_value in state_obj.attributes.values():
|
||||||
if str(attr_value).lower() == parsed_state.lower():
|
if str(attr_value).lower() == parsed_state.lower():
|
||||||
return True
|
return True
|
||||||
@@ -232,7 +239,8 @@ class AssignmentsMixin:
|
|||||||
if state_obj is None or state_obj.state in ("unavailable", "unknown", None, ""):
|
if state_obj is None or state_obj.state in ("unavailable", "unknown", None, ""):
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"Weather entity '%s' unavailable, not blocking chore '%s'",
|
"Weather entity '%s' unavailable, not blocking chore '%s'",
|
||||||
entity_id, getattr(chore, "name", ""),
|
entity_id,
|
||||||
|
getattr(chore, "name", ""),
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -352,6 +360,26 @@ class AssignmentsMixin:
|
|||||||
return cached
|
return cached
|
||||||
return self._compute_active_children_uncached(chore, today)
|
return self._compute_active_children_uncached(chore, today)
|
||||||
|
|
||||||
|
def _swap_override(self, chore: Chore, today: date | None = None) -> str:
|
||||||
|
"""The child an approved sibling swap moved this chore to for ``today``.
|
||||||
|
|
||||||
|
Returns "" when there is no swap for that date, or when the swapped-to
|
||||||
|
child is no longer in the chore's pool (removed from `assigned_to`, or
|
||||||
|
deleted). Stamped with a date so it expires on its own — a swap is a
|
||||||
|
one-day arrangement, and probes of other days must stay pure rotation.
|
||||||
|
"""
|
||||||
|
swap_date = getattr(chore, "assignment_swap_date", "") or ""
|
||||||
|
swapped_to = getattr(chore, "assignment_swap_child_id", "") or ""
|
||||||
|
if not swap_date or not swapped_to:
|
||||||
|
return ""
|
||||||
|
if today is None:
|
||||||
|
today = dt_util.as_local(dt_util.now()).date()
|
||||||
|
if swap_date != today.isoformat():
|
||||||
|
return ""
|
||||||
|
if swapped_to not in self._chore_assignment_pool(chore):
|
||||||
|
return ""
|
||||||
|
return swapped_to
|
||||||
|
|
||||||
def _compute_active_children_uncached(self, chore: Chore, today: date | None = None) -> list[str]:
|
def _compute_active_children_uncached(self, chore: Chore, today: date | None = None) -> list[str]:
|
||||||
mode = getattr(chore, "assignment_mode", "everyone")
|
mode = getattr(chore, "assignment_mode", "everyone")
|
||||||
require_availability = getattr(chore, "require_availability", False)
|
require_availability = getattr(chore, "require_availability", False)
|
||||||
@@ -359,6 +387,16 @@ class AssignmentsMixin:
|
|||||||
if mode == "unassigned":
|
if mode == "unassigned":
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
# An approved swap replaces the whole active set for that day, ahead of
|
||||||
|
# every mode's own logic. `require_availability` is deliberately not
|
||||||
|
# re-applied: a parent explicitly approved this child for today, which
|
||||||
|
# outranks an availability entity. "everyone" chores have no single
|
||||||
|
# assignee to move, and async_request_swap already refuses them.
|
||||||
|
if mode != "everyone":
|
||||||
|
swapped_to = self._swap_override(chore, today)
|
||||||
|
if swapped_to:
|
||||||
|
return [swapped_to]
|
||||||
|
|
||||||
if mode == "first_come":
|
if mode == "first_come":
|
||||||
# Competitive: every child in the resolved pool sees it until the
|
# Competitive: every child in the resolved pool sees it until the
|
||||||
# first completion fills the shared quota (see _is_rotation_done_today).
|
# first completion fills the shared quota (see _is_rotation_done_today).
|
||||||
@@ -458,9 +496,7 @@ class AssignmentsMixin:
|
|||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def _apply_sticky_policy(
|
def _apply_sticky_policy(self, group, chore_by_id: dict[str, Chore], result: dict[str, str]) -> None:
|
||||||
self, group, chore_by_id: dict[str, Chore], result: dict[str, str]
|
|
||||||
) -> None:
|
|
||||||
"""Force followers onto the leader chore's assignee (when in pool)."""
|
"""Force followers onto the leader chore's assignee (when in pool)."""
|
||||||
leader_id = group.chore_ids[0]
|
leader_id = group.chore_ids[0]
|
||||||
leader_child = result.get(leader_id)
|
leader_child = result.get(leader_id)
|
||||||
@@ -478,12 +514,12 @@ class AssignmentsMixin:
|
|||||||
else:
|
else:
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"STICKY fallback: leader %s assigned to %s not in follower %s pool",
|
"STICKY fallback: leader %s assigned to %s not in follower %s pool",
|
||||||
leader_id, leader_child, follower_id,
|
leader_id,
|
||||||
|
leader_child,
|
||||||
|
follower_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _apply_spread_policy(
|
def _apply_spread_policy(self, group, chore_by_id: dict[str, Chore], result: dict[str, str]) -> None:
|
||||||
self, group, chore_by_id: dict[str, Chore], result: dict[str, str]
|
|
||||||
) -> None:
|
|
||||||
"""Assign group members to distinct children; wraps when pool < group size."""
|
"""Assign group members to distinct children; wraps when pool < group size."""
|
||||||
used: set[str] = set()
|
used: set[str] = set()
|
||||||
for chore_id in group.chore_ids:
|
for chore_id in group.chore_ids:
|
||||||
@@ -529,17 +565,18 @@ class AssignmentsMixin:
|
|||||||
size = len(pool)
|
size = len(pool)
|
||||||
# Cache per-call so the same child isn't queried twice in a scan.
|
# Cache per-call so the same child isn't queried twice in a scan.
|
||||||
cache: dict[str, bool] = {}
|
cache: dict[str, bool] = {}
|
||||||
|
|
||||||
def available(cid: str) -> bool:
|
def available(cid: str) -> bool:
|
||||||
if cid not in cache:
|
if cid not in cache:
|
||||||
cache[cid] = self._is_child_available(cid)
|
cache[cid] = self._is_child_available(cid)
|
||||||
return cache[cid]
|
return cache[cid]
|
||||||
|
|
||||||
for step in range(size):
|
for step in range(size):
|
||||||
cid = pool[(start_idx + step) % size]
|
cid = pool[(start_idx + step) % size]
|
||||||
if available(cid):
|
if available(cid):
|
||||||
return cid
|
return cid
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"Availability skip: no available child in pool %s for chore, "
|
"Availability skip: no available child in pool %s for chore, hiding chore (all children unavailable)",
|
||||||
"hiding chore (all children unavailable)",
|
|
||||||
pool,
|
pool,
|
||||||
)
|
)
|
||||||
return ""
|
return ""
|
||||||
@@ -557,7 +594,7 @@ class AssignmentsMixin:
|
|||||||
active child still has uncompleted bonus sub-tasks for today, keep
|
active child still has uncompleted bonus sub-tasks for today, keep
|
||||||
the chore visible (return False) so they remain reachable.
|
the chore visible (return False) so they remain reachable.
|
||||||
"""
|
"""
|
||||||
if getattr(chore, 'assignment_mode', 'everyone') == 'everyone':
|
if getattr(chore, "assignment_mode", "everyone") == "everyone":
|
||||||
return False
|
return False
|
||||||
# PERF-1: result depends only on the chore; memoize per availability build.
|
# PERF-1: result depends only on the chore; memoize per availability build.
|
||||||
cache = getattr(self, "_avail_cache", None)
|
cache = getattr(self, "_avail_cache", None)
|
||||||
@@ -573,7 +610,7 @@ class AssignmentsMixin:
|
|||||||
if not pool:
|
if not pool:
|
||||||
return False
|
return False
|
||||||
today = dt_util.as_local(dt_util.now()).date()
|
today = dt_util.as_local(dt_util.now()).date()
|
||||||
active_child_id = getattr(chore, 'assignment_current_child_id', '') or ''
|
active_child_id = getattr(chore, "assignment_current_child_id", "") or ""
|
||||||
completions_today = 0
|
completions_today = 0
|
||||||
completed_bonus_ids_today: set[str] = set()
|
completed_bonus_ids_today: set[str] = set()
|
||||||
for comp in self._cached_completions():
|
for comp in self._cached_completions():
|
||||||
@@ -581,14 +618,14 @@ class AssignmentsMixin:
|
|||||||
continue
|
continue
|
||||||
comp_dt = comp.completed_at
|
comp_dt = comp.completed_at
|
||||||
try:
|
try:
|
||||||
if hasattr(comp_dt, 'astimezone'):
|
if hasattr(comp_dt, "astimezone"):
|
||||||
comp_dt = dt_util.as_local(comp_dt)
|
comp_dt = dt_util.as_local(comp_dt)
|
||||||
comp_date = comp_dt.date() if hasattr(comp_dt, 'date') else None
|
comp_date = comp_dt.date() if hasattr(comp_dt, "date") else None
|
||||||
except (AttributeError, TypeError, ValueError):
|
except (AttributeError, TypeError, ValueError):
|
||||||
continue
|
continue
|
||||||
if comp_date != today:
|
if comp_date != today:
|
||||||
continue
|
continue
|
||||||
bonus_id = getattr(comp, 'bonus_subtask_id', None)
|
bonus_id = getattr(comp, "bonus_subtask_id", None)
|
||||||
if bonus_id:
|
if bonus_id:
|
||||||
# Bonus completions don't count toward the parent's daily
|
# Bonus completions don't count toward the parent's daily
|
||||||
# quota; track them only to decide whether the active child
|
# quota; track them only to decide whether the active child
|
||||||
@@ -601,16 +638,16 @@ class AssignmentsMixin:
|
|||||||
if comp.child_id in pool or comp.child_id == "__parent__":
|
if comp.child_id in pool or comp.child_id == "__parent__":
|
||||||
completions_today += 1
|
completions_today += 1
|
||||||
# first_come is a single-winner race: clamp any mis-configured quota to 1.
|
# first_come is a single-winner race: clamp any mis-configured quota to 1.
|
||||||
if getattr(chore, 'assignment_mode', 'everyone') == 'first_come':
|
if getattr(chore, "assignment_mode", "everyone") == "first_come":
|
||||||
daily_limit = 1
|
daily_limit = 1
|
||||||
else:
|
else:
|
||||||
daily_limit = getattr(chore, 'daily_limit', 1) or 1
|
daily_limit = getattr(chore, "daily_limit", 1) or 1
|
||||||
if completions_today < daily_limit:
|
if completions_today < daily_limit:
|
||||||
return False
|
return False
|
||||||
bonus_subtasks = getattr(chore, 'bonus_subtasks', None) or []
|
bonus_subtasks = getattr(chore, "bonus_subtasks", None) or []
|
||||||
if bonus_subtasks and active_child_id:
|
if bonus_subtasks and active_child_id:
|
||||||
for bst in bonus_subtasks:
|
for bst in bonus_subtasks:
|
||||||
bst_id = getattr(bst, 'id', None)
|
bst_id = getattr(bst, "id", None)
|
||||||
if bst_id and bst_id not in completed_bonus_ids_today:
|
if bst_id and bst_id not in completed_bonus_ids_today:
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
@@ -621,8 +658,8 @@ class AssignmentsMixin:
|
|||||||
Runs at midnight. All chores are processed concurrently so the runtime
|
Runs at midnight. All chores are processed concurrently so the runtime
|
||||||
is bounded by the slowest single publish, not the sum across chores.
|
is bounded by the slowest single publish, not the sum across chores.
|
||||||
|
|
||||||
Also clears stale skip state (skip_date != today) so yesterday's skip
|
Also clears stale skip and swap state (dated != today) so yesterday's
|
||||||
doesn't bleed into the new day.
|
skip or approved sibling swap doesn't bleed into the new day.
|
||||||
"""
|
"""
|
||||||
today = dt_util.as_local(dt_util.now()).date()
|
today = dt_util.as_local(dt_util.now()).date()
|
||||||
today_iso = today.isoformat()
|
today_iso = today.isoformat()
|
||||||
@@ -630,11 +667,16 @@ class AssignmentsMixin:
|
|||||||
if not chores:
|
if not chores:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Clear stale skip state in-memory (persisted via update_chore below).
|
# Clear stale skip/swap state in-memory (persisted via update_chore
|
||||||
|
# below). Both are read-time-guarded by their date too, so this is
|
||||||
|
# housekeeping rather than a correctness requirement.
|
||||||
for chore in chores:
|
for chore in chores:
|
||||||
if getattr(chore, "skip_date", "") and chore.skip_date != today_iso:
|
if getattr(chore, "skip_date", "") and chore.skip_date != today_iso:
|
||||||
chore.skip_date = ""
|
chore.skip_date = ""
|
||||||
chore.skip_count = 0
|
chore.skip_count = 0
|
||||||
|
if getattr(chore, "assignment_swap_date", "") and chore.assignment_swap_date != today_iso:
|
||||||
|
chore.assignment_swap_date = ""
|
||||||
|
chore.assignment_swap_child_id = ""
|
||||||
|
|
||||||
# Group-aware daily assignment map.
|
# Group-aware daily assignment map.
|
||||||
daily = self._compute_daily_assignments(today)
|
daily = self._compute_daily_assignments(today)
|
||||||
@@ -650,11 +692,15 @@ class AssignmentsMixin:
|
|||||||
await self._publish_chore_to_calendars(chore, today)
|
await self._publish_chore_to_calendars(chore, today)
|
||||||
if list(getattr(chore, "publish_calendar_published_dates", []) or []) != before:
|
if list(getattr(chore, "publish_calendar_published_dates", []) or []) != before:
|
||||||
dirty = True
|
dirty = True
|
||||||
# Always persist if skip state was cleared above.
|
# Always persist if skip/swap state was cleared above.
|
||||||
if getattr(chore, "skip_date", "") == "" and getattr(chore, "skip_count", 0) == 0:
|
if getattr(chore, "skip_date", "") == "" and getattr(chore, "skip_count", 0) == 0:
|
||||||
stored = self.storage.get_chore(chore.id)
|
stored = self.storage.get_chore(chore.id)
|
||||||
if stored and (stored.skip_date or stored.skip_count):
|
if stored and (stored.skip_date or stored.skip_count):
|
||||||
dirty = True
|
dirty = True
|
||||||
|
if getattr(chore, "assignment_swap_date", "") == "":
|
||||||
|
stored = self.storage.get_chore(chore.id)
|
||||||
|
if stored and getattr(stored, "assignment_swap_date", ""):
|
||||||
|
dirty = True
|
||||||
if dirty:
|
if dirty:
|
||||||
self.storage.update_chore(chore)
|
self.storage.update_chore(chore)
|
||||||
return dirty
|
return dirty
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ a lifetime-points total, or a best-streak length — or ``free`` for always
|
|||||||
available). Children unlock avatars by hitting those milestones and can switch
|
available). Children unlock avatars by hitting those milestones and can switch
|
||||||
to any avatar they've unlocked; parents can set any catalogue avatar.
|
to any avatar they've unlocked; parents can set any catalogue avatar.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -14,14 +15,14 @@ _LOGGER = logging.getLogger(__name__)
|
|||||||
# Shipped defaults so the feature is useful out of the box. Parents can replace
|
# Shipped defaults so the feature is useful out of the box. Parents can replace
|
||||||
# the whole list from the panel.
|
# the whole list from the panel.
|
||||||
DEFAULT_AVATAR_CATALOG: list[dict] = [
|
DEFAULT_AVATAR_CATALOG: list[dict] = [
|
||||||
{"id": "starter", "label": "Starter", "icon": "mdi:account-circle", "unlock_type": "free", "unlock_value": 0},
|
{"id": "starter", "label": "Starter", "icon": "mdi:account-circle", "unlock_type": "free", "unlock_value": 0},
|
||||||
{"id": "rocket", "label": "Rocket", "icon": "mdi:rocket-launch", "unlock_type": "level", "unlock_value": 3},
|
{"id": "rocket", "label": "Rocket", "icon": "mdi:rocket-launch", "unlock_type": "level", "unlock_value": 3},
|
||||||
{"id": "robot", "label": "Robot", "icon": "mdi:robot-happy", "unlock_type": "level", "unlock_value": 5},
|
{"id": "robot", "label": "Robot", "icon": "mdi:robot-happy", "unlock_type": "level", "unlock_value": 5},
|
||||||
{"id": "ninja", "label": "Ninja", "icon": "mdi:ninja", "unlock_type": "level", "unlock_value": 10},
|
{"id": "ninja", "label": "Ninja", "icon": "mdi:ninja", "unlock_type": "level", "unlock_value": 10},
|
||||||
{"id": "crown", "label": "Royalty", "icon": "mdi:crown", "unlock_type": "points", "unlock_value": 500},
|
{"id": "crown", "label": "Royalty", "icon": "mdi:crown", "unlock_type": "points", "unlock_value": 500},
|
||||||
{"id": "trophy", "label": "Champion", "icon": "mdi:trophy", "unlock_type": "points", "unlock_value": 1000},
|
{"id": "trophy", "label": "Champion", "icon": "mdi:trophy", "unlock_type": "points", "unlock_value": 1000},
|
||||||
{"id": "fire", "label": "On Fire", "icon": "mdi:fire", "unlock_type": "streak", "unlock_value": 7},
|
{"id": "fire", "label": "On Fire", "icon": "mdi:fire", "unlock_type": "streak", "unlock_value": 7},
|
||||||
{"id": "diamond", "label": "Diamond", "icon": "mdi:diamond-stone", "unlock_type": "streak", "unlock_value": 30},
|
{"id": "diamond", "label": "Diamond", "icon": "mdi:diamond-stone", "unlock_type": "streak", "unlock_value": 30},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -67,13 +68,15 @@ class AvatarsMixin:
|
|||||||
req = f"{value}-day streak"
|
req = f"{value}-day streak"
|
||||||
else:
|
else:
|
||||||
req = ""
|
req = ""
|
||||||
out.append({
|
out.append(
|
||||||
"id": entry.get("id", entry.get("icon")),
|
{
|
||||||
"label": entry.get("label", ""),
|
"id": entry.get("id", entry.get("icon")),
|
||||||
"icon": entry.get("icon"),
|
"label": entry.get("label", ""),
|
||||||
"unlocked": self._avatar_unlocked(entry, child),
|
"icon": entry.get("icon"),
|
||||||
"requirement": req,
|
"unlocked": self._avatar_unlocked(entry, child),
|
||||||
})
|
"requirement": req,
|
||||||
|
}
|
||||||
|
)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
async def async_update_avatar_catalog(self, catalog: list[dict]) -> None:
|
async def async_update_avatar_catalog(self, catalog: list[dict]) -> None:
|
||||||
@@ -83,13 +86,15 @@ class AvatarsMixin:
|
|||||||
icon = (a.get("icon") or "").strip()
|
icon = (a.get("icon") or "").strip()
|
||||||
if not icon:
|
if not icon:
|
||||||
continue
|
continue
|
||||||
cleaned.append({
|
cleaned.append(
|
||||||
"id": (a.get("id") or icon).strip(),
|
{
|
||||||
"label": (a.get("label") or "").strip(),
|
"id": (a.get("id") or icon).strip(),
|
||||||
"icon": icon,
|
"label": (a.get("label") or "").strip(),
|
||||||
"unlock_type": a.get("unlock_type", "free"),
|
"icon": icon,
|
||||||
"unlock_value": int(a.get("unlock_value", 0) or 0),
|
"unlock_type": a.get("unlock_type", "free"),
|
||||||
})
|
"unlock_value": int(a.get("unlock_value", 0) or 0),
|
||||||
|
}
|
||||||
|
)
|
||||||
self.storage.set_setting("avatar_catalog", cleaned)
|
self.storage.set_setting("avatar_catalog", cleaned)
|
||||||
await self.storage.async_save()
|
await self.storage.async_save()
|
||||||
await self.async_refresh()
|
await self.async_refresh()
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Badge evaluation engine and built-in catalogue."""
|
"""Badge evaluation engine and built-in catalogue."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -8,8 +9,9 @@ from .models import Badge, BadgeCriterion, Child
|
|||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _b(id_suffix: str, name: str, description: str, icon: str, tier: str,
|
def _b(
|
||||||
point_bonus: int, metric: str, value: int) -> Badge:
|
id_suffix: str, name: str, description: str, icon: str, tier: str, point_bonus: int, metric: str, value: int
|
||||||
|
) -> Badge:
|
||||||
"""Helper to build a built-in badge."""
|
"""Helper to build a built-in badge."""
|
||||||
criteria = [BadgeCriterion(metric=metric, operator=">=", value=value)] if metric else []
|
criteria = [BadgeCriterion(metric=metric, operator=">=", value=value)] if metric else []
|
||||||
badge = Badge(
|
badge = Badge(
|
||||||
@@ -29,39 +31,116 @@ def _b(id_suffix: str, name: str, description: str, icon: str, tier: str,
|
|||||||
|
|
||||||
BUILTIN_CATALOGUE: list[Badge] = [
|
BUILTIN_CATALOGUE: list[Badge] = [
|
||||||
# Bronze
|
# Bronze
|
||||||
_b("first_chore", "First Chore", "Complete your very first chore",
|
_b(
|
||||||
"mdi:check-circle", "bronze", 0, "first_chore", 1),
|
"first_chore",
|
||||||
_b("first_reward", "First Reward", "Claim your first reward",
|
"First Chore",
|
||||||
"mdi:gift", "bronze", 0, "first_reward", 1),
|
"Complete your very first chore",
|
||||||
_b("100_points", "100 Points", "Earn 100 lifetime points",
|
"mdi:check-circle",
|
||||||
"mdi:star", "bronze", 0, "total_points", 100),
|
"bronze",
|
||||||
_b("10_chores", "10 Chores Completed", "Complete 10 chores",
|
0,
|
||||||
"mdi:checkbox-marked-circle", "bronze", 0, "total_chores", 10),
|
"first_chore",
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
_b("first_reward", "First Reward", "Claim your first reward", "mdi:gift", "bronze", 0, "first_reward", 1),
|
||||||
|
_b("100_points", "100 Points", "Earn 100 lifetime points", "mdi:star", "bronze", 0, "total_points", 100),
|
||||||
|
_b(
|
||||||
|
"10_chores",
|
||||||
|
"10 Chores Completed",
|
||||||
|
"Complete 10 chores",
|
||||||
|
"mdi:checkbox-marked-circle",
|
||||||
|
"bronze",
|
||||||
|
0,
|
||||||
|
"total_chores",
|
||||||
|
10,
|
||||||
|
),
|
||||||
# Silver
|
# Silver
|
||||||
_b("500_points", "500 Points", "Earn 500 lifetime points",
|
_b("500_points", "500 Points", "Earn 500 lifetime points", "mdi:star-circle", "silver", 25, "total_points", 500),
|
||||||
"mdi:star-circle", "silver", 25, "total_points", 500),
|
_b(
|
||||||
_b("50_chores", "50 Chores Completed", "Complete 50 chores",
|
"50_chores",
|
||||||
"mdi:checkbox-multiple-marked-circle", "silver", 25, "total_chores", 50),
|
"50 Chores Completed",
|
||||||
_b("3_day_streak", "3-Day Streak", "Complete chores 3 days in a row",
|
"Complete 50 chores",
|
||||||
"mdi:fire", "silver", 25, "current_streak", 3),
|
"mdi:checkbox-multiple-marked-circle",
|
||||||
_b("first_perfect_week", "First Perfect Week", "Complete a perfect week",
|
"silver",
|
||||||
"mdi:calendar-star", "silver", 50, "perfect_weeks", 1),
|
25,
|
||||||
|
"total_chores",
|
||||||
|
50,
|
||||||
|
),
|
||||||
|
_b(
|
||||||
|
"3_day_streak", "3-Day Streak", "Complete chores 3 days in a row", "mdi:fire", "silver", 25, "current_streak", 3
|
||||||
|
),
|
||||||
|
_b(
|
||||||
|
"first_perfect_week",
|
||||||
|
"First Perfect Week",
|
||||||
|
"Complete a perfect week",
|
||||||
|
"mdi:calendar-star",
|
||||||
|
"silver",
|
||||||
|
50,
|
||||||
|
"perfect_weeks",
|
||||||
|
1,
|
||||||
|
),
|
||||||
# Gold
|
# Gold
|
||||||
_b("1000_points", "1000 Points", "Earn 1000 lifetime points",
|
_b("1000_points", "1000 Points", "Earn 1000 lifetime points", "mdi:trophy", "gold", 100, "total_points", 1000),
|
||||||
"mdi:trophy", "gold", 100, "total_points", 1000),
|
_b(
|
||||||
_b("100_chores", "100 Chores Completed", "Complete 100 chores",
|
"100_chores",
|
||||||
"mdi:trophy-variant", "gold", 100, "total_chores", 100),
|
"100 Chores Completed",
|
||||||
_b("7_day_streak", "7-Day Streak", "Complete chores 7 days in a row",
|
"Complete 100 chores",
|
||||||
"mdi:lightning-bolt", "gold", 50, "current_streak", 7),
|
"mdi:trophy-variant",
|
||||||
_b("5_perfect_weeks", "5 Perfect Weeks", "Achieve 5 perfect weeks",
|
"gold",
|
||||||
"mdi:calendar-multiple-check", "gold", 100, "perfect_weeks", 5),
|
100,
|
||||||
|
"total_chores",
|
||||||
|
100,
|
||||||
|
),
|
||||||
|
_b(
|
||||||
|
"7_day_streak",
|
||||||
|
"7-Day Streak",
|
||||||
|
"Complete chores 7 days in a row",
|
||||||
|
"mdi:lightning-bolt",
|
||||||
|
"gold",
|
||||||
|
50,
|
||||||
|
"current_streak",
|
||||||
|
7,
|
||||||
|
),
|
||||||
|
_b(
|
||||||
|
"5_perfect_weeks",
|
||||||
|
"5 Perfect Weeks",
|
||||||
|
"Achieve 5 perfect weeks",
|
||||||
|
"mdi:calendar-multiple-check",
|
||||||
|
"gold",
|
||||||
|
100,
|
||||||
|
"perfect_weeks",
|
||||||
|
5,
|
||||||
|
),
|
||||||
# Platinum
|
# Platinum
|
||||||
_b("5000_points", "5000 Points", "Earn 5000 lifetime points",
|
_b(
|
||||||
"mdi:diamond-stone", "platinum", 250, "total_points", 5000),
|
"5000_points",
|
||||||
_b("30_day_streak", "30-Day Streak", "Complete chores 30 days in a row",
|
"5000 Points",
|
||||||
"mdi:crown", "platinum", 250, "current_streak", 30),
|
"Earn 5000 lifetime points",
|
||||||
_b("10_perfect_weeks", "10 Perfect Weeks", "Achieve 10 perfect weeks",
|
"mdi:diamond-stone",
|
||||||
"mdi:rainbow", "platinum", 250, "perfect_weeks", 10),
|
"platinum",
|
||||||
|
250,
|
||||||
|
"total_points",
|
||||||
|
5000,
|
||||||
|
),
|
||||||
|
_b(
|
||||||
|
"30_day_streak",
|
||||||
|
"30-Day Streak",
|
||||||
|
"Complete chores 30 days in a row",
|
||||||
|
"mdi:crown",
|
||||||
|
"platinum",
|
||||||
|
250,
|
||||||
|
"current_streak",
|
||||||
|
30,
|
||||||
|
),
|
||||||
|
_b(
|
||||||
|
"10_perfect_weeks",
|
||||||
|
"10 Perfect Weeks",
|
||||||
|
"Achieve 10 perfect weeks",
|
||||||
|
"mdi:rainbow",
|
||||||
|
"platinum",
|
||||||
|
250,
|
||||||
|
"perfect_weeks",
|
||||||
|
10,
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -80,10 +159,7 @@ def resolve_metric(metric: str, child: Child, storage) -> int:
|
|||||||
if metric == "first_chore":
|
if metric == "first_chore":
|
||||||
return 1 if (child.total_chores_completed or 0) >= 1 else 0
|
return 1 if (child.total_chores_completed or 0) >= 1 else 0
|
||||||
if metric in ("total_rewards", "first_reward"):
|
if metric in ("total_rewards", "first_reward"):
|
||||||
approved_count = sum(
|
approved_count = sum(1 for c in storage.get_reward_claims() if c.child_id == child.id and c.approved)
|
||||||
1 for c in storage.get_reward_claims()
|
|
||||||
if c.child_id == child.id and c.approved
|
|
||||||
)
|
|
||||||
if metric == "first_reward":
|
if metric == "first_reward":
|
||||||
return 1 if approved_count >= 1 else 0
|
return 1 if approved_count >= 1 else 0
|
||||||
return approved_count
|
return approved_count
|
||||||
@@ -240,7 +316,9 @@ class BadgeCoordinator:
|
|||||||
self.storage.add_awarded_badge(award)
|
self.storage.add_awarded_badge(award)
|
||||||
if bonus > 0:
|
if bonus > 0:
|
||||||
await self.points_coord.async_add_points(
|
await self.points_coord.async_add_points(
|
||||||
child_id, bonus, reason=f"Badge: {badge.name}",
|
child_id,
|
||||||
|
bonus,
|
||||||
|
reason=f"Badge: {badge.name}",
|
||||||
)
|
)
|
||||||
self.hass.bus.async_fire(
|
self.hass.bus.async_fire(
|
||||||
"taskmate_badge_earned",
|
"taskmate_badge_earned",
|
||||||
@@ -260,9 +338,7 @@ class BadgeCoordinator:
|
|||||||
|
|
||||||
async def revoke(self, awarded_id: str) -> bool:
|
async def revoke(self, awarded_id: str) -> bool:
|
||||||
"""Revoke an awarded badge; reverse bonus_credited if > 0."""
|
"""Revoke an awarded badge; reverse bonus_credited if > 0."""
|
||||||
matching = [
|
matching = [a for a in self.storage.get_awarded_badges() if a.id == awarded_id]
|
||||||
a for a in self.storage.get_awarded_badges() if a.id == awarded_id
|
|
||||||
]
|
|
||||||
if not matching:
|
if not matching:
|
||||||
return False
|
return False
|
||||||
award = matching[0]
|
award = matching[0]
|
||||||
@@ -288,7 +364,9 @@ class BadgeCoordinator:
|
|||||||
total = 0
|
total = 0
|
||||||
for child in self.storage.get_children():
|
for child in self.storage.get_children():
|
||||||
new_awards = await self.evaluate_for_child(
|
new_awards = await self.evaluate_for_child(
|
||||||
child.id, "manual", silent=True,
|
child.id,
|
||||||
|
"manual",
|
||||||
|
silent=True,
|
||||||
)
|
)
|
||||||
total += len(new_awards)
|
total += len(new_awards)
|
||||||
return total
|
return total
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Calendar operations mixin for TaskMateCoordinator."""
|
"""Calendar operations mixin for TaskMateCoordinator."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -55,13 +56,15 @@ class CalendarMixin:
|
|||||||
pid = str(entry.get("id") or "").strip()
|
pid = str(entry.get("id") or "").strip()
|
||||||
if not pid or pid == "anytime" or start is None or end is None:
|
if not pid or pid == "anytime" or start is None or end is None:
|
||||||
continue
|
continue
|
||||||
periods.append({
|
periods.append(
|
||||||
"id": pid,
|
{
|
||||||
"label": str(entry.get("label") or "").strip(),
|
"id": pid,
|
||||||
"start": start.strftime("%H:%M"),
|
"label": str(entry.get("label") or "").strip(),
|
||||||
"end": end.strftime("%H:%M"),
|
"start": start.strftime("%H:%M"),
|
||||||
"icon": str(entry.get("icon") or "") or TIME_CATEGORY_ICONS.get(pid, "mdi:clock-outline"),
|
"end": end.strftime("%H:%M"),
|
||||||
})
|
"icon": str(entry.get("icon") or "") or TIME_CATEGORY_ICONS.get(pid, "mdi:clock-outline"),
|
||||||
|
}
|
||||||
|
)
|
||||||
if periods:
|
if periods:
|
||||||
return sorted(periods, key=lambda p: p["start"])
|
return sorted(periods, key=lambda p: p["start"])
|
||||||
|
|
||||||
@@ -74,13 +77,15 @@ class CalendarMixin:
|
|||||||
end_str = self.storage.get_setting(f"time_{pid}_end", default["end"])
|
end_str = self.storage.get_setting(f"time_{pid}_end", default["end"])
|
||||||
start = self._parse_hhmm(start_str) or self._parse_hhmm(default["start"])
|
start = self._parse_hhmm(start_str) or self._parse_hhmm(default["start"])
|
||||||
end = self._parse_hhmm(end_str) or self._parse_hhmm(default["end"])
|
end = self._parse_hhmm(end_str) or self._parse_hhmm(default["end"])
|
||||||
periods.append({
|
periods.append(
|
||||||
"id": pid,
|
{
|
||||||
"label": "",
|
"id": pid,
|
||||||
"start": start.strftime("%H:%M"),
|
"label": "",
|
||||||
"end": end.strftime("%H:%M"),
|
"start": start.strftime("%H:%M"),
|
||||||
"icon": default["icon"],
|
"end": end.strftime("%H:%M"),
|
||||||
})
|
"icon": default["icon"],
|
||||||
|
}
|
||||||
|
)
|
||||||
return sorted(periods, key=lambda p: p["start"])
|
return sorted(periods, key=lambda p: p["start"])
|
||||||
|
|
||||||
def _get_time_boundaries(self) -> dict[str, tuple[time, time] | None]:
|
def _get_time_boundaries(self) -> dict[str, tuple[time, time] | None]:
|
||||||
@@ -109,9 +114,9 @@ class CalendarMixin:
|
|||||||
def _calendar_projection_days(self) -> int:
|
def _calendar_projection_days(self) -> int:
|
||||||
"""Return the configured projection horizon, clamped to the allowed range."""
|
"""Return the configured projection horizon, clamped to the allowed range."""
|
||||||
try:
|
try:
|
||||||
raw = int(float(self.storage.get_setting(
|
raw = int(
|
||||||
"calendar_projection_days", str(DEFAULT_CALENDAR_PROJECTION_DAYS)
|
float(self.storage.get_setting("calendar_projection_days", str(DEFAULT_CALENDAR_PROJECTION_DAYS)))
|
||||||
)))
|
)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
raw = DEFAULT_CALENDAR_PROJECTION_DAYS
|
raw = DEFAULT_CALENDAR_PROJECTION_DAYS
|
||||||
return max(MIN_CALENDAR_PROJECTION_DAYS, min(MAX_CALENDAR_PROJECTION_DAYS, raw))
|
return max(MIN_CALENDAR_PROJECTION_DAYS, min(MAX_CALENDAR_PROJECTION_DAYS, raw))
|
||||||
@@ -204,9 +209,7 @@ class CalendarMixin:
|
|||||||
def _build_event_payload(self, chore: Chore, day: date, summary: str) -> dict:
|
def _build_event_payload(self, chore: Chore, day: date, summary: str) -> dict:
|
||||||
"""Build the calendar.create_event payload for one (chore, day)."""
|
"""Build the calendar.create_event payload for one (chore, day)."""
|
||||||
description = self._chore_event_marker(chore)
|
description = self._chore_event_marker(chore)
|
||||||
window = self._time_category_window(
|
window = self._time_category_window(getattr(chore, "time_category", "anytime"), day)
|
||||||
getattr(chore, "time_category", "anytime"), day
|
|
||||||
)
|
|
||||||
if window is None:
|
if window is None:
|
||||||
return {
|
return {
|
||||||
"summary": summary,
|
"summary": summary,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ points this week — and awards a one-off bonus when a child hits it. Progress
|
|||||||
and the award reset automatically when the period rolls over (a new day or a
|
and the award reset automatically when the period rolls over (a new day or a
|
||||||
new Monday-anchored week).
|
new Monday-anchored week).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -89,19 +90,21 @@ class ChallengesMixin:
|
|||||||
_, period_key = self._period_start_key(ch.scope)
|
_, period_key = self._period_start_key(ch.scope)
|
||||||
prog = self.storage.get_challenge_child_progress(ch.id, child_id)
|
prog = self.storage.get_challenge_child_progress(ch.id, child_id)
|
||||||
awarded = bool(prog.get("awarded")) and prog.get("period") == period_key
|
awarded = bool(prog.get("awarded")) and prog.get("period") == period_key
|
||||||
out.append({
|
out.append(
|
||||||
"challenge_id": ch.id,
|
{
|
||||||
"name": ch.name,
|
"challenge_id": ch.id,
|
||||||
"icon": ch.icon,
|
"name": ch.name,
|
||||||
"scope": ch.scope,
|
"icon": ch.icon,
|
||||||
"metric": ch.metric,
|
"scope": ch.scope,
|
||||||
"target": ch.target,
|
"metric": ch.metric,
|
||||||
"progress": min(value, ch.target),
|
"target": ch.target,
|
||||||
"value": value,
|
"progress": min(value, ch.target),
|
||||||
"bonus_points": ch.bonus_points,
|
"value": value,
|
||||||
"complete": value >= ch.target,
|
"bonus_points": ch.bonus_points,
|
||||||
"awarded": awarded,
|
"complete": value >= ch.target,
|
||||||
})
|
"awarded": awarded,
|
||||||
|
}
|
||||||
|
)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
# ── Evaluation ───────────────────────────────────────────────────────
|
# ── Evaluation ───────────────────────────────────────────────────────
|
||||||
@@ -142,24 +145,36 @@ class ChallengesMixin:
|
|||||||
child.points += bonus
|
child.points += bonus
|
||||||
child.total_points_earned += bonus
|
child.total_points_earned += bonus
|
||||||
child.career_score = child.total_points_earned - child.total_penalties_received
|
child.career_score = child.total_points_earned - child.total_penalties_received
|
||||||
self.storage.add_points_transaction(PointsTransaction(
|
self.storage.add_points_transaction(
|
||||||
child_id=child.id, points=bonus,
|
PointsTransaction(
|
||||||
reason=f"Challenge complete: {challenge.name}", created_at=dt_util.now(),
|
child_id=child.id,
|
||||||
))
|
points=bonus,
|
||||||
|
reason=f"Challenge complete: {challenge.name}",
|
||||||
|
created_at=dt_util.now(),
|
||||||
|
)
|
||||||
|
)
|
||||||
if hasattr(self, "_maybe_level_up"):
|
if hasattr(self, "_maybe_level_up"):
|
||||||
await self._maybe_level_up(child)
|
await self._maybe_level_up(child)
|
||||||
self.storage.update_child(child)
|
self.storage.update_child(child)
|
||||||
|
|
||||||
self.hass.bus.async_fire("taskmate_challenge_completed", {
|
self.hass.bus.async_fire(
|
||||||
"child_id": child.id, "child_name": child.name,
|
"taskmate_challenge_completed",
|
||||||
"challenge_id": challenge.id, "challenge_name": challenge.name,
|
{
|
||||||
"scope": challenge.scope, "bonus": bonus,
|
"child_id": child.id,
|
||||||
"timestamp": dt_util.now().isoformat(),
|
"child_name": child.name,
|
||||||
})
|
"challenge_id": challenge.id,
|
||||||
|
"challenge_name": challenge.name,
|
||||||
|
"scope": challenge.scope,
|
||||||
|
"bonus": bonus,
|
||||||
|
"timestamp": dt_util.now().isoformat(),
|
||||||
|
},
|
||||||
|
)
|
||||||
if hasattr(self, "_celebrate"):
|
if hasattr(self, "_celebrate"):
|
||||||
await self._celebrate(
|
await self._celebrate(
|
||||||
child, "challenge_completed",
|
child,
|
||||||
|
"challenge_completed",
|
||||||
f"{child.name} completed the challenge '{challenge.name}'!",
|
f"{child.name} completed the challenge '{challenge.name}'!",
|
||||||
tier=2, extra={"challenge_id": challenge.id, "bonus": bonus},
|
tier=2,
|
||||||
|
extra={"challenge_id": challenge.id, "bonus": bonus},
|
||||||
)
|
)
|
||||||
_LOGGER.info("Challenge '%s' completed by %s (+%d)", challenge.name, child.name, bonus)
|
_LOGGER.info("Challenge '%s' completed by %s (+%d)", challenge.name, child.name, bonus)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Chore operations mixin for TaskMateCoordinator."""
|
"""Chore operations mixin for TaskMateCoordinator."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -26,10 +27,15 @@ def _add_months(d: date, months: int) -> date:
|
|||||||
|
|
||||||
|
|
||||||
_DOW_MAP = {
|
_DOW_MAP = {
|
||||||
'monday': 0, 'tuesday': 1, 'wednesday': 2, 'thursday': 3,
|
"monday": 0,
|
||||||
'friday': 4, 'saturday': 5, 'sunday': 6,
|
"tuesday": 1,
|
||||||
|
"wednesday": 2,
|
||||||
|
"thursday": 3,
|
||||||
|
"friday": 4,
|
||||||
|
"saturday": 5,
|
||||||
|
"sunday": 6,
|
||||||
}
|
}
|
||||||
_MONTH_STEPS = {'monthly': 1, 'every_3_months': 3, 'every_6_months': 6}
|
_MONTH_STEPS = {"monthly": 1, "every_3_months": 3, "every_6_months": 6}
|
||||||
|
|
||||||
|
|
||||||
class ChoresMixin:
|
class ChoresMixin:
|
||||||
@@ -124,7 +130,9 @@ class ChoresMixin:
|
|||||||
# For random/balanced manual-start, override today's cached child so
|
# For random/balanced manual-start, override today's cached child so
|
||||||
# the parent sees the chosen child immediately.
|
# the parent sees the chosen child immediately.
|
||||||
if manual_start_child_id and resolved_mode in ("random", "balanced"):
|
if manual_start_child_id and resolved_mode in ("random", "balanced"):
|
||||||
resolved_pool = self._chore_assignment_pool(chore) if chore.assigned_to else [c.id for c in self.storage.get_children()]
|
resolved_pool = (
|
||||||
|
self._chore_assignment_pool(chore) if chore.assigned_to else [c.id for c in self.storage.get_children()]
|
||||||
|
)
|
||||||
if manual_start_child_id in resolved_pool:
|
if manual_start_child_id in resolved_pool:
|
||||||
chore.assignment_current_child_id = manual_start_child_id
|
chore.assignment_current_child_id = manual_start_child_id
|
||||||
self.storage.add_chore(chore)
|
self.storage.add_chore(chore)
|
||||||
@@ -138,6 +146,7 @@ class ChoresMixin:
|
|||||||
async def async_request_swap(self, chore_id: str, requester_id: str) -> str:
|
async def async_request_swap(self, chore_id: str, requester_id: str) -> str:
|
||||||
"""A child requests to take over today's rotation assignment of a chore."""
|
"""A child requests to take over today's rotation assignment of a chore."""
|
||||||
from .models import generate_id
|
from .models import generate_id
|
||||||
|
|
||||||
chore = self.get_chore(chore_id)
|
chore = self.get_chore(chore_id)
|
||||||
if not chore:
|
if not chore:
|
||||||
raise ValueError(f"Chore {chore_id} not found")
|
raise ValueError(f"Chore {chore_id} not found")
|
||||||
@@ -164,20 +173,37 @@ class ChoresMixin:
|
|||||||
|
|
||||||
async def async_approve_swap(self, req_id: str) -> None:
|
async def async_approve_swap(self, req_id: str) -> None:
|
||||||
"""Approve a swap — reassign today's chore to the requester."""
|
"""Approve a swap — reassign today's chore to the requester."""
|
||||||
req = next((r for r in self.storage.get_swap_requests()
|
req = next(
|
||||||
if r.get("id") == req_id and r.get("status") == "pending"), None)
|
(r for r in self.storage.get_swap_requests() if r.get("id") == req_id and r.get("status") == "pending"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
if not req:
|
if not req:
|
||||||
raise ValueError(f"Swap request {req_id} not found")
|
raise ValueError(f"Swap request {req_id} not found")
|
||||||
chore = self.get_chore(req["chore_id"])
|
chore = self.get_chore(req["chore_id"])
|
||||||
if chore:
|
if chore:
|
||||||
|
# Stamp the dated override *and* the cached current child. The
|
||||||
|
# override is what every eligibility gate reads (#781); the cached
|
||||||
|
# field keeps the sensors/cards consistent without waiting for the
|
||||||
|
# next assignment pass.
|
||||||
|
chore.assignment_swap_child_id = req["requester_id"]
|
||||||
|
chore.assignment_swap_date = dt_util.as_local(dt_util.now()).date().isoformat()
|
||||||
chore.assignment_current_child_id = req["requester_id"]
|
chore.assignment_current_child_id = req["requester_id"]
|
||||||
self.storage.update_chore(chore)
|
self.storage.update_chore(chore)
|
||||||
self.storage.update_swap_request(req_id, status="approved")
|
# Consume the request, exactly as rejection does (#783). Nothing reads a
|
||||||
self.hass.bus.async_fire("taskmate_swap_approved", {
|
# request once it leaves "pending" — both readers filter on it — so
|
||||||
"chore_id": req["chore_id"], "requester_id": req["requester_id"],
|
# keeping it would grow the store forever. The approval stays observable
|
||||||
"from_child_id": req.get("from_child_id", ""),
|
# through the event below and the chore's own dated override. `req` is
|
||||||
"timestamp": dt_util.now().isoformat(),
|
# already captured, so the payload survives the removal.
|
||||||
})
|
self.storage.remove_swap_request(req_id)
|
||||||
|
self.hass.bus.async_fire(
|
||||||
|
"taskmate_swap_approved",
|
||||||
|
{
|
||||||
|
"chore_id": req["chore_id"],
|
||||||
|
"requester_id": req["requester_id"],
|
||||||
|
"from_child_id": req.get("from_child_id", ""),
|
||||||
|
"timestamp": dt_util.now().isoformat(),
|
||||||
|
},
|
||||||
|
)
|
||||||
await self.storage.async_save()
|
await self.storage.async_save()
|
||||||
await self.async_refresh()
|
await self.async_refresh()
|
||||||
|
|
||||||
@@ -197,7 +223,8 @@ class ChoresMixin:
|
|||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
"Chore '%s' has an unparseable deadline_at %r — ignoring it",
|
"Chore '%s' has an unparseable deadline_at %r — ignoring it",
|
||||||
getattr(chore, "name", ""), raw,
|
getattr(chore, "name", ""),
|
||||||
|
raw,
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
# A naive value came from hand-edited storage; treat it as local time
|
# A naive value came from hand-edited storage; treat it as local time
|
||||||
@@ -237,7 +264,8 @@ class ChoresMixin:
|
|||||||
changed = True
|
changed = True
|
||||||
_LOGGER.info(
|
_LOGGER.info(
|
||||||
"Reactive chore '%s' expired (deadline %s)",
|
"Reactive chore '%s' expired (deadline %s)",
|
||||||
chore.name, getattr(chore, "deadline_at", ""),
|
chore.name,
|
||||||
|
getattr(chore, "deadline_at", ""),
|
||||||
)
|
)
|
||||||
self.hass.bus.async_fire(
|
self.hass.bus.async_fire(
|
||||||
"taskmate_chore_expired",
|
"taskmate_chore_expired",
|
||||||
@@ -297,6 +325,8 @@ class ChoresMixin:
|
|||||||
data["assignment_current_child_id"] = ""
|
data["assignment_current_child_id"] = ""
|
||||||
data["skip_date"] = ""
|
data["skip_date"] = ""
|
||||||
data["skip_count"] = 0
|
data["skip_count"] = 0
|
||||||
|
data["assignment_swap_child_id"] = ""
|
||||||
|
data["assignment_swap_date"] = ""
|
||||||
data["publish_calendar_published_dates"] = []
|
data["publish_calendar_published_dates"] = []
|
||||||
data["disabled_for"] = []
|
data["disabled_for"] = []
|
||||||
data["enabled"] = True
|
data["enabled"] = True
|
||||||
@@ -352,9 +382,7 @@ class ChoresMixin:
|
|||||||
await self.async_refresh()
|
await self.async_refresh()
|
||||||
return count
|
return count
|
||||||
|
|
||||||
async def async_approve_chores_bulk(
|
async def async_approve_chores_bulk(self, completion_ids: list[str] | None = None) -> int:
|
||||||
self, completion_ids: list[str] | None = None
|
|
||||||
) -> int:
|
|
||||||
"""Approve several pending chore completions at once. Returns count approved.
|
"""Approve several pending chore completions at once. Returns count approved.
|
||||||
|
|
||||||
If completion_ids is given, only those (still-pending) completions are
|
If completion_ids is given, only those (still-pending) completions are
|
||||||
@@ -390,7 +418,7 @@ class ChoresMixin:
|
|||||||
visibility_entity: str = "",
|
visibility_entity: str = "",
|
||||||
visibility_state: str = "on",
|
visibility_state: str = "on",
|
||||||
visibility_operator: str = "equals",
|
visibility_operator: str = "equals",
|
||||||
) -> list[Chore]:
|
) -> list[Chore]:
|
||||||
"""Add multiple chores at once with shared settings."""
|
"""Add multiple chores at once with shared settings."""
|
||||||
chores = []
|
chores = []
|
||||||
for name in chore_names:
|
for name in chore_names:
|
||||||
@@ -412,7 +440,7 @@ class ChoresMixin:
|
|||||||
visibility_entity=visibility_entity,
|
visibility_entity=visibility_entity,
|
||||||
visibility_state=visibility_state,
|
visibility_state=visibility_state,
|
||||||
visibility_operator=visibility_operator,
|
visibility_operator=visibility_operator,
|
||||||
)
|
)
|
||||||
self.storage.add_chore(chore)
|
self.storage.add_chore(chore)
|
||||||
chores.append(chore)
|
chores.append(chore)
|
||||||
|
|
||||||
@@ -453,7 +481,10 @@ class ChoresMixin:
|
|||||||
extra_prefixes.append(f"{prev_name} — ")
|
extra_prefixes.append(f"{prev_name} — ")
|
||||||
extra_prefixes.append(f"{chore.name} — ")
|
extra_prefixes.append(f"{chore.name} — ")
|
||||||
await self._cleanup_chore_from_calendars(
|
await self._cleanup_chore_from_calendars(
|
||||||
chore, cleanup_entities, today, summary_prefixes=extra_prefixes,
|
chore,
|
||||||
|
cleanup_entities,
|
||||||
|
today,
|
||||||
|
summary_prefixes=extra_prefixes,
|
||||||
)
|
)
|
||||||
self.storage.update_chore(chore)
|
self.storage.update_chore(chore)
|
||||||
# Replacing or clearing the picture orphans the old file; delete it —
|
# Replacing or clearing the picture orphans the old file; delete it —
|
||||||
@@ -464,9 +495,7 @@ class ChoresMixin:
|
|||||||
await self.storage.async_save()
|
await self.storage.async_save()
|
||||||
await self.async_refresh()
|
await self.async_refresh()
|
||||||
|
|
||||||
async def _async_release_image(
|
async def _async_release_image(self, image_url: str, *, excluding_chore_id: str = "") -> None:
|
||||||
self, image_url: str, *, excluding_chore_id: str = ""
|
|
||||||
) -> None:
|
|
||||||
"""Delete a chore image file, but only if nothing else still shows it.
|
"""Delete a chore image file, but only if nothing else still shows it.
|
||||||
|
|
||||||
`async_clone_chore` copies `image_url` straight from the source, so two
|
`async_clone_chore` copies `image_url` straight from the source, so two
|
||||||
@@ -493,9 +522,7 @@ class ChoresMixin:
|
|||||||
# Nothing sweeps taskmate_images, so the file has to go with the chore —
|
# Nothing sweeps taskmate_images, so the file has to go with the chore —
|
||||||
# unless a clone still shows it (#768).
|
# unless a clone still shows it (#768).
|
||||||
if existing is not None and getattr(existing, "image_url", ""):
|
if existing is not None and getattr(existing, "image_url", ""):
|
||||||
await self._async_release_image(
|
await self._async_release_image(existing.image_url, excluding_chore_id=chore_id)
|
||||||
existing.image_url, excluding_chore_id=chore_id
|
|
||||||
)
|
|
||||||
self.storage.remove_chore(chore_id)
|
self.storage.remove_chore(chore_id)
|
||||||
self.storage.remove_completions_for_chore(chore_id)
|
self.storage.remove_completions_for_chore(chore_id)
|
||||||
self.storage.remove_last_completed_for_chore(chore_id)
|
self.storage.remove_last_completed_for_chore(chore_id)
|
||||||
@@ -503,6 +530,9 @@ class ChoresMixin:
|
|||||||
self.storage.remove_chore_from_task_groups(chore_id)
|
self.storage.remove_chore_from_task_groups(chore_id)
|
||||||
# Drop queued scheduled changes (#675) — nothing left to apply them to.
|
# Drop queued scheduled changes (#675) — nothing left to apply them to.
|
||||||
self.storage.remove_scheduled_changes_for_chore(chore_id)
|
self.storage.remove_scheduled_changes_for_chore(chore_id)
|
||||||
|
# Drop pending swap requests (#785), else they sit in the parent's
|
||||||
|
# approval queue forever showing "?" for the chore that no longer exists.
|
||||||
|
self.storage.remove_swap_requests_for_chore(chore_id)
|
||||||
# Remove chore from children's chore_order lists
|
# Remove chore from children's chore_order lists
|
||||||
for child in self.storage.get_children():
|
for child in self.storage.get_children():
|
||||||
if chore_id in child.chore_order:
|
if chore_id in child.chore_order:
|
||||||
@@ -533,9 +563,7 @@ class ChoresMixin:
|
|||||||
# Reject skipping a sticky group follower — the group would drift.
|
# Reject skipping a sticky group follower — the group would drift.
|
||||||
group = self.storage.get_task_group_for_chore(chore_id)
|
group = self.storage.get_task_group_for_chore(chore_id)
|
||||||
if group and group.policy == "sticky" and group.chore_ids and group.chore_ids[0] != chore_id:
|
if group and group.policy == "sticky" and group.chore_ids and group.chore_ids[0] != chore_id:
|
||||||
raise ValueError(
|
raise ValueError("Cannot skip a sticky group follower; skip the leader chore instead")
|
||||||
"Cannot skip a sticky group follower; skip the leader chore instead"
|
|
||||||
)
|
|
||||||
|
|
||||||
pool = self._chore_assignment_pool(chore)
|
pool = self._chore_assignment_pool(chore)
|
||||||
if len(pool) <= 1:
|
if len(pool) <= 1:
|
||||||
@@ -549,6 +577,12 @@ class ChoresMixin:
|
|||||||
chore.skip_date = today_iso
|
chore.skip_date = today_iso
|
||||||
chore.skip_count = 0
|
chore.skip_count = 0
|
||||||
|
|
||||||
|
# A skip is a deliberate move of today's assignee, so it supersedes any
|
||||||
|
# approved swap — otherwise the swap override would keep winning and the
|
||||||
|
# skip would silently do nothing.
|
||||||
|
chore.assignment_swap_child_id = ""
|
||||||
|
chore.assignment_swap_date = ""
|
||||||
|
|
||||||
# Cyclical: A → B → … → unassigned → back to A.
|
# Cyclical: A → B → … → unassigned → back to A.
|
||||||
# skip_count < pool_size → advance to next child
|
# skip_count < pool_size → advance to next child
|
||||||
# skip_count == pool_size → unassigned (no child today)
|
# skip_count == pool_size → unassigned (no child today)
|
||||||
@@ -612,9 +646,13 @@ class ChoresMixin:
|
|||||||
chore.assigned_to = [child_id] + [c for c in pool if c != child_id]
|
chore.assigned_to = [child_id] + [c for c in pool if c != child_id]
|
||||||
chore.assignment_rotation_anchor = today.isoformat()
|
chore.assignment_rotation_anchor = today.isoformat()
|
||||||
|
|
||||||
# Any previous skip is wiped — manual start is an explicit reset.
|
# Any previous skip or approved swap is wiped — manual start is an
|
||||||
|
# explicit reset, and a lingering swap override would outrank the
|
||||||
|
# child the parent just picked.
|
||||||
chore.skip_date = ""
|
chore.skip_date = ""
|
||||||
chore.skip_count = 0
|
chore.skip_count = 0
|
||||||
|
chore.assignment_swap_child_id = ""
|
||||||
|
chore.assignment_swap_date = ""
|
||||||
|
|
||||||
chore.assignment_current_child_id = child_id
|
chore.assignment_current_child_id = child_id
|
||||||
|
|
||||||
@@ -623,7 +661,9 @@ class ChoresMixin:
|
|||||||
await self.async_refresh()
|
await self.async_refresh()
|
||||||
return chore
|
return chore
|
||||||
|
|
||||||
async def async_complete_chore(self, chore_id: str, child_id: str, as_parent: bool = False, photo_url: str = "") -> ChoreCompletion | None:
|
async def async_complete_chore(
|
||||||
|
self, chore_id: str, child_id: str, as_parent: bool = False, photo_url: str = ""
|
||||||
|
) -> ChoreCompletion | None:
|
||||||
"""Mark a chore as completed by a child.
|
"""Mark a chore as completed by a child.
|
||||||
|
|
||||||
When ``as_parent`` is True the completion auto-approves (the parent is the
|
When ``as_parent`` is True the completion auto-approves (the parent is the
|
||||||
@@ -664,8 +704,8 @@ class ChoresMixin:
|
|||||||
# SINGLE daily quota across the whole pool. Enforce it here at completion
|
# SINGLE daily quota across the whole pool. Enforce it here at completion
|
||||||
# time, not just in the card UI — otherwise a caller can award every pool
|
# time, not just in the card UI — otherwise a caller can award every pool
|
||||||
# member by completing the chore once per child_id (one call each).
|
# member by completing the chore once per child_id (one call each).
|
||||||
assignment_mode = getattr(chore, 'assignment_mode', 'everyone')
|
assignment_mode = getattr(chore, "assignment_mode", "everyone")
|
||||||
if assignment_mode != 'everyone':
|
if assignment_mode != "everyone":
|
||||||
if self._is_rotation_done_today(chore):
|
if self._is_rotation_done_today(chore):
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"complete_chore no-op: '%s' already completed today (rotation quota filled)",
|
"complete_chore no-op: '%s' already completed today (rotation quota filled)",
|
||||||
@@ -676,16 +716,17 @@ class ChoresMixin:
|
|||||||
# assignee. Parents (as_parent) may complete on behalf of any pool
|
# assignee. Parents (as_parent) may complete on behalf of any pool
|
||||||
# member — e.g. ticking it off for the off-rotation child. first_come
|
# member — e.g. ticking it off for the off-rotation child. first_come
|
||||||
# keeps its competitive semantics (every pool member may race).
|
# keeps its competitive semantics (every pool member may race).
|
||||||
if not as_parent and assignment_mode != 'first_come':
|
if not as_parent and assignment_mode != "first_come":
|
||||||
if child_id not in self._compute_active_children(chore):
|
if child_id not in self._compute_active_children(chore):
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"complete_chore no-op: '%s' not assigned to %s today",
|
"complete_chore no-op: '%s' not assigned to %s today",
|
||||||
chore.name, child.name,
|
chore.name,
|
||||||
|
child.name,
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Check recurrence window for Mode B chores
|
# Check recurrence window for Mode B chores
|
||||||
if getattr(chore, 'schedule_mode', 'specific_days') == 'recurring':
|
if getattr(chore, "schedule_mode", "specific_days") == "recurring":
|
||||||
if not self.is_chore_available_for_child(chore, child_id):
|
if not self.is_chore_available_for_child(chore, child_id):
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"complete_chore no-op: '%s' not available yet (recurrence window)",
|
"complete_chore no-op: '%s' not available yet (recurrence window)",
|
||||||
@@ -694,7 +735,7 @@ class ChoresMixin:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
# Check availability for one-shot chores
|
# Check availability for one-shot chores
|
||||||
if getattr(chore, 'schedule_mode', 'specific_days') == 'one_shot':
|
if getattr(chore, "schedule_mode", "specific_days") == "one_shot":
|
||||||
if not self.is_chore_available_for_child(chore, child_id):
|
if not self.is_chore_available_for_child(chore, child_id):
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"complete_chore no-op: '%s' not available (one-shot done or expired)",
|
"complete_chore no-op: '%s' not available (one-shot done or expired)",
|
||||||
@@ -718,11 +759,13 @@ class ChoresMixin:
|
|||||||
if comp_dt.date() == today:
|
if comp_dt.date() == today:
|
||||||
todays_completions_count += 1
|
todays_completions_count += 1
|
||||||
|
|
||||||
daily_limit = getattr(chore, 'daily_limit', 1)
|
daily_limit = getattr(chore, "daily_limit", 1)
|
||||||
if todays_completions_count >= daily_limit:
|
if todays_completions_count >= daily_limit:
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"complete_chore no-op: daily limit reached for '%s' (%d/%d today)",
|
"complete_chore no-op: daily limit reached for '%s' (%d/%d today)",
|
||||||
chore.name, todays_completions_count, daily_limit,
|
chore.name,
|
||||||
|
todays_completions_count,
|
||||||
|
daily_limit,
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -781,7 +824,7 @@ class ChoresMixin:
|
|||||||
self.storage.set_last_completed(chore_id, child_id, now.isoformat())
|
self.storage.set_last_completed(chore_id, child_id, now.isoformat())
|
||||||
|
|
||||||
# One-shot: if auto-approved, disable for this child immediately
|
# One-shot: if auto-approved, disable for this child immediately
|
||||||
if getattr(chore, 'schedule_mode', 'specific_days') == 'one_shot' and auto_approve:
|
if getattr(chore, "schedule_mode", "specific_days") == "one_shot" and auto_approve:
|
||||||
if child_id not in chore.disabled_for:
|
if child_id not in chore.disabled_for:
|
||||||
chore.disabled_for.append(child_id)
|
chore.disabled_for.append(child_id)
|
||||||
self._check_one_shot_fully_disabled(chore)
|
self._check_one_shot_fully_disabled(chore)
|
||||||
@@ -792,8 +835,11 @@ class ChoresMixin:
|
|||||||
# Fire approval notification only if it stays pending
|
# Fire approval notification only if it stays pending
|
||||||
if not auto_approve:
|
if not auto_approve:
|
||||||
await self._async_notify_pending_approval(
|
await self._async_notify_pending_approval(
|
||||||
child.name, chore.name, chore.points,
|
child.name,
|
||||||
completion_id=completion.id, photo_url=completion.photo_url,
|
chore.name,
|
||||||
|
chore.points,
|
||||||
|
completion_id=completion.id,
|
||||||
|
photo_url=completion.photo_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
await self.async_refresh()
|
await self.async_refresh()
|
||||||
@@ -819,19 +865,17 @@ class ChoresMixin:
|
|||||||
if not chore:
|
if not chore:
|
||||||
raise ValueError(f"Chore {chore_id} not found")
|
raise ValueError(f"Chore {chore_id} not found")
|
||||||
|
|
||||||
if not getattr(chore, 'enabled', True):
|
if not getattr(chore, "enabled", True):
|
||||||
raise ValueError(f"Chore '{chore.name}' is disabled")
|
raise ValueError(f"Chore '{chore.name}' is disabled")
|
||||||
|
|
||||||
schedule_mode = getattr(chore, 'schedule_mode', 'specific_days')
|
schedule_mode = getattr(chore, "schedule_mode", "specific_days")
|
||||||
if schedule_mode == 'one_shot':
|
if schedule_mode == "one_shot":
|
||||||
raise ValueError(
|
raise ValueError(f"Chore '{chore.name}' is a one-shot chore and cannot be parent-completed")
|
||||||
f"Chore '{chore.name}' is a one-shot chore and cannot be parent-completed"
|
|
||||||
)
|
|
||||||
|
|
||||||
now = dt_util.now()
|
now = dt_util.now()
|
||||||
|
|
||||||
# Determine child pool — empty assigned_to means all children
|
# Determine child pool — empty assigned_to means all children
|
||||||
assigned = getattr(chore, 'assigned_to', []) or []
|
assigned = getattr(chore, "assigned_to", []) or []
|
||||||
if assigned:
|
if assigned:
|
||||||
child_ids = list(assigned)
|
child_ids = list(assigned)
|
||||||
else:
|
else:
|
||||||
@@ -860,8 +904,12 @@ class ChoresMixin:
|
|||||||
# the "Current" column / child-stats card stops pointing at the
|
# the "Current" column / child-stats card stops pointing at the
|
||||||
# original child. The pointer recomputes at the next midnight refresh.
|
# original child. The pointer recomputes at the next midnight refresh.
|
||||||
if getattr(chore, "assignment_mode", "everyone") != "everyone":
|
if getattr(chore, "assignment_mode", "everyone") != "everyone":
|
||||||
if getattr(chore, "assignment_current_child_id", ""):
|
if getattr(chore, "assignment_current_child_id", "") or getattr(chore, "assignment_swap_date", ""):
|
||||||
chore.assignment_current_child_id = ""
|
chore.assignment_current_child_id = ""
|
||||||
|
# Drop the swap override too, or it would re-point the column
|
||||||
|
# at the swapped-to child on the next assignment pass.
|
||||||
|
chore.assignment_swap_child_id = ""
|
||||||
|
chore.assignment_swap_date = ""
|
||||||
self.storage.update_chore(chore)
|
self.storage.update_chore(chore)
|
||||||
|
|
||||||
await self.storage.async_save()
|
await self.storage.async_save()
|
||||||
@@ -912,9 +960,7 @@ class ChoresMixin:
|
|||||||
for c in all_completions
|
for c in all_completions
|
||||||
)
|
)
|
||||||
if already_done:
|
if already_done:
|
||||||
raise ValueError(
|
raise ValueError(f"Bonus sub-task '{subtask.name}' already completed today.")
|
||||||
f"Bonus sub-task '{subtask.name}' already completed today."
|
|
||||||
)
|
|
||||||
|
|
||||||
completion = ChoreCompletion(
|
completion = ChoreCompletion(
|
||||||
chore_id=chore_id,
|
chore_id=chore_id,
|
||||||
@@ -960,19 +1006,24 @@ class ChoresMixin:
|
|||||||
comp_date = dt_util.as_local(completion.completed_at).date()
|
comp_date = dt_util.as_local(completion.completed_at).date()
|
||||||
is_bonus = bool(completion.bonus_subtask_id)
|
is_bonus = bool(completion.bonus_subtask_id)
|
||||||
if is_bonus:
|
if is_bonus:
|
||||||
subtask = next(
|
subtask = next((b for b in chore.bonus_subtasks if b.id == completion.bonus_subtask_id), None)
|
||||||
(b for b in chore.bonus_subtasks if b.id == completion.bonus_subtask_id), None
|
|
||||||
)
|
|
||||||
pts = subtask.points if subtask else 0
|
pts = subtask.points if subtask else 0
|
||||||
elif completion.timed_duration_seconds > 0 and chore.task_type == "timed":
|
elif completion.timed_duration_seconds > 0 and chore.task_type == "timed":
|
||||||
rate_seconds = chore.timed_rate_minutes * 60
|
rate_seconds = chore.timed_rate_minutes * 60
|
||||||
pts = (completion.timed_duration_seconds // rate_seconds) * chore.timed_rate_points if rate_seconds > 0 else 0
|
pts = (
|
||||||
|
(completion.timed_duration_seconds // rate_seconds) * chore.timed_rate_points
|
||||||
|
if rate_seconds > 0
|
||||||
|
else 0
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
pts = self._apply_time_adjustment(
|
pts = self._apply_time_adjustment(
|
||||||
chore, self.effective_chore_points(chore), completion.completed_at
|
chore, self.effective_chore_points(chore), completion.completed_at
|
||||||
)
|
)
|
||||||
total_awarded = await self._award_points(
|
total_awarded = await self._award_points(
|
||||||
child, pts, completion_date=comp_date, skip_streak=is_bonus,
|
child,
|
||||||
|
pts,
|
||||||
|
completion_date=comp_date,
|
||||||
|
skip_streak=is_bonus,
|
||||||
chore_id=completion.chore_id,
|
chore_id=completion.chore_id,
|
||||||
)
|
)
|
||||||
completion.approved = True
|
completion.approved = True
|
||||||
@@ -984,9 +1035,7 @@ class ChoresMixin:
|
|||||||
# reviewed (covers single approve AND "approve all", which
|
# reviewed (covers single approve AND "approve all", which
|
||||||
# reuses this method per completion).
|
# reuses this method per completion).
|
||||||
if getattr(self, "notifications", None):
|
if getattr(self, "notifications", None):
|
||||||
await self.notifications.clear_approval(
|
await self.notifications.clear_approval("pending_chore_approval", completion_id)
|
||||||
"pending_chore_approval", completion_id
|
|
||||||
)
|
|
||||||
|
|
||||||
self.hass.bus.async_fire(
|
self.hass.bus.async_fire(
|
||||||
"taskmate_chore_approved",
|
"taskmate_chore_approved",
|
||||||
@@ -999,7 +1048,7 @@ class ChoresMixin:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# One-shot: disable for this child on approval (parent completions only)
|
# One-shot: disable for this child on approval (parent completions only)
|
||||||
if not is_bonus and getattr(chore, 'schedule_mode', 'specific_days') == 'one_shot':
|
if not is_bonus and getattr(chore, "schedule_mode", "specific_days") == "one_shot":
|
||||||
if completion.child_id not in chore.disabled_for:
|
if completion.child_id not in chore.disabled_for:
|
||||||
chore.disabled_for.append(completion.child_id)
|
chore.disabled_for.append(completion.child_id)
|
||||||
self._check_one_shot_fully_disabled(chore)
|
self._check_one_shot_fully_disabled(chore)
|
||||||
@@ -1031,13 +1080,17 @@ class ChoresMixin:
|
|||||||
{"child_name": child.name, "child_id": child.id},
|
{"child_name": child.name, "child_id": child.id},
|
||||||
)
|
)
|
||||||
await self._celebrate(
|
await self._celebrate(
|
||||||
child, "all_chores_done",
|
child,
|
||||||
f"{child.name} finished every chore today!", tier=1,
|
"all_chores_done",
|
||||||
|
f"{child.name} finished every chore today!",
|
||||||
|
tier=1,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
"Cannot approve completion %s: chore (%s) or child (%s) not found",
|
"Cannot approve completion %s: chore (%s) or child (%s) not found",
|
||||||
completion_id, completion.chore_id, completion.child_id,
|
completion_id,
|
||||||
|
completion.chore_id,
|
||||||
|
completion.child_id,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
_LOGGER.warning("Completion %s not found for approval", completion_id)
|
_LOGGER.warning("Completion %s not found for approval", completion_id)
|
||||||
@@ -1082,9 +1135,7 @@ class ChoresMixin:
|
|||||||
and c.child_id == completion.child_id
|
and c.child_id == completion.child_id
|
||||||
and not c.bonus_subtask_id
|
and not c.bonus_subtask_id
|
||||||
]
|
]
|
||||||
child.last_completion_date = (
|
child.last_completion_date = max(remaining).isoformat() if remaining else None
|
||||||
max(remaining).isoformat() if remaining else None
|
|
||||||
)
|
|
||||||
# Reverse any streak milestones this completion unlocked.
|
# Reverse any streak milestones this completion unlocked.
|
||||||
# Milestone bonuses are logged as separate transactions
|
# Milestone bonuses are logged as separate transactions
|
||||||
# (not part of points_awarded), so dropping the streak
|
# (not part of points_awarded), so dropping the streak
|
||||||
@@ -1094,24 +1145,15 @@ class ChoresMixin:
|
|||||||
if lost:
|
if lost:
|
||||||
try:
|
try:
|
||||||
milestones = self.parse_milestone_setting(
|
milestones = self.parse_milestone_setting(
|
||||||
self.storage.get_setting(
|
self.storage.get_setting("streak_milestones", self.DEFAULT_STREAK_MILESTONES)
|
||||||
"streak_milestones", self.DEFAULT_STREAK_MILESTONES
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
milestones = self.parse_milestone_setting(
|
milestones = self.parse_milestone_setting(self.DEFAULT_STREAK_MILESTONES)
|
||||||
self.DEFAULT_STREAK_MILESTONES
|
|
||||||
)
|
|
||||||
refund = sum(milestones.get(d, 0) for d in lost)
|
refund = sum(milestones.get(d, 0) for d in lost)
|
||||||
if refund > 0:
|
if refund > 0:
|
||||||
child.points = max(0, child.points - refund)
|
child.points = max(0, child.points - refund)
|
||||||
child.total_points_earned = max(
|
child.total_points_earned = max(0, child.total_points_earned - refund)
|
||||||
0, child.total_points_earned - refund
|
child.career_score = child.total_points_earned - child.total_penalties_received
|
||||||
)
|
|
||||||
child.career_score = (
|
|
||||||
child.total_points_earned
|
|
||||||
- child.total_penalties_received
|
|
||||||
)
|
|
||||||
self.storage.add_points_transaction(
|
self.storage.add_points_transaction(
|
||||||
PointsTransaction(
|
PointsTransaction(
|
||||||
child_id=child.id,
|
child_id=child.id,
|
||||||
@@ -1120,9 +1162,7 @@ class ChoresMixin:
|
|||||||
created_at=dt_util.now(),
|
created_at=dt_util.now(),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
child.streak_milestones_achieved = sorted(
|
child.streak_milestones_achieved = sorted(d for d in achieved if d <= child.current_streak)
|
||||||
d for d in achieved if d <= child.current_streak
|
|
||||||
)
|
|
||||||
|
|
||||||
self.storage.update_child(child)
|
self.storage.update_child(child)
|
||||||
|
|
||||||
@@ -1133,7 +1173,8 @@ class ChoresMixin:
|
|||||||
# chore/child on the same day (caller disposes of the records).
|
# chore/child on the same day (caller disposes of the records).
|
||||||
comp_date = dt_util.as_local(target_completion.completed_at).date()
|
comp_date = dt_util.as_local(target_completion.completed_at).date()
|
||||||
bonus_completions = [
|
bonus_completions = [
|
||||||
c for c in completions
|
c
|
||||||
|
for c in completions
|
||||||
if c.chore_id == target_completion.chore_id
|
if c.chore_id == target_completion.chore_id
|
||||||
and c.child_id == target_completion.child_id
|
and c.child_id == target_completion.child_id
|
||||||
and c.bonus_subtask_id
|
and c.bonus_subtask_id
|
||||||
@@ -1151,13 +1192,11 @@ class ChoresMixin:
|
|||||||
self.storage.update_child(child)
|
self.storage.update_child(child)
|
||||||
|
|
||||||
# Undo last_completed store so recurrence window resets correctly
|
# Undo last_completed store so recurrence window resets correctly
|
||||||
self.storage.undo_last_completed(
|
self.storage.undo_last_completed(target_completion.chore_id, target_completion.child_id)
|
||||||
target_completion.chore_id, target_completion.child_id
|
|
||||||
)
|
|
||||||
|
|
||||||
# One-shot: re-enable for this child
|
# One-shot: re-enable for this child
|
||||||
chore = self.get_chore(target_completion.chore_id)
|
chore = self.get_chore(target_completion.chore_id)
|
||||||
if chore and getattr(chore, 'schedule_mode', 'specific_days') == 'one_shot':
|
if chore and getattr(chore, "schedule_mode", "specific_days") == "one_shot":
|
||||||
if target_completion.child_id in chore.disabled_for:
|
if target_completion.child_id in chore.disabled_for:
|
||||||
chore.disabled_for.remove(target_completion.child_id)
|
chore.disabled_for.remove(target_completion.child_id)
|
||||||
chore.enabled = True
|
chore.enabled = True
|
||||||
@@ -1168,14 +1207,10 @@ class ChoresMixin:
|
|||||||
async def async_reject_chore(self, completion_id: str) -> None:
|
async def async_reject_chore(self, completion_id: str) -> None:
|
||||||
"""Reject a chore completion and fully reverse all awards if already granted."""
|
"""Reject a chore completion and fully reverse all awards if already granted."""
|
||||||
completions = self.storage.get_completions()
|
completions = self.storage.get_completions()
|
||||||
target_completion = next(
|
target_completion = next((c for c in completions if c.id == completion_id), None)
|
||||||
(c for c in completions if c.id == completion_id), None
|
|
||||||
)
|
|
||||||
|
|
||||||
if target_completion:
|
if target_completion:
|
||||||
bonus_completions = self._reverse_completion_awards(
|
bonus_completions = self._reverse_completion_awards(target_completion, completions)
|
||||||
target_completion, completions
|
|
||||||
)
|
|
||||||
for bc in bonus_completions:
|
for bc in bonus_completions:
|
||||||
self.storage.remove_completion(bc.id)
|
self.storage.remove_completion(bc.id)
|
||||||
|
|
||||||
@@ -1190,21 +1225,22 @@ class ChoresMixin:
|
|||||||
if target_completion:
|
if target_completion:
|
||||||
child = self.get_child(target_completion.child_id)
|
child = self.get_child(target_completion.child_id)
|
||||||
chore = self.get_chore(target_completion.chore_id)
|
chore = self.get_chore(target_completion.chore_id)
|
||||||
self.hass.bus.async_fire("taskmate_chore_rejected", {
|
self.hass.bus.async_fire(
|
||||||
"child_id": target_completion.child_id,
|
"taskmate_chore_rejected",
|
||||||
"child_name": getattr(child, "name", ""),
|
{
|
||||||
"chore_id": target_completion.chore_id,
|
"child_id": target_completion.child_id,
|
||||||
"chore_name": getattr(chore, "name", ""),
|
"child_name": getattr(child, "name", ""),
|
||||||
"completion_id": completion_id,
|
"chore_id": target_completion.chore_id,
|
||||||
"timestamp": dt_util.now().isoformat(),
|
"chore_name": getattr(chore, "name", ""),
|
||||||
})
|
"completion_id": completion_id,
|
||||||
|
"timestamp": dt_util.now().isoformat(),
|
||||||
|
},
|
||||||
|
)
|
||||||
# Dismiss the mobile approval push for this reviewed completion. Also
|
# Dismiss the mobile approval push for this reviewed completion. Also
|
||||||
# covers undoing an already-approved chore (whose push was cleared at
|
# covers undoing an already-approved chore (whose push was cleared at
|
||||||
# approval): re-clearing a stale tag is a harmless no-op.
|
# approval): re-clearing a stale tag is a harmless no-op.
|
||||||
if getattr(self, "notifications", None):
|
if getattr(self, "notifications", None):
|
||||||
await self.notifications.clear_approval(
|
await self.notifications.clear_approval("pending_chore_approval", completion_id)
|
||||||
"pending_chore_approval", completion_id
|
|
||||||
)
|
|
||||||
|
|
||||||
async def async_undo_chore_approval(self, completion_id: str) -> None:
|
async def async_undo_chore_approval(self, completion_id: str) -> None:
|
||||||
"""Undo an accidental approval: reverse the awards and return the
|
"""Undo an accidental approval: reverse the awards and return the
|
||||||
@@ -1240,14 +1276,17 @@ class ChoresMixin:
|
|||||||
|
|
||||||
child = self.get_child(target.child_id)
|
child = self.get_child(target.child_id)
|
||||||
chore = self.get_chore(target.chore_id)
|
chore = self.get_chore(target.chore_id)
|
||||||
self.hass.bus.async_fire("taskmate_chore_approval_undone", {
|
self.hass.bus.async_fire(
|
||||||
"child_id": target.child_id,
|
"taskmate_chore_approval_undone",
|
||||||
"child_name": getattr(child, "name", ""),
|
{
|
||||||
"chore_id": target.chore_id,
|
"child_id": target.child_id,
|
||||||
"chore_name": getattr(chore, "name", ""),
|
"child_name": getattr(child, "name", ""),
|
||||||
"completion_id": completion_id,
|
"chore_id": target.chore_id,
|
||||||
"timestamp": dt_util.now().isoformat(),
|
"chore_name": getattr(chore, "name", ""),
|
||||||
})
|
"completion_id": completion_id,
|
||||||
|
"timestamp": dt_util.now().isoformat(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
def _check_one_shot_fully_disabled(self, chore) -> None:
|
def _check_one_shot_fully_disabled(self, chore) -> None:
|
||||||
"""Check if a one-shot chore should be fully disabled (all children done)."""
|
"""Check if a one-shot chore should be fully disabled (all children done)."""
|
||||||
@@ -1278,16 +1317,16 @@ class ChoresMixin:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
# Check if chore is globally disabled (soft-disabled one-shot chores)
|
# Check if chore is globally disabled (soft-disabled one-shot chores)
|
||||||
if not getattr(chore, 'enabled', True):
|
if not getattr(chore, "enabled", True):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Check per-child disabling (one-shot chores completed by this child)
|
# Check per-child disabling (one-shot chores completed by this child)
|
||||||
disabled_for = getattr(chore, 'disabled_for', [])
|
disabled_for = getattr(chore, "disabled_for", [])
|
||||||
if child_id in disabled_for:
|
if child_id in disabled_for:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Dynamic assignment — only the active child(ren) see alternating/random chores
|
# Dynamic assignment — only the active child(ren) see alternating/random chores
|
||||||
if getattr(chore, 'assignment_mode', 'everyone') != 'everyone':
|
if getattr(chore, "assignment_mode", "everyone") != "everyone":
|
||||||
active = self._compute_active_children(chore)
|
active = self._compute_active_children(chore)
|
||||||
if child_id not in active:
|
if child_id not in active:
|
||||||
return False
|
return False
|
||||||
@@ -1298,9 +1337,9 @@ class ChoresMixin:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
# Check visibility entity first — if not visible, chore is not available
|
# Check visibility entity first — if not visible, chore is not available
|
||||||
visibility_entity = getattr(chore, 'visibility_entity', '')
|
visibility_entity = getattr(chore, "visibility_entity", "")
|
||||||
visibility_state = getattr(chore, 'visibility_state', 'on')
|
visibility_state = getattr(chore, "visibility_state", "on")
|
||||||
visibility_operator = getattr(chore, 'visibility_operator', 'equals')
|
visibility_operator = getattr(chore, "visibility_operator", "equals")
|
||||||
if not self._is_visibility_entity_active(visibility_entity, visibility_state, visibility_operator):
|
if not self._is_visibility_entity_active(visibility_entity, visibility_state, visibility_operator):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -1318,7 +1357,7 @@ class ChoresMixin:
|
|||||||
|
|
||||||
# Chore dependencies (FEAT-1): this chore unlocks only once every chore
|
# Chore dependencies (FEAT-1): this chore unlocks only once every chore
|
||||||
# it depends on has an approved completion today by this same child.
|
# it depends on has an approved completion today by this same child.
|
||||||
depends_on = getattr(chore, 'depends_on', []) or []
|
depends_on = getattr(chore, "depends_on", []) or []
|
||||||
if depends_on:
|
if depends_on:
|
||||||
dep_today = dt_util.as_local(dt_util.now()).date()
|
dep_today = dt_util.as_local(dt_util.now()).date()
|
||||||
completions = self._cached_completions()
|
completions = self._cached_completions()
|
||||||
@@ -1327,18 +1366,18 @@ class ChoresMixin:
|
|||||||
c.chore_id == dep_id
|
c.chore_id == dep_id
|
||||||
and c.child_id == child_id
|
and c.child_id == child_id
|
||||||
and c.approved
|
and c.approved
|
||||||
and not getattr(c, 'bonus_subtask_id', '')
|
and not getattr(c, "bonus_subtask_id", "")
|
||||||
and dt_util.as_local(c.completed_at).date() == dep_today
|
and dt_util.as_local(c.completed_at).date() == dep_today
|
||||||
for c in completions
|
for c in completions
|
||||||
)
|
)
|
||||||
if not satisfied:
|
if not satisfied:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
schedule_mode = getattr(chore, 'schedule_mode', 'specific_days')
|
schedule_mode = getattr(chore, "schedule_mode", "specific_days")
|
||||||
|
|
||||||
# One-shot chores: only available on the day they were created
|
# One-shot chores: only available on the day they were created
|
||||||
if schedule_mode == 'one_shot':
|
if schedule_mode == "one_shot":
|
||||||
created_date = getattr(chore, 'created_date', '')
|
created_date = getattr(chore, "created_date", "")
|
||||||
if created_date:
|
if created_date:
|
||||||
today = dt_util.as_local(dt_util.now()).date()
|
today = dt_util.as_local(dt_util.now()).date()
|
||||||
try:
|
try:
|
||||||
@@ -1348,25 +1387,25 @@ class ChoresMixin:
|
|||||||
pass
|
pass
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if schedule_mode != 'recurring':
|
if schedule_mode != "recurring":
|
||||||
return True
|
return True
|
||||||
|
|
||||||
recurrence = getattr(chore, 'recurrence', 'weekly')
|
recurrence = getattr(chore, "recurrence", "weekly")
|
||||||
first_occurrence_mode = getattr(chore, 'first_occurrence_mode', 'available_immediately')
|
first_occurrence_mode = getattr(chore, "first_occurrence_mode", "available_immediately")
|
||||||
recurrence_day = getattr(chore, 'recurrence_day', '')
|
recurrence_day = getattr(chore, "recurrence_day", "")
|
||||||
recurrence_start = getattr(chore, 'recurrence_start', '')
|
recurrence_start = getattr(chore, "recurrence_start", "")
|
||||||
|
|
||||||
now = dt_util.now()
|
now = dt_util.now()
|
||||||
today = dt_util.as_local(now).date()
|
today = dt_util.as_local(now).date()
|
||||||
|
|
||||||
window_days = {
|
window_days = {
|
||||||
'every_2_days': 2,
|
"every_2_days": 2,
|
||||||
'weekly': 7,
|
"weekly": 7,
|
||||||
'every_2_weeks': 14,
|
"every_2_weeks": 14,
|
||||||
}.get(recurrence, 7)
|
}.get(recurrence, 7)
|
||||||
|
|
||||||
record = self.storage.get_last_completed(chore.id, child_id)
|
record = self.storage.get_last_completed(chore.id, child_id)
|
||||||
current_iso = record.get('current')
|
current_iso = record.get("current")
|
||||||
|
|
||||||
if not current_iso:
|
if not current_iso:
|
||||||
# Never completed — a future recurrence anchor always defers
|
# Never completed — a future recurrence anchor always defers
|
||||||
@@ -1377,7 +1416,7 @@ class ChoresMixin:
|
|||||||
return False
|
return False
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
if first_occurrence_mode == 'wait_for_first_occurrence' and recurrence_day:
|
if first_occurrence_mode == "wait_for_first_occurrence" and recurrence_day:
|
||||||
target_dow = _DOW_MAP.get(recurrence_day.lower())
|
target_dow = _DOW_MAP.get(recurrence_day.lower())
|
||||||
if target_dow is not None and today.weekday() != target_dow:
|
if target_dow is not None and today.weekday() != target_dow:
|
||||||
return False
|
return False
|
||||||
@@ -1389,7 +1428,7 @@ class ChoresMixin:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
# every_2_days with anchor — check alignment
|
# every_2_days with anchor — check alignment
|
||||||
if recurrence == 'every_2_days' and recurrence_start:
|
if recurrence == "every_2_days" and recurrence_start:
|
||||||
try:
|
try:
|
||||||
anchor = date.fromisoformat(recurrence_start)
|
anchor = date.fromisoformat(recurrence_start)
|
||||||
days_since_anchor = (today - anchor).days
|
days_since_anchor = (today - anchor).days
|
||||||
@@ -1402,7 +1441,7 @@ class ChoresMixin:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
# weekly/every_2_weeks with specific day — only available on that day
|
# weekly/every_2_weeks with specific day — only available on that day
|
||||||
if recurrence_day and recurrence in ('weekly', 'every_2_weeks'):
|
if recurrence_day and recurrence in ("weekly", "every_2_weeks"):
|
||||||
target_dow = _DOW_MAP.get(recurrence_day.lower())
|
target_dow = _DOW_MAP.get(recurrence_day.lower())
|
||||||
if target_dow is not None and today.weekday() != target_dow:
|
if target_dow is not None and today.weekday() != target_dow:
|
||||||
return False
|
return False
|
||||||
@@ -1477,7 +1516,9 @@ class ChoresMixin:
|
|||||||
changed = True
|
changed = True
|
||||||
_LOGGER.info(
|
_LOGGER.info(
|
||||||
"Chore '%s' expired (expires_on %s, today %s)",
|
"Chore '%s' expired (expires_on %s, today %s)",
|
||||||
chore.name, expires_on, today.isoformat(),
|
chore.name,
|
||||||
|
expires_on,
|
||||||
|
today.isoformat(),
|
||||||
)
|
)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
continue
|
continue
|
||||||
@@ -1492,11 +1533,11 @@ class ChoresMixin:
|
|||||||
changed = False
|
changed = False
|
||||||
|
|
||||||
for chore in self.storage.get_chores():
|
for chore in self.storage.get_chores():
|
||||||
if getattr(chore, 'schedule_mode', 'specific_days') != 'one_shot':
|
if getattr(chore, "schedule_mode", "specific_days") != "one_shot":
|
||||||
continue
|
continue
|
||||||
if not getattr(chore, 'enabled', True):
|
if not getattr(chore, "enabled", True):
|
||||||
continue
|
continue
|
||||||
created_date = getattr(chore, 'created_date', '')
|
created_date = getattr(chore, "created_date", "")
|
||||||
if not created_date:
|
if not created_date:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
@@ -1506,7 +1547,9 @@ class ChoresMixin:
|
|||||||
changed = True
|
changed = True
|
||||||
_LOGGER.info(
|
_LOGGER.info(
|
||||||
"One-shot chore '%s' expired (created %s, today %s)",
|
"One-shot chore '%s' expired (created %s, today %s)",
|
||||||
chore.name, created_date, today.isoformat(),
|
chore.name,
|
||||||
|
created_date,
|
||||||
|
today.isoformat(),
|
||||||
)
|
)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
continue
|
continue
|
||||||
@@ -1563,18 +1606,15 @@ class ChoresMixin:
|
|||||||
if not chore:
|
if not chore:
|
||||||
raise ValueError(f"Unknown chore: {chore_id}")
|
raise ValueError(f"Unknown chore: {chore_id}")
|
||||||
if getattr(chore, "assignment_mode", "everyone") == "everyone":
|
if getattr(chore, "assignment_mode", "everyone") == "everyone":
|
||||||
raise ValueError(
|
raise ValueError(f"Chore '{chore.name}' uses 'everyone' mode and cannot join a group")
|
||||||
f"Chore '{chore.name}' uses 'everyone' mode and cannot join a group"
|
|
||||||
)
|
|
||||||
existing_group = self.storage.get_task_group_for_chore(chore_id)
|
existing_group = self.storage.get_task_group_for_chore(chore_id)
|
||||||
if existing_group and existing_group.id != exclude_group_id:
|
if existing_group and existing_group.id != exclude_group_id:
|
||||||
raise ValueError(
|
raise ValueError(f"Chore '{chore.name}' already belongs to group '{existing_group.name}'")
|
||||||
f"Chore '{chore.name}' already belongs to group '{existing_group.name}'"
|
|
||||||
)
|
|
||||||
|
|
||||||
async def async_add_task_group(self, name: str, policy: str, chore_ids: list[str] | None = None):
|
async def async_add_task_group(self, name: str, policy: str, chore_ids: list[str] | None = None):
|
||||||
"""Create a task group."""
|
"""Create a task group."""
|
||||||
from .models import TaskGroup
|
from .models import TaskGroup
|
||||||
|
|
||||||
if policy not in ("sticky", "spread"):
|
if policy not in ("sticky", "spread"):
|
||||||
raise ValueError(f"Unknown task group policy: {policy}")
|
raise ValueError(f"Unknown task group policy: {policy}")
|
||||||
chore_ids = list(chore_ids or [])
|
chore_ids = list(chore_ids or [])
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Mandatory-chore detection, scheduling, and resolution (#532)."""
|
"""Mandatory-chore detection, scheduling, and resolution (#532)."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -64,10 +65,7 @@ class MandatoryMixin:
|
|||||||
|
|
||||||
async def async_detect_mandatory_misses(self, period_id: str, day: date) -> int:
|
async def async_detect_mandatory_misses(self, period_id: str, day: date) -> int:
|
||||||
"""Create misses for due+incomplete mandatory chores in `period_id`."""
|
"""Create misses for due+incomplete mandatory chores in `period_id`."""
|
||||||
existing = {
|
existing = {(m.chore_id, m.child_id, m.due_date) for m in self.storage.get_mandatory_misses()}
|
||||||
(m.chore_id, m.child_id, m.due_date)
|
|
||||||
for m in self.storage.get_mandatory_misses()
|
|
||||||
}
|
|
||||||
created = 0
|
created = 0
|
||||||
for chore in self.storage.get_chores():
|
for chore in self.storage.get_chores():
|
||||||
if not getattr(chore, "mandatory", False):
|
if not getattr(chore, "mandatory", False):
|
||||||
@@ -94,11 +92,17 @@ class MandatoryMixin:
|
|||||||
)
|
)
|
||||||
self.storage.add_mandatory_miss(miss)
|
self.storage.add_mandatory_miss(miss)
|
||||||
created += 1
|
created += 1
|
||||||
self.hass.bus.async_fire("taskmate_mandatory_missed", {
|
self.hass.bus.async_fire(
|
||||||
"miss_id": miss.id, "chore_id": chore.id, "child_id": child_id,
|
"taskmate_mandatory_missed",
|
||||||
"period_id": period_id, "penalty_points": miss.penalty_points,
|
{
|
||||||
"timestamp": dt_util.now().isoformat(),
|
"miss_id": miss.id,
|
||||||
})
|
"chore_id": chore.id,
|
||||||
|
"child_id": child_id,
|
||||||
|
"period_id": period_id,
|
||||||
|
"penalty_points": miss.penalty_points,
|
||||||
|
"timestamp": dt_util.now().isoformat(),
|
||||||
|
},
|
||||||
|
)
|
||||||
if created:
|
if created:
|
||||||
await self.storage.async_save()
|
await self.storage.async_save()
|
||||||
await self.async_refresh()
|
await self.async_refresh()
|
||||||
@@ -132,15 +136,22 @@ class MandatoryMixin:
|
|||||||
name = getattr(chore, "name", "chore")
|
name = getattr(chore, "name", "chore")
|
||||||
if miss.penalty_points > 0:
|
if miss.penalty_points > 0:
|
||||||
await self.async_remove_points(
|
await self.async_remove_points(
|
||||||
miss.child_id, miss.penalty_points,
|
miss.child_id,
|
||||||
|
miss.penalty_points,
|
||||||
reason=f"Penalty: {name} (missed mandatory)",
|
reason=f"Penalty: {name} (missed mandatory)",
|
||||||
)
|
)
|
||||||
self.storage.remove_mandatory_miss(miss_id)
|
self.storage.remove_mandatory_miss(miss_id)
|
||||||
await self.storage.async_save()
|
await self.storage.async_save()
|
||||||
self.hass.bus.async_fire("taskmate_mandatory_penalty_applied", {
|
self.hass.bus.async_fire(
|
||||||
"miss_id": miss_id, "chore_id": miss.chore_id, "child_id": miss.child_id,
|
"taskmate_mandatory_penalty_applied",
|
||||||
"points": miss.penalty_points, "timestamp": dt_util.now().isoformat(),
|
{
|
||||||
})
|
"miss_id": miss_id,
|
||||||
|
"chore_id": miss.chore_id,
|
||||||
|
"child_id": miss.child_id,
|
||||||
|
"points": miss.penalty_points,
|
||||||
|
"timestamp": dt_util.now().isoformat(),
|
||||||
|
},
|
||||||
|
)
|
||||||
await self.async_refresh()
|
await self.async_refresh()
|
||||||
|
|
||||||
async def async_postpone_mandatory_chore(self, miss_id: str) -> None:
|
async def async_postpone_mandatory_chore(self, miss_id: str) -> None:
|
||||||
@@ -156,10 +167,16 @@ class MandatoryMixin:
|
|||||||
# else: no window left today -> let normal scheduling resurface tomorrow
|
# else: no window left today -> let normal scheduling resurface tomorrow
|
||||||
self.storage.remove_mandatory_miss(miss_id)
|
self.storage.remove_mandatory_miss(miss_id)
|
||||||
await self.storage.async_save()
|
await self.storage.async_save()
|
||||||
self.hass.bus.async_fire("taskmate_mandatory_postponed", {
|
self.hass.bus.async_fire(
|
||||||
"miss_id": miss_id, "chore_id": miss.chore_id, "child_id": miss.child_id,
|
"taskmate_mandatory_postponed",
|
||||||
"next_period": nxt or "", "timestamp": dt_util.now().isoformat(),
|
{
|
||||||
})
|
"miss_id": miss_id,
|
||||||
|
"chore_id": miss.chore_id,
|
||||||
|
"child_id": miss.child_id,
|
||||||
|
"next_period": nxt or "",
|
||||||
|
"timestamp": dt_util.now().isoformat(),
|
||||||
|
},
|
||||||
|
)
|
||||||
await self.async_refresh()
|
await self.async_refresh()
|
||||||
|
|
||||||
async def async_dismiss_mandatory_chore(self, miss_id: str) -> None:
|
async def async_dismiss_mandatory_chore(self, miss_id: str) -> None:
|
||||||
@@ -168,10 +185,15 @@ class MandatoryMixin:
|
|||||||
return
|
return
|
||||||
self.storage.remove_mandatory_miss(miss_id)
|
self.storage.remove_mandatory_miss(miss_id)
|
||||||
await self.storage.async_save()
|
await self.storage.async_save()
|
||||||
self.hass.bus.async_fire("taskmate_mandatory_dismissed", {
|
self.hass.bus.async_fire(
|
||||||
"miss_id": miss_id, "chore_id": miss.chore_id, "child_id": miss.child_id,
|
"taskmate_mandatory_dismissed",
|
||||||
"timestamp": dt_util.now().isoformat(),
|
{
|
||||||
})
|
"miss_id": miss_id,
|
||||||
|
"chore_id": miss.chore_id,
|
||||||
|
"child_id": miss.child_id,
|
||||||
|
"timestamp": dt_util.now().isoformat(),
|
||||||
|
},
|
||||||
|
)
|
||||||
await self.async_refresh()
|
await self.async_refresh()
|
||||||
|
|
||||||
# ---- escalation (FEAT-6) ----------------------------------------------
|
# ---- escalation (FEAT-6) ----------------------------------------------
|
||||||
@@ -225,12 +247,14 @@ class MandatoryMixin:
|
|||||||
for stage in range(miss.escalation_stage + 1, target + 1):
|
for stage in range(miss.escalation_stage + 1, target + 1):
|
||||||
if stage in (1, 2):
|
if stage in (1, 2):
|
||||||
await self.notifications.fire(
|
await self.notifications.fire(
|
||||||
NOTIF_TYPE_MANDATORY_REMINDER, ctx,
|
NOTIF_TYPE_MANDATORY_REMINDER,
|
||||||
|
ctx,
|
||||||
only_recipients={f"child:{miss.child_id}"},
|
only_recipients={f"child:{miss.child_id}"},
|
||||||
)
|
)
|
||||||
elif stage == 3:
|
elif stage == 3:
|
||||||
await self.notifications.fire(
|
await self.notifications.fire(
|
||||||
NOTIF_TYPE_MANDATORY_PARENT_ALERT, ctx,
|
NOTIF_TYPE_MANDATORY_PARENT_ALERT,
|
||||||
|
ctx,
|
||||||
)
|
)
|
||||||
miss.escalation_stage = target
|
miss.escalation_stage = target
|
||||||
self.storage.update_mandatory_miss(miss)
|
self.storage.update_mandatory_miss(miss)
|
||||||
@@ -261,13 +285,17 @@ class MandatoryMixin:
|
|||||||
unsub = async_track_time_change(
|
unsub = async_track_time_change(
|
||||||
self.hass,
|
self.hass,
|
||||||
self._make_mandatory_period_cb(period_id),
|
self._make_mandatory_period_cb(period_id),
|
||||||
hour=hour, minute=minute, second=10,
|
hour=hour,
|
||||||
|
minute=minute,
|
||||||
|
second=10,
|
||||||
)
|
)
|
||||||
self._unsub_mandatory.append(unsub)
|
self._unsub_mandatory.append(unsub)
|
||||||
# Reminder escalation ladder (FEAT-6) — re-evaluate open misses on a tick.
|
# Reminder escalation ladder (FEAT-6) — re-evaluate open misses on a tick.
|
||||||
self._unsub_mandatory.append(
|
self._unsub_mandatory.append(
|
||||||
async_track_time_interval(
|
async_track_time_interval(
|
||||||
self.hass, self._escalation_tick, _ESCALATION_INTERVAL,
|
self.hass,
|
||||||
|
self._escalation_tick,
|
||||||
|
_ESCALATION_INTERVAL,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -278,9 +306,8 @@ class MandatoryMixin:
|
|||||||
def _make_mandatory_period_cb(self, period_id: str):
|
def _make_mandatory_period_cb(self, period_id: str):
|
||||||
@callback
|
@callback
|
||||||
def _cb(now: datetime) -> None:
|
def _cb(now: datetime) -> None:
|
||||||
self.hass.async_create_task(
|
self.hass.async_create_task(self.async_detect_mandatory_misses(period_id, dt_util.now().date()))
|
||||||
self.async_detect_mandatory_misses(period_id, dt_util.now().date())
|
|
||||||
)
|
|
||||||
return _cb
|
return _cb
|
||||||
|
|
||||||
def disarm_mandatory_schedules(self) -> None:
|
def disarm_mandatory_schedules(self) -> None:
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ All TaskMate notifications flow through this module. It owns:
|
|||||||
Other coordinators MUST NOT call notify.* / persistent_notification directly
|
Other coordinators MUST NOT call notify.* / persistent_notification directly
|
||||||
once this module is in place. They call self.notifications.fire(...).
|
once this module is in place. They call self.notifications.fire(...).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -59,34 +60,32 @@ def _approval_tag(entry_id: str) -> str:
|
|||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class NotificationTypeMeta:
|
class NotificationTypeMeta:
|
||||||
id: str
|
id: str
|
||||||
audience: str # "child" | "parent" | "both"
|
audience: str # "child" | "parent" | "both"
|
||||||
time_gated: bool # has its own scheduled callback
|
time_gated: bool # has its own scheduled callback
|
||||||
per_recipient_time: bool # if True, route.time controls the schedule per recipient
|
per_recipient_time: bool # if True, route.time controls the schedule per recipient
|
||||||
actionable: bool # carries Approve/Reject mobile actions
|
actionable: bool # carries Approve/Reject mobile actions
|
||||||
default_enabled: bool # default master_enabled state at install
|
default_enabled: bool # default master_enabled state at install
|
||||||
|
|
||||||
|
|
||||||
NOTIFICATION_TYPES: list[NotificationTypeMeta] = [
|
NOTIFICATION_TYPES: list[NotificationTypeMeta] = [
|
||||||
NotificationTypeMeta(NOTIF_TYPE_BEDTIME_REMINDER, "child", True, True, False, False),
|
NotificationTypeMeta(NOTIF_TYPE_BEDTIME_REMINDER, "child", True, True, False, False),
|
||||||
NotificationTypeMeta(NOTIF_TYPE_STREAK_AT_RISK, "child", True, False, False, False),
|
NotificationTypeMeta(NOTIF_TYPE_STREAK_AT_RISK, "child", True, False, False, False),
|
||||||
NotificationTypeMeta(NOTIF_TYPE_ALL_CHORES_DONE, "both", False, False, False, False),
|
NotificationTypeMeta(NOTIF_TYPE_ALL_CHORES_DONE, "both", False, False, False, False),
|
||||||
NotificationTypeMeta(NOTIF_TYPE_BADGE_EARNED, "both", False, False, False, True),
|
NotificationTypeMeta(NOTIF_TYPE_BADGE_EARNED, "both", False, False, False, True),
|
||||||
NotificationTypeMeta(NOTIF_TYPE_PENDING_CHORE_APPROVAL, "parent", False, False, True, True),
|
NotificationTypeMeta(NOTIF_TYPE_PENDING_CHORE_APPROVAL, "parent", False, False, True, True),
|
||||||
NotificationTypeMeta(NOTIF_TYPE_PENDING_REWARD_CLAIM, "parent", False, False, True, True),
|
NotificationTypeMeta(NOTIF_TYPE_PENDING_REWARD_CLAIM, "parent", False, False, True, True),
|
||||||
NotificationTypeMeta(NOTIF_TYPE_STREAK_MILESTONE, "both", False, False, False, False),
|
NotificationTypeMeta(NOTIF_TYPE_STREAK_MILESTONE, "both", False, False, False, False),
|
||||||
NotificationTypeMeta(NOTIF_TYPE_LEVEL_UP, "both", False, False, False, False),
|
NotificationTypeMeta(NOTIF_TYPE_LEVEL_UP, "both", False, False, False, False),
|
||||||
NotificationTypeMeta(NOTIF_TYPE_WEEKLY_DIGEST, "parent", False, False, False, False),
|
NotificationTypeMeta(NOTIF_TYPE_WEEKLY_DIGEST, "parent", False, False, False, False),
|
||||||
NotificationTypeMeta(NOTIF_TYPE_CELEBRATION, "both", False, False, False, False),
|
NotificationTypeMeta(NOTIF_TYPE_CELEBRATION, "both", False, False, False, False),
|
||||||
NotificationTypeMeta(NOTIF_TYPE_MANDATORY_REMINDER, "child", False, False, False, False),
|
NotificationTypeMeta(NOTIF_TYPE_MANDATORY_REMINDER, "child", False, False, False, False),
|
||||||
NotificationTypeMeta(NOTIF_TYPE_MANDATORY_PARENT_ALERT, "parent", False, False, False, False),
|
NotificationTypeMeta(NOTIF_TYPE_MANDATORY_PARENT_ALERT, "parent", False, False, False, False),
|
||||||
NotificationTypeMeta(NOTIF_TYPE_MONTHLY_REPORT, "parent", False, False, False, False),
|
NotificationTypeMeta(NOTIF_TYPE_MONTHLY_REPORT, "parent", False, False, False, False),
|
||||||
NotificationTypeMeta(NOTIF_TYPE_SEASON_CHAMPION, "both", False, False, False, False),
|
NotificationTypeMeta(NOTIF_TYPE_SEASON_CHAMPION, "both", False, False, False, False),
|
||||||
NotificationTypeMeta(NOTIF_TYPE_FAMILY_GOAL_REACHED, "both", False, False, False, False),
|
NotificationTypeMeta(NOTIF_TYPE_FAMILY_GOAL_REACHED, "both", False, False, False, False),
|
||||||
]
|
]
|
||||||
|
|
||||||
NOTIFICATION_TYPES_BY_ID: dict[str, NotificationTypeMeta] = {
|
NOTIFICATION_TYPES_BY_ID: dict[str, NotificationTypeMeta] = {t.id: t for t in NOTIFICATION_TYPES}
|
||||||
t.id: t for t in NOTIFICATION_TYPES
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_nav_url(value: str) -> str:
|
def _validate_nav_url(value: str) -> str:
|
||||||
@@ -101,11 +100,7 @@ def _validate_nav_url(value: str) -> str:
|
|||||||
return value
|
return value
|
||||||
if value.lower().startswith(("http://", "https://")):
|
if value.lower().startswith(("http://", "https://")):
|
||||||
return value
|
return value
|
||||||
if (
|
if value.startswith("/") and not value.startswith("//") and not any(ord(c) <= 32 or ord(c) == 127 for c in value):
|
||||||
value.startswith("/")
|
|
||||||
and not value.startswith("//")
|
|
||||||
and not any(ord(c) <= 32 or ord(c) == 127 for c in value)
|
|
||||||
):
|
|
||||||
return value
|
return value
|
||||||
raise ValueError("nav_url must be a /path, an http(s) URL, or noAction")
|
raise ValueError("nav_url must be a /path, an http(s) URL, or noAction")
|
||||||
|
|
||||||
@@ -145,6 +140,7 @@ def _is_within_quiet_hours(start: str, end: str, now) -> bool:
|
|||||||
|
|
||||||
class _SafeDict(dict):
|
class _SafeDict(dict):
|
||||||
"""str.format_map dict that leaves missing keys as `{key}` literal."""
|
"""str.format_map dict that leaves missing keys as `{key}` literal."""
|
||||||
|
|
||||||
def __missing__(self, key: str) -> str:
|
def __missing__(self, key: str) -> str:
|
||||||
return "{" + key + "}"
|
return "{" + key + "}"
|
||||||
|
|
||||||
@@ -155,11 +151,13 @@ class NotificationCoordinator:
|
|||||||
def __init__(self, hass: HomeAssistant, storage) -> None:
|
def __init__(self, hass: HomeAssistant, storage) -> None:
|
||||||
self.hass = hass
|
self.hass = hass
|
||||||
self.storage = storage
|
self.storage = storage
|
||||||
self._scheduled_unsubs: list = [] # cancellation handles for time triggers
|
self._scheduled_unsubs: list = [] # cancellation handles for time triggers
|
||||||
self.coordinator: Any = None
|
self.coordinator: Any = None
|
||||||
|
|
||||||
async def fire(
|
async def fire(
|
||||||
self, type_id: str, context: dict[str, Any],
|
self,
|
||||||
|
type_id: str,
|
||||||
|
context: dict[str, Any],
|
||||||
only_recipients: set[str] | None = None,
|
only_recipients: set[str] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Dispatch a notification of the given type with the given context.
|
"""Dispatch a notification of the given type with the given context.
|
||||||
@@ -229,7 +227,8 @@ class NotificationCoordinator:
|
|||||||
set one up shouldn't be silently excluded from every approval.
|
set one up shouldn't be silently excluded from every approval.
|
||||||
"""
|
"""
|
||||||
parent = next(
|
parent = next(
|
||||||
(p for p in self.storage.get_parent_recipients() if p.id == recipient_id), None,
|
(p for p in self.storage.get_parent_recipients() if p.id == recipient_id),
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
entity_id = (getattr(parent, "presence_entity", "") or "").strip() if parent else ""
|
entity_id = (getattr(parent, "presence_entity", "") or "").strip() if parent else ""
|
||||||
if not entity_id:
|
if not entity_id:
|
||||||
@@ -241,13 +240,16 @@ class NotificationCoordinator:
|
|||||||
return str(state.state).lower() in ("home", "on", "true", "present")
|
return str(state.state).lower() in ("home", "on", "true", "present")
|
||||||
|
|
||||||
def _route_parents(
|
def _route_parents(
|
||||||
self, type_id: str, cfg, only_recipients: set[str] | None,
|
self,
|
||||||
|
type_id: str,
|
||||||
|
cfg,
|
||||||
|
only_recipients: set[str] | None,
|
||||||
) -> set[str]:
|
) -> set[str]:
|
||||||
"""Which parent recipient ids should receive this notification."""
|
"""Which parent recipient ids should receive this notification."""
|
||||||
candidates = [
|
candidates = [
|
||||||
rid for rid, route in cfg.routes.items()
|
rid
|
||||||
if rid.startswith("parent:") and route.enabled
|
for rid, route in cfg.routes.items()
|
||||||
and (only_recipients is None or rid in only_recipients)
|
if rid.startswith("parent:") and route.enabled and (only_recipients is None or rid in only_recipients)
|
||||||
]
|
]
|
||||||
if not candidates:
|
if not candidates:
|
||||||
return set()
|
return set()
|
||||||
@@ -328,20 +330,15 @@ class NotificationCoordinator:
|
|||||||
if child is None:
|
if child is None:
|
||||||
return False
|
return False
|
||||||
from homeassistant.util import dt as dt_util
|
from homeassistant.util import dt as dt_util
|
||||||
return _is_within_quiet_hours(
|
|
||||||
child.quiet_hours_start, child.quiet_hours_end, dt_util.now()
|
return _is_within_quiet_hours(child.quiet_hours_start, child.quiet_hours_end, dt_util.now())
|
||||||
)
|
|
||||||
|
|
||||||
def _resolve_nav_url(self, cfg) -> str:
|
def _resolve_nav_url(self, cfg) -> str:
|
||||||
"""Tap target for this notification: per-type override, else global default."""
|
"""Tap target for this notification: per-type override, else global default."""
|
||||||
per_type = (getattr(cfg, "nav_url", "") or "").strip()
|
per_type = (getattr(cfg, "nav_url", "") or "").strip()
|
||||||
if per_type:
|
if per_type:
|
||||||
return per_type
|
return per_type
|
||||||
return str(
|
return str(self.storage.get_setting("notification_nav_url", DEFAULT_NOTIFICATION_NAV_URL) or "").strip()
|
||||||
self.storage.get_setting(
|
|
||||||
"notification_nav_url", DEFAULT_NOTIFICATION_NAV_URL
|
|
||||||
) or ""
|
|
||||||
).strip()
|
|
||||||
|
|
||||||
def _resolve_notify_service(self, recipient_id: str) -> str:
|
def _resolve_notify_service(self, recipient_id: str) -> str:
|
||||||
if recipient_id.startswith("child:"):
|
if recipient_id.startswith("child:"):
|
||||||
@@ -358,21 +355,21 @@ class NotificationCoordinator:
|
|||||||
# Built-in types use a baked-in default; will be replaced by translations
|
# Built-in types use a baked-in default; will be replaced by translations
|
||||||
# in a later task. For now use a safe English fallback so dispatch works.
|
# in a later task. For now use a safe English fallback so dispatch works.
|
||||||
templates = {
|
templates = {
|
||||||
NOTIF_TYPE_BEDTIME_REMINDER: "{child_name}, you still have chores to do before bedtime.",
|
NOTIF_TYPE_BEDTIME_REMINDER: "{child_name}, you still have chores to do before bedtime.",
|
||||||
NOTIF_TYPE_STREAK_AT_RISK: "{child_name}, complete a chore today to keep your {streak}-day streak!",
|
NOTIF_TYPE_STREAK_AT_RISK: "{child_name}, complete a chore today to keep your {streak}-day streak!",
|
||||||
NOTIF_TYPE_ALL_CHORES_DONE: "{child_name} finished every chore today!",
|
NOTIF_TYPE_ALL_CHORES_DONE: "{child_name} finished every chore today!",
|
||||||
NOTIF_TYPE_BADGE_EARNED: "{child_name} earned the {badge_name} badge!",
|
NOTIF_TYPE_BADGE_EARNED: "{child_name} earned the {badge_name} badge!",
|
||||||
NOTIF_TYPE_PENDING_CHORE_APPROVAL: "{child_name} completed '{chore_name}' (+{points} {points_name}) — awaiting approval.",
|
NOTIF_TYPE_PENDING_CHORE_APPROVAL: "{child_name} completed '{chore_name}' (+{points} {points_name}) — awaiting approval.",
|
||||||
NOTIF_TYPE_PENDING_REWARD_CLAIM: "{child_name} claimed '{reward_name}' ({cost} {points_name}) — awaiting approval.",
|
NOTIF_TYPE_PENDING_REWARD_CLAIM: "{child_name} claimed '{reward_name}' ({cost} {points_name}) — awaiting approval.",
|
||||||
NOTIF_TYPE_STREAK_MILESTONE: "{child_name} hit a {days}-day streak — +{points} {points_name}!",
|
NOTIF_TYPE_STREAK_MILESTONE: "{child_name} hit a {days}-day streak — +{points} {points_name}!",
|
||||||
NOTIF_TYPE_LEVEL_UP: "{child_name} reached level {level}! 🎉",
|
NOTIF_TYPE_LEVEL_UP: "{child_name} reached level {level}! 🎉",
|
||||||
NOTIF_TYPE_WEEKLY_DIGEST: "TaskMate weekly digest:\n{summary}",
|
NOTIF_TYPE_WEEKLY_DIGEST: "TaskMate weekly digest:\n{summary}",
|
||||||
NOTIF_TYPE_CELEBRATION: "🎉 {message}",
|
NOTIF_TYPE_CELEBRATION: "🎉 {message}",
|
||||||
NOTIF_TYPE_MANDATORY_REMINDER: "{child_name}, you still need to do '{chore_name}'.",
|
NOTIF_TYPE_MANDATORY_REMINDER: "{child_name}, you still need to do '{chore_name}'.",
|
||||||
NOTIF_TYPE_MANDATORY_PARENT_ALERT: "{child_name} still hasn't done the mandatory chore '{chore_name}'.",
|
NOTIF_TYPE_MANDATORY_PARENT_ALERT: "{child_name} still hasn't done the mandatory chore '{chore_name}'.",
|
||||||
NOTIF_TYPE_MONTHLY_REPORT: "TaskMate {month} report:\n{summary}",
|
NOTIF_TYPE_MONTHLY_REPORT: "TaskMate {month} report:\n{summary}",
|
||||||
NOTIF_TYPE_SEASON_CHAMPION: "🏆 {child_name} won the {month} leaderboard with {points} {points_name}!",
|
NOTIF_TYPE_SEASON_CHAMPION: "🏆 {child_name} won the {month} leaderboard with {points} {points_name}!",
|
||||||
NOTIF_TYPE_FAMILY_GOAL_REACHED: "🎉 Family goal reached: {goal_name}! Time for {goal_reward}.",
|
NOTIF_TYPE_FAMILY_GOAL_REACHED: "🎉 Family goal reached: {goal_name}! Time for {goal_reward}.",
|
||||||
}
|
}
|
||||||
tpl = context.get("message_template") or templates.get(meta.id, "")
|
tpl = context.get("message_template") or templates.get(meta.id, "")
|
||||||
try:
|
try:
|
||||||
@@ -383,13 +380,14 @@ class NotificationCoordinator:
|
|||||||
return tpl
|
return tpl
|
||||||
|
|
||||||
async def _send_to(
|
async def _send_to(
|
||||||
self, notify_service: str, message: str,
|
self,
|
||||||
meta: "NotificationTypeMeta", context: dict[str, Any], nav_url: str = "",
|
notify_service: str,
|
||||||
|
message: str,
|
||||||
|
meta: "NotificationTypeMeta",
|
||||||
|
context: dict[str, Any],
|
||||||
|
nav_url: str = "",
|
||||||
) -> None:
|
) -> None:
|
||||||
domain, service = (
|
domain, service = notify_service.split(".", 1) if "." in notify_service else ("notify", notify_service)
|
||||||
notify_service.split(".", 1) if "." in notify_service
|
|
||||||
else ("notify", notify_service)
|
|
||||||
)
|
|
||||||
if domain != "notify":
|
if domain != "notify":
|
||||||
_LOGGER.warning("notify_service must be notify.*, got %s", notify_service)
|
_LOGGER.warning("notify_service must be notify.*, got %s", notify_service)
|
||||||
return
|
return
|
||||||
@@ -419,7 +417,7 @@ class NotificationCoordinator:
|
|||||||
push["tag"] = _approval_tag(entry_id)
|
push["tag"] = _approval_tag(entry_id)
|
||||||
push["actions"] = [
|
push["actions"] = [
|
||||||
{"action": f"TASKMATE_APPROVE_{entry_id}", "title": "Approve"},
|
{"action": f"TASKMATE_APPROVE_{entry_id}", "title": "Approve"},
|
||||||
{"action": f"TASKMATE_REJECT_{entry_id}", "title": "Reject"},
|
{"action": f"TASKMATE_REJECT_{entry_id}", "title": "Reject"},
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
data["message"] = f"{message} {_APPROVE_IN_PANEL_HINT}"
|
data["message"] = f"{message} {_APPROVE_IN_PANEL_HINT}"
|
||||||
@@ -472,10 +470,7 @@ class NotificationCoordinator:
|
|||||||
notify_service = self._resolve_notify_service(recipient_id)
|
notify_service = self._resolve_notify_service(recipient_id)
|
||||||
if not notify_service:
|
if not notify_service:
|
||||||
continue
|
continue
|
||||||
domain, service = (
|
domain, service = notify_service.split(".", 1) if "." in notify_service else ("notify", notify_service)
|
||||||
notify_service.split(".", 1) if "." in notify_service
|
|
||||||
else ("notify", notify_service)
|
|
||||||
)
|
|
||||||
if domain != "notify" or not service.startswith("mobile_app"):
|
if domain != "notify" or not service.startswith("mobile_app"):
|
||||||
continue
|
continue
|
||||||
if service in cleared_services:
|
if service in cleared_services:
|
||||||
@@ -483,7 +478,8 @@ class NotificationCoordinator:
|
|||||||
cleared_services.add(service)
|
cleared_services.add(service)
|
||||||
try:
|
try:
|
||||||
await self.hass.services.async_call(
|
await self.hass.services.async_call(
|
||||||
"notify", service,
|
"notify",
|
||||||
|
service,
|
||||||
{"message": "clear_notification", "data": {"tag": tag}},
|
{"message": "clear_notification", "data": {"tag": tag}},
|
||||||
blocking=False,
|
blocking=False,
|
||||||
)
|
)
|
||||||
@@ -492,7 +488,8 @@ class NotificationCoordinator:
|
|||||||
|
|
||||||
async def _fire_persistent_notification(self, type_id: str, message: str) -> None:
|
async def _fire_persistent_notification(self, type_id: str, message: str) -> None:
|
||||||
await self.hass.services.async_call(
|
await self.hass.services.async_call(
|
||||||
"persistent_notification", "create",
|
"persistent_notification",
|
||||||
|
"create",
|
||||||
{
|
{
|
||||||
"title": "TaskMate",
|
"title": "TaskMate",
|
||||||
"message": message,
|
"message": message,
|
||||||
@@ -516,7 +513,7 @@ class NotificationCoordinator:
|
|||||||
return
|
return
|
||||||
|
|
||||||
if action.startswith("TASKMATE_APPROVE_"):
|
if action.startswith("TASKMATE_APPROVE_"):
|
||||||
entry_id = action[len("TASKMATE_APPROVE_"):]
|
entry_id = action[len("TASKMATE_APPROVE_") :]
|
||||||
try:
|
try:
|
||||||
await coordinator.async_approve_chore(entry_id)
|
await coordinator.async_approve_chore(entry_id)
|
||||||
return
|
return
|
||||||
@@ -527,7 +524,7 @@ class NotificationCoordinator:
|
|||||||
except (ValueError, KeyError):
|
except (ValueError, KeyError):
|
||||||
_LOGGER.info("Mobile action %s — entry not found", action)
|
_LOGGER.info("Mobile action %s — entry not found", action)
|
||||||
elif action.startswith("TASKMATE_REJECT_"):
|
elif action.startswith("TASKMATE_REJECT_"):
|
||||||
entry_id = action[len("TASKMATE_REJECT_"):]
|
entry_id = action[len("TASKMATE_REJECT_") :]
|
||||||
try:
|
try:
|
||||||
await coordinator.async_reject_chore(entry_id)
|
await coordinator.async_reject_chore(entry_id)
|
||||||
return
|
return
|
||||||
@@ -591,7 +588,11 @@ class NotificationCoordinator:
|
|||||||
_LOGGER.warning("Invalid time %r — skipping schedule", hhmm)
|
_LOGGER.warning("Invalid time %r — skipping schedule", hhmm)
|
||||||
return
|
return
|
||||||
unsub = async_track_time_change(
|
unsub = async_track_time_change(
|
||||||
self.hass, callback, hour=hour, minute=minute, second=0,
|
self.hass,
|
||||||
|
callback,
|
||||||
|
hour=hour,
|
||||||
|
minute=minute,
|
||||||
|
second=0,
|
||||||
)
|
)
|
||||||
self._scheduled_unsubs.append(unsub)
|
self._scheduled_unsubs.append(unsub)
|
||||||
|
|
||||||
@@ -606,10 +607,12 @@ class NotificationCoordinator:
|
|||||||
"bedtime_reminder",
|
"bedtime_reminder",
|
||||||
{"child_name": child.name, "child_id": child_id},
|
{"child_name": child.name, "child_id": child_id},
|
||||||
)
|
)
|
||||||
|
|
||||||
return _cb
|
return _cb
|
||||||
|
|
||||||
async def _streak_at_risk_callback(self, now) -> None:
|
async def _streak_at_risk_callback(self, now) -> None:
|
||||||
from homeassistant.util import dt as dt_util
|
from homeassistant.util import dt as dt_util
|
||||||
|
|
||||||
today = dt_util.now().date().isoformat()
|
today = dt_util.now().date().isoformat()
|
||||||
for child in self.storage.get_children():
|
for child in self.storage.get_children():
|
||||||
if (child.current_streak or 0) < 2:
|
if (child.current_streak or 0) < 2:
|
||||||
@@ -628,6 +631,7 @@ class NotificationCoordinator:
|
|||||||
def _make_custom_callback(self, custom_id: str):
|
def _make_custom_callback(self, custom_id: str):
|
||||||
async def _cb(now):
|
async def _cb(now):
|
||||||
from homeassistant.util import dt as dt_util
|
from homeassistant.util import dt as dt_util
|
||||||
|
|
||||||
n = next(
|
n = next(
|
||||||
(c for c in self.storage.get_custom_notifications() if c.id == custom_id),
|
(c for c in self.storage.get_custom_notifications() if c.id == custom_id),
|
||||||
None,
|
None,
|
||||||
@@ -659,7 +663,8 @@ class NotificationCoordinator:
|
|||||||
message = n.message_template
|
message = n.message_template
|
||||||
service_name = notify_service.split(".", 1)[1] if "." in notify_service else notify_service
|
service_name = notify_service.split(".", 1)[1] if "." in notify_service else notify_service
|
||||||
await self.hass.services.async_call(
|
await self.hass.services.async_call(
|
||||||
"notify", service_name,
|
"notify",
|
||||||
|
service_name,
|
||||||
{"title": "TaskMate", "message": message},
|
{"title": "TaskMate", "message": message},
|
||||||
blocking=False,
|
blocking=False,
|
||||||
)
|
)
|
||||||
@@ -667,6 +672,7 @@ class NotificationCoordinator:
|
|||||||
"taskmate_custom_notification",
|
"taskmate_custom_notification",
|
||||||
{"id": n.id, "name": n.name, "recipients": n.recipient_ids},
|
{"id": n.id, "name": n.name, "recipients": n.recipient_ids},
|
||||||
)
|
)
|
||||||
|
|
||||||
return _cb
|
return _cb
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -702,9 +708,7 @@ class NotificationCoordinator:
|
|||||||
if self.storage.get_notification_config(meta.id).routes:
|
if self.storage.get_notification_config(meta.id).routes:
|
||||||
continue # already configured — leave it alone
|
continue # already configured — leave it alone
|
||||||
for p in parents:
|
for p in parents:
|
||||||
self.storage.set_notification_route(
|
self.storage.set_notification_route(meta.id, p.id, NotificationRoute(enabled=True))
|
||||||
meta.id, p.id, NotificationRoute(enabled=True)
|
|
||||||
)
|
|
||||||
changed = True
|
changed = True
|
||||||
return changed
|
return changed
|
||||||
|
|
||||||
@@ -750,13 +754,14 @@ class NotificationCoordinator:
|
|||||||
"""Returns True if the child has at least one chore assigned today
|
"""Returns True if the child has at least one chore assigned today
|
||||||
that has no approved/pending completion yet."""
|
that has no approved/pending completion yet."""
|
||||||
from homeassistant.util import dt as dt_util
|
from homeassistant.util import dt as dt_util
|
||||||
|
|
||||||
today = dt_util.now().date()
|
today = dt_util.now().date()
|
||||||
chores = self.storage.get_chores()
|
chores = self.storage.get_chores()
|
||||||
completions = self.storage.get_completions()
|
completions = self.storage.get_completions()
|
||||||
completed_today = {
|
completed_today = {
|
||||||
c.chore_id for c in completions
|
c.chore_id
|
||||||
if c.child_id == child_id
|
for c in completions
|
||||||
and dt_util.as_local(c.completed_at).date() == today
|
if c.child_id == child_id and dt_util.as_local(c.completed_at).date() == today
|
||||||
}
|
}
|
||||||
for chore in chores:
|
for chore in chores:
|
||||||
if not chore.assigned_to or child_id not in chore.assigned_to:
|
if not chore.assigned_to or child_id not in chore.assigned_to:
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Points operations mixin for TaskMateCoordinator."""
|
"""Points operations mixin for TaskMateCoordinator."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -72,7 +73,7 @@ class PointsMixin:
|
|||||||
return req
|
return req
|
||||||
|
|
||||||
for child in children:
|
for child in children:
|
||||||
awarded_weeks = list(getattr(child, 'awarded_perfect_weeks', None) or [])
|
awarded_weeks = list(getattr(child, "awarded_perfect_weeks", None) or [])
|
||||||
|
|
||||||
# Skip if already awarded for this week
|
# Skip if already awarded for this week
|
||||||
if week_key in awarded_weeks:
|
if week_key in awarded_weeks:
|
||||||
@@ -100,9 +101,7 @@ class PointsMixin:
|
|||||||
# counts only when EVERY chore due that day was done — not just one.
|
# counts only when EVERY chore due that day was done — not just one.
|
||||||
if all_mode and derived_dates:
|
if all_mode and derived_dates:
|
||||||
satisfied = all(
|
satisfied = all(
|
||||||
self._all_due_chores_done(
|
self._all_due_chores_done(child.id, date.fromisoformat(d), include_rotation=False)
|
||||||
child.id, date.fromisoformat(d), include_rotation=False
|
|
||||||
)
|
|
||||||
for d in derived_dates
|
for d in derived_dates
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -113,9 +112,7 @@ class PointsMixin:
|
|||||||
child.points += perfect_week_bonus
|
child.points += perfect_week_bonus
|
||||||
child.total_points_earned += perfect_week_bonus
|
child.total_points_earned += perfect_week_bonus
|
||||||
child.career_score = child.total_points_earned - child.total_penalties_received
|
child.career_score = child.total_points_earned - child.total_penalties_received
|
||||||
self.storage.append_career_score_snapshot(
|
self.storage.append_career_score_snapshot(child.id, today.isoformat(), child.career_score)
|
||||||
child.id, today.isoformat(), child.career_score
|
|
||||||
)
|
|
||||||
self.storage.update_child(child)
|
self.storage.update_child(child)
|
||||||
|
|
||||||
transaction = PointsTransaction(
|
transaction = PointsTransaction(
|
||||||
@@ -140,13 +137,17 @@ class PointsMixin:
|
|||||||
if getattr(self, "badges", None):
|
if getattr(self, "badges", None):
|
||||||
await self.badges.evaluate_for_child(child.id, "perfect_week")
|
await self.badges.evaluate_for_child(child.id, "perfect_week")
|
||||||
await self._celebrate(
|
await self._celebrate(
|
||||||
child, "perfect_week",
|
child,
|
||||||
|
"perfect_week",
|
||||||
f"{child.name} earned a perfect week — +{perfect_week_bonus}!",
|
f"{child.name} earned a perfect week — +{perfect_week_bonus}!",
|
||||||
tier=3, extra={"bonus": perfect_week_bonus},
|
tier=3,
|
||||||
|
extra={"bonus": perfect_week_bonus},
|
||||||
)
|
)
|
||||||
_LOGGER.info(
|
_LOGGER.info(
|
||||||
"Perfect week bonus (%d pts) awarded to %s for week of %s",
|
"Perfect week bonus (%d pts) awarded to %s for week of %s",
|
||||||
perfect_week_bonus, child.name, week_key,
|
perfect_week_bonus,
|
||||||
|
child.name,
|
||||||
|
week_key,
|
||||||
)
|
)
|
||||||
|
|
||||||
if changed:
|
if changed:
|
||||||
@@ -213,17 +214,13 @@ class PointsMixin:
|
|||||||
if streak_mode == "pause" or getattr(child, "streak_paused", False):
|
if streak_mode == "pause" or getattr(child, "streak_paused", False):
|
||||||
child.streak_paused = True
|
child.streak_paused = True
|
||||||
_LOGGER.info(
|
_LOGGER.info(
|
||||||
"Streak paused for %s (last completion: %s, mode=%s)",
|
"Streak paused for %s (last completion: %s, mode=%s)", child.name, last_date_str, streak_mode
|
||||||
child.name, last_date_str, streak_mode
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Default: reset to 0
|
# Default: reset to 0
|
||||||
child.current_streak = 0
|
child.current_streak = 0
|
||||||
child.streak_paused = False
|
child.streak_paused = False
|
||||||
_LOGGER.info(
|
_LOGGER.info("Streak reset for %s (last completion: %s, mode=reset)", child.name, last_date_str)
|
||||||
"Streak reset for %s (last completion: %s, mode=reset)",
|
|
||||||
child.name, last_date_str
|
|
||||||
)
|
|
||||||
self.storage.update_child(child)
|
self.storage.update_child(child)
|
||||||
changed = True
|
changed = True
|
||||||
|
|
||||||
@@ -243,9 +240,7 @@ class PointsMixin:
|
|||||||
child.career_score = child.total_points_earned - child.total_penalties_received
|
child.career_score = child.total_points_earned - child.total_penalties_received
|
||||||
await self._maybe_level_up(child)
|
await self._maybe_level_up(child)
|
||||||
self.storage.update_child(child)
|
self.storage.update_child(child)
|
||||||
self.storage.append_career_score_snapshot(
|
self.storage.append_career_score_snapshot(child_id, date.today().isoformat(), child.career_score)
|
||||||
child_id, date.today().isoformat(), child.career_score
|
|
||||||
)
|
|
||||||
# Log the manual transaction
|
# Log the manual transaction
|
||||||
transaction = PointsTransaction(
|
transaction = PointsTransaction(
|
||||||
child_id=child_id,
|
child_id=child_id,
|
||||||
@@ -279,9 +274,7 @@ class PointsMixin:
|
|||||||
if reason.startswith("Penalty: "):
|
if reason.startswith("Penalty: "):
|
||||||
child.total_penalties_received += actual_deducted
|
child.total_penalties_received += actual_deducted
|
||||||
child.career_score = child.total_points_earned - child.total_penalties_received
|
child.career_score = child.total_points_earned - child.total_penalties_received
|
||||||
self.storage.append_career_score_snapshot(
|
self.storage.append_career_score_snapshot(child_id, date.today().isoformat(), child.career_score)
|
||||||
child_id, date.today().isoformat(), child.career_score
|
|
||||||
)
|
|
||||||
self.storage.update_child(child)
|
self.storage.update_child(child)
|
||||||
# Log the manual transaction (negative points)
|
# Log the manual transaction (negative points)
|
||||||
transaction = PointsTransaction(
|
transaction = PointsTransaction(
|
||||||
@@ -338,9 +331,7 @@ class PointsMixin:
|
|||||||
if reason.startswith("Gift to ") or reason.startswith("Gift from "):
|
if reason.startswith("Gift to ") or reason.startswith("Gift from "):
|
||||||
link_id = getattr(target, "link_id", "") or ""
|
link_id = getattr(target, "link_id", "") or ""
|
||||||
if not link_id:
|
if not link_id:
|
||||||
raise ValueError(
|
raise ValueError("This gift predates undo support and can't be reversed automatically.")
|
||||||
"This gift predates undo support and can't be reversed automatically."
|
|
||||||
)
|
|
||||||
for leg in [t for t in txns if (getattr(t, "link_id", "") or "") == link_id]:
|
for leg in [t for t in txns if (getattr(t, "link_id", "") or "") == link_id]:
|
||||||
leg_child = self.get_child(leg.child_id)
|
leg_child = self.get_child(leg.child_id)
|
||||||
if leg_child:
|
if leg_child:
|
||||||
@@ -368,9 +359,7 @@ class PointsMixin:
|
|||||||
# points, so nothing else to reverse.
|
# points, so nothing else to reverse.
|
||||||
child.career_score = child.total_points_earned - child.total_penalties_received
|
child.career_score = child.total_points_earned - child.total_penalties_received
|
||||||
self.storage.update_child(child)
|
self.storage.update_child(child)
|
||||||
self.storage.append_career_score_snapshot(
|
self.storage.append_career_score_snapshot(child.id, dt_util.now().date().isoformat(), child.career_score)
|
||||||
child.id, dt_util.now().date().isoformat(), child.career_score
|
|
||||||
)
|
|
||||||
self.storage.remove_points_transaction(transaction_id)
|
self.storage.remove_points_transaction(transaction_id)
|
||||||
await self.storage.async_save()
|
await self.storage.async_save()
|
||||||
await self.async_refresh()
|
await self.async_refresh()
|
||||||
@@ -394,9 +383,7 @@ class PointsMixin:
|
|||||||
lvl = xp // step + 1
|
lvl = xp // step + 1
|
||||||
return {"level": lvl, "progress": xp - (lvl - 1) * step, "target": step}
|
return {"level": lvl, "progress": xp - (lvl - 1) * step, "target": step}
|
||||||
|
|
||||||
async def _celebrate(
|
async def _celebrate(self, child, kind: str, message: str, tier: int = 1, extra: dict | None = None) -> None:
|
||||||
self, child, kind: str, message: str, tier: int = 1, extra: dict | None = None
|
|
||||||
) -> None:
|
|
||||||
"""Central celebration funnel for notable moments.
|
"""Central celebration funnel for notable moments.
|
||||||
|
|
||||||
Always fires a single ``taskmate_celebration`` event carrying a ``tier``
|
Always fires a single ``taskmate_celebration`` event carrying a ``tier``
|
||||||
@@ -452,17 +439,30 @@ class PointsMixin:
|
|||||||
if new < old:
|
if new < old:
|
||||||
return # earned total dropped (e.g. undo); resync quietly
|
return # earned total dropped (e.g. undo); resync quietly
|
||||||
for lvl in range(old + 1, new + 1):
|
for lvl in range(old + 1, new + 1):
|
||||||
self.hass.bus.async_fire("taskmate_level_up", {
|
self.hass.bus.async_fire(
|
||||||
"child_id": child.id, "child_name": child.name,
|
"taskmate_level_up",
|
||||||
"level": lvl, "timestamp": dt_util.now().isoformat(),
|
{
|
||||||
})
|
"child_id": child.id,
|
||||||
|
"child_name": child.name,
|
||||||
|
"level": lvl,
|
||||||
|
"timestamp": dt_util.now().isoformat(),
|
||||||
|
},
|
||||||
|
)
|
||||||
if getattr(self, "notifications", None):
|
if getattr(self, "notifications", None):
|
||||||
await self.notifications.fire("level_up", {
|
await self.notifications.fire(
|
||||||
"child_name": child.name, "child_id": child.id, "level": lvl,
|
"level_up",
|
||||||
})
|
{
|
||||||
|
"child_name": child.name,
|
||||||
|
"child_id": child.id,
|
||||||
|
"level": lvl,
|
||||||
|
},
|
||||||
|
)
|
||||||
await self._celebrate(
|
await self._celebrate(
|
||||||
child, "level_up", f"{child.name} reached level {lvl}!",
|
child,
|
||||||
tier=3 if lvl % 5 == 0 else 2, extra={"level": lvl},
|
"level_up",
|
||||||
|
f"{child.name} reached level {lvl}!",
|
||||||
|
tier=3 if lvl % 5 == 0 else 2,
|
||||||
|
extra={"level": lvl},
|
||||||
)
|
)
|
||||||
|
|
||||||
async def async_gift_points(self, from_child_id: str, to_child_id: str, points: int) -> None:
|
async def async_gift_points(self, from_child_id: str, to_child_id: str, points: int) -> None:
|
||||||
@@ -481,9 +481,7 @@ class PointsMixin:
|
|||||||
if not sender or not recipient:
|
if not sender or not recipient:
|
||||||
raise ValueError("Sender or recipient not found")
|
raise ValueError("Sender or recipient not found")
|
||||||
if (sender.points or 0) < points:
|
if (sender.points or 0) < points:
|
||||||
raise ValueError(
|
raise ValueError(f"Not enough points: {sender.name} has {sender.points}, gift {points}")
|
||||||
f"Not enough points: {sender.name} has {sender.points}, gift {points}"
|
|
||||||
)
|
|
||||||
now = dt_util.now()
|
now = dt_util.now()
|
||||||
sender.points -= points
|
sender.points -= points
|
||||||
recipient.points += points
|
recipient.points += points
|
||||||
@@ -491,19 +489,35 @@ class PointsMixin:
|
|||||||
self.storage.update_child(recipient)
|
self.storage.update_child(recipient)
|
||||||
# Shared link_id so undo can reverse both legs together.
|
# Shared link_id so undo can reverse both legs together.
|
||||||
gift_link = generate_id()
|
gift_link = generate_id()
|
||||||
self.storage.add_points_transaction(PointsTransaction(
|
self.storage.add_points_transaction(
|
||||||
child_id=sender.id, points=-points,
|
PointsTransaction(
|
||||||
reason=f"Gift to {recipient.name}", created_at=now, link_id=gift_link,
|
child_id=sender.id,
|
||||||
))
|
points=-points,
|
||||||
self.storage.add_points_transaction(PointsTransaction(
|
reason=f"Gift to {recipient.name}",
|
||||||
child_id=recipient.id, points=points,
|
created_at=now,
|
||||||
reason=f"Gift from {sender.name}", created_at=now, link_id=gift_link,
|
link_id=gift_link,
|
||||||
))
|
)
|
||||||
self.hass.bus.async_fire("taskmate_points_gifted", {
|
)
|
||||||
"from_child_id": sender.id, "from_child_name": sender.name,
|
self.storage.add_points_transaction(
|
||||||
"to_child_id": recipient.id, "to_child_name": recipient.name,
|
PointsTransaction(
|
||||||
"points": points, "timestamp": now.isoformat(),
|
child_id=recipient.id,
|
||||||
})
|
points=points,
|
||||||
|
reason=f"Gift from {sender.name}",
|
||||||
|
created_at=now,
|
||||||
|
link_id=gift_link,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.hass.bus.async_fire(
|
||||||
|
"taskmate_points_gifted",
|
||||||
|
{
|
||||||
|
"from_child_id": sender.id,
|
||||||
|
"from_child_name": sender.name,
|
||||||
|
"to_child_id": recipient.id,
|
||||||
|
"to_child_name": recipient.name,
|
||||||
|
"points": points,
|
||||||
|
"timestamp": now.isoformat(),
|
||||||
|
},
|
||||||
|
)
|
||||||
await self.storage.async_save()
|
await self.storage.async_save()
|
||||||
await self.async_refresh()
|
await self.async_refresh()
|
||||||
|
|
||||||
@@ -526,10 +540,7 @@ class PointsMixin:
|
|||||||
return
|
return
|
||||||
period = self.storage.get_setting("points_decay_period", "monthly")
|
period = self.storage.get_setting("points_decay_period", "monthly")
|
||||||
today = dt_util.now().date()
|
today = dt_util.now().date()
|
||||||
due = (
|
due = (period == "weekly" and today.weekday() == 0) or (period == "monthly" and today.day == 1)
|
||||||
(period == "weekly" and today.weekday() == 0)
|
|
||||||
or (period == "monthly" and today.day == 1)
|
|
||||||
)
|
|
||||||
if not due:
|
if not due:
|
||||||
return
|
return
|
||||||
if self.storage.get_setting("points_decay_last", "") == today.isoformat():
|
if self.storage.get_setting("points_decay_last", "") == today.isoformat():
|
||||||
@@ -544,14 +555,23 @@ class PointsMixin:
|
|||||||
continue
|
continue
|
||||||
child.points = max(0, child.points - loss)
|
child.points = max(0, child.points - loss)
|
||||||
self.storage.update_child(child)
|
self.storage.update_child(child)
|
||||||
self.storage.add_points_transaction(PointsTransaction(
|
self.storage.add_points_transaction(
|
||||||
child_id=child.id, points=-loss,
|
PointsTransaction(
|
||||||
reason=f"Points decay (-{pct:.0f}%)", created_at=now,
|
child_id=child.id,
|
||||||
))
|
points=-loss,
|
||||||
self.hass.bus.async_fire("taskmate_points_decay", {
|
reason=f"Points decay (-{pct:.0f}%)",
|
||||||
"child_id": child.id, "child_name": child.name,
|
created_at=now,
|
||||||
"points": loss, "timestamp": now.isoformat(),
|
)
|
||||||
})
|
)
|
||||||
|
self.hass.bus.async_fire(
|
||||||
|
"taskmate_points_decay",
|
||||||
|
{
|
||||||
|
"child_id": child.id,
|
||||||
|
"child_name": child.name,
|
||||||
|
"points": loss,
|
||||||
|
"timestamp": now.isoformat(),
|
||||||
|
},
|
||||||
|
)
|
||||||
changed = True
|
changed = True
|
||||||
self.storage.set_setting("points_decay_last", today.isoformat())
|
self.storage.set_setting("points_decay_last", today.isoformat())
|
||||||
if changed:
|
if changed:
|
||||||
@@ -576,10 +596,7 @@ class PointsMixin:
|
|||||||
return
|
return
|
||||||
period = self.storage.get_setting("interest_period", "weekly")
|
period = self.storage.get_setting("interest_period", "weekly")
|
||||||
today = dt_util.now().date()
|
today = dt_util.now().date()
|
||||||
due = (
|
due = (period == "weekly" and today.weekday() == 0) or (period == "monthly" and today.day == 1)
|
||||||
(period == "weekly" and today.weekday() == 0)
|
|
||||||
or (period == "monthly" and today.day == 1)
|
|
||||||
)
|
|
||||||
if not due:
|
if not due:
|
||||||
return
|
return
|
||||||
if self.storage.get_setting("interest_last", "") == today.isoformat():
|
if self.storage.get_setting("interest_last", "") == today.isoformat():
|
||||||
@@ -592,10 +609,15 @@ class PointsMixin:
|
|||||||
if interest <= 0:
|
if interest <= 0:
|
||||||
continue
|
continue
|
||||||
await self.async_add_points(child.id, interest, reason=f"Savings interest (+{pct:.0f}%)")
|
await self.async_add_points(child.id, interest, reason=f"Savings interest (+{pct:.0f}%)")
|
||||||
self.hass.bus.async_fire("taskmate_interest_paid", {
|
self.hass.bus.async_fire(
|
||||||
"child_id": child.id, "child_name": child.name,
|
"taskmate_interest_paid",
|
||||||
"points": interest, "timestamp": dt_util.now().isoformat(),
|
{
|
||||||
})
|
"child_id": child.id,
|
||||||
|
"child_name": child.name,
|
||||||
|
"points": interest,
|
||||||
|
"timestamp": dt_util.now().isoformat(),
|
||||||
|
},
|
||||||
|
)
|
||||||
self.storage.set_setting("interest_last", today.isoformat())
|
self.storage.set_setting("interest_last", today.isoformat())
|
||||||
await self.storage.async_save()
|
await self.storage.async_save()
|
||||||
|
|
||||||
@@ -613,17 +635,13 @@ class PointsMixin:
|
|||||||
if not part:
|
if not part:
|
||||||
continue
|
continue
|
||||||
if ":" not in part:
|
if ":" not in part:
|
||||||
raise ValueError(
|
raise ValueError(f"Invalid format '{part}' — use 'days:points' pairs, e.g. '7:10, 14:20'")
|
||||||
f"Invalid format '{part}' — use 'days:points' pairs, e.g. '7:10, 14:20'"
|
|
||||||
)
|
|
||||||
days_str, points_str = part.split(":", 1)
|
days_str, points_str = part.split(":", 1)
|
||||||
try:
|
try:
|
||||||
days = int(days_str.strip())
|
days = int(days_str.strip())
|
||||||
points = int(points_str.strip())
|
points = int(points_str.strip())
|
||||||
except ValueError as err:
|
except ValueError as err:
|
||||||
raise ValueError(
|
raise ValueError(f"Invalid numbers in '{part}' — days and points must be whole numbers") from err
|
||||||
f"Invalid numbers in '{part}' — days and points must be whole numbers"
|
|
||||||
) from err
|
|
||||||
if days < 1:
|
if days < 1:
|
||||||
raise ValueError(f"Days must be at least 1, got {days}")
|
raise ValueError(f"Days must be at least 1, got {days}")
|
||||||
if points < 1:
|
if points < 1:
|
||||||
@@ -718,7 +736,7 @@ class PointsMixin:
|
|||||||
today = now.date()
|
today = now.date()
|
||||||
effective_date = completion_date or today
|
effective_date = completion_date or today
|
||||||
effective_date_str = effective_date.isoformat()
|
effective_date_str = effective_date.isoformat()
|
||||||
last_date_str = getattr(child, 'last_completion_date', None)
|
last_date_str = getattr(child, "last_completion_date", None)
|
||||||
|
|
||||||
# ── Weekend multiplier ──────────────────────────────────────────────
|
# ── Weekend multiplier ──────────────────────────────────────────────
|
||||||
# Applied to base chore points only, based on completion date
|
# Applied to base chore points only, based on completion date
|
||||||
@@ -740,7 +758,10 @@ class PointsMixin:
|
|||||||
if weekend_bonus > 0:
|
if weekend_bonus > 0:
|
||||||
_LOGGER.info(
|
_LOGGER.info(
|
||||||
"Weekend multiplier (%.1fx) applied for %s: +%d bonus on top of %d",
|
"Weekend multiplier (%.1fx) applied for %s: +%d bonus on top of %d",
|
||||||
multiplier, child.name, weekend_bonus, points,
|
multiplier,
|
||||||
|
child.name,
|
||||||
|
weekend_bonus,
|
||||||
|
points,
|
||||||
)
|
)
|
||||||
# Log weekend bonus as a separate transaction for activity history
|
# Log weekend bonus as a separate transaction for activity history
|
||||||
transaction = PointsTransaction(
|
transaction = PointsTransaction(
|
||||||
@@ -758,9 +779,7 @@ class PointsMixin:
|
|||||||
# day is done. The in-flight completion (chore_id) is counted as done
|
# day is done. The in-flight completion (chore_id) is counted as done
|
||||||
# since it may not be persisted yet at this point in the flow.
|
# since it may not be persisted yet at this point in the flow.
|
||||||
if advance_streak and self._setting_enabled("streak_requires_all_chores"):
|
if advance_streak and self._setting_enabled("streak_requires_all_chores"):
|
||||||
if not self._all_due_chores_done(
|
if not self._all_due_chores_done(child.id, effective_date, include_rotation=True, extra_done=chore_id):
|
||||||
child.id, effective_date, include_rotation=True, extra_done=chore_id
|
|
||||||
):
|
|
||||||
advance_streak = False
|
advance_streak = False
|
||||||
if advance_streak:
|
if advance_streak:
|
||||||
streak_mode = self.storage.get_setting("streak_reset_mode", "reset")
|
streak_mode = self.storage.get_setting("streak_reset_mode", "reset")
|
||||||
@@ -815,9 +834,7 @@ class PointsMixin:
|
|||||||
milestones_enabled = self.storage.get_setting("streak_milestones_enabled", "true") == "true"
|
milestones_enabled = self.storage.get_setting("streak_milestones_enabled", "true") == "true"
|
||||||
if advance_streak and milestones_enabled and child.current_streak > 0:
|
if advance_streak and milestones_enabled and child.current_streak > 0:
|
||||||
# Parse custom milestone config
|
# Parse custom milestone config
|
||||||
milestone_setting = self.storage.get_setting(
|
milestone_setting = self.storage.get_setting("streak_milestones", self.DEFAULT_STREAK_MILESTONES)
|
||||||
"streak_milestones", self.DEFAULT_STREAK_MILESTONES
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
milestones = self.parse_milestone_setting(milestone_setting)
|
milestones = self.parse_milestone_setting(milestone_setting)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -836,7 +853,9 @@ class PointsMixin:
|
|||||||
reached_milestones.append((days, bonus_pts))
|
reached_milestones.append((days, bonus_pts))
|
||||||
_LOGGER.info(
|
_LOGGER.info(
|
||||||
"Streak milestone %d days reached for %s: +%d bonus",
|
"Streak milestone %d days reached for %s: +%d bonus",
|
||||||
days, child.name, bonus_pts,
|
days,
|
||||||
|
child.name,
|
||||||
|
bonus_pts,
|
||||||
)
|
)
|
||||||
|
|
||||||
child.streak_milestones_achieved = sorted(achieved)
|
child.streak_milestones_achieved = sorted(achieved)
|
||||||
@@ -853,9 +872,7 @@ class PointsMixin:
|
|||||||
)
|
)
|
||||||
self.storage.add_points_transaction(transaction)
|
self.storage.add_points_transaction(transaction)
|
||||||
|
|
||||||
self.storage.append_career_score_snapshot(
|
self.storage.append_career_score_snapshot(child.id, effective_date.isoformat(), child.career_score)
|
||||||
child.id, effective_date.isoformat(), child.career_score
|
|
||||||
)
|
|
||||||
await self._maybe_level_up(child)
|
await self._maybe_level_up(child)
|
||||||
self.storage.update_child(child)
|
self.storage.update_child(child)
|
||||||
|
|
||||||
@@ -877,9 +894,11 @@ class PointsMixin:
|
|||||||
# A big streak is a celebration moment too — epic at 30+ days.
|
# A big streak is a celebration moment too — epic at 30+ days.
|
||||||
for days, _bonus_pts in reached_milestones:
|
for days, _bonus_pts in reached_milestones:
|
||||||
await self._celebrate(
|
await self._celebrate(
|
||||||
child, "streak_milestone",
|
child,
|
||||||
|
"streak_milestone",
|
||||||
f"{child.name} hit a {days}-day streak!",
|
f"{child.name} hit a {days}-day streak!",
|
||||||
tier=3 if days >= 30 else 2, extra={"days": days},
|
tier=3 if days >= 30 else 2,
|
||||||
|
extra={"days": days},
|
||||||
)
|
)
|
||||||
return total_points
|
return total_points
|
||||||
|
|
||||||
@@ -890,10 +909,7 @@ class PointsMixin:
|
|||||||
before = len(all_completions)
|
before = len(all_completions)
|
||||||
|
|
||||||
# Keep completions newer than cutoff OR unapproved (pending)
|
# Keep completions newer than cutoff OR unapproved (pending)
|
||||||
to_keep = [
|
to_keep = [c for c in all_completions if c.completed_at >= cutoff or not c.approved]
|
||||||
c for c in all_completions
|
|
||||||
if c.completed_at >= cutoff or not c.approved
|
|
||||||
]
|
|
||||||
|
|
||||||
if len(to_keep) < before:
|
if len(to_keep) < before:
|
||||||
kept_ids = {c.id for c in to_keep}
|
kept_ids = {c.id for c in to_keep}
|
||||||
@@ -906,10 +922,7 @@ class PointsMixin:
|
|||||||
if c.id not in kept_ids and getattr(c, "photo_url", ""):
|
if c.id not in kept_ids and getattr(c, "photo_url", ""):
|
||||||
await photos.async_delete_photo(self.hass, c.photo_url)
|
await photos.async_delete_photo(self.hass, c.photo_url)
|
||||||
await self.async_refresh()
|
await self.async_refresh()
|
||||||
_LOGGER.info(
|
_LOGGER.info("Pruned %d completions older than %d days", before - len(to_keep), days)
|
||||||
"Pruned %d completions older than %d days",
|
|
||||||
before - len(to_keep), days
|
|
||||||
)
|
|
||||||
|
|
||||||
# Penalty operations
|
# Penalty operations
|
||||||
async def async_add_penalty(
|
async def async_add_penalty(
|
||||||
@@ -954,12 +967,17 @@ class PointsMixin:
|
|||||||
if not child:
|
if not child:
|
||||||
raise ValueError(f"Child {child_id} not found")
|
raise ValueError(f"Child {child_id} not found")
|
||||||
await self.async_remove_points(child_id, penalty.points, reason=f"Penalty: {penalty.name}")
|
await self.async_remove_points(child_id, penalty.points, reason=f"Penalty: {penalty.name}")
|
||||||
self.hass.bus.async_fire("taskmate_penalty_applied", {
|
self.hass.bus.async_fire(
|
||||||
"child_id": child.id, "child_name": child.name,
|
"taskmate_penalty_applied",
|
||||||
"penalty_id": penalty.id, "penalty_name": penalty.name,
|
{
|
||||||
"points": penalty.points,
|
"child_id": child.id,
|
||||||
"timestamp": dt_util.now().isoformat(),
|
"child_name": child.name,
|
||||||
})
|
"penalty_id": penalty.id,
|
||||||
|
"penalty_name": penalty.name,
|
||||||
|
"points": penalty.points,
|
||||||
|
"timestamp": dt_util.now().isoformat(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
# Bonus operations
|
# Bonus operations
|
||||||
async def async_add_bonus(
|
async def async_add_bonus(
|
||||||
@@ -1004,16 +1022,25 @@ class PointsMixin:
|
|||||||
if not child:
|
if not child:
|
||||||
raise ValueError(f"Child {child_id} not found")
|
raise ValueError(f"Child {child_id} not found")
|
||||||
await self.async_add_points(child_id, bonus.points, reason=f"Bonus: {bonus.name}")
|
await self.async_add_points(child_id, bonus.points, reason=f"Bonus: {bonus.name}")
|
||||||
self.hass.bus.async_fire("taskmate_bonus_applied", {
|
self.hass.bus.async_fire(
|
||||||
"child_id": child.id, "child_name": child.name,
|
"taskmate_bonus_applied",
|
||||||
"bonus_id": bonus.id, "bonus_name": bonus.name,
|
{
|
||||||
"points": bonus.points,
|
"child_id": child.id,
|
||||||
"timestamp": dt_util.now().isoformat(),
|
"child_name": child.name,
|
||||||
})
|
"bonus_id": bonus.id,
|
||||||
|
"bonus_name": bonus.name,
|
||||||
|
"points": bonus.points,
|
||||||
|
"timestamp": dt_util.now().isoformat(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
async def _async_notify_pending_approval(
|
async def _async_notify_pending_approval(
|
||||||
self, child_name: str, chore_name: str, points: int,
|
self,
|
||||||
completion_id: str | None = None, photo_url: str = "",
|
child_name: str,
|
||||||
|
chore_name: str,
|
||||||
|
points: int,
|
||||||
|
completion_id: str | None = None,
|
||||||
|
photo_url: str = "",
|
||||||
) -> None:
|
) -> None:
|
||||||
await self.notifications.fire(
|
await self.notifications.fire(
|
||||||
"pending_chore_approval",
|
"pending_chore_approval",
|
||||||
@@ -1030,7 +1057,10 @@ class PointsMixin:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def _async_notify_pending_reward_claim(
|
async def _async_notify_pending_reward_claim(
|
||||||
self, child_name: str, reward_name: str, cost: int,
|
self,
|
||||||
|
child_name: str,
|
||||||
|
reward_name: str,
|
||||||
|
cost: int,
|
||||||
claim_id: str | None = None,
|
claim_id: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
await self.notifications.fire(
|
await self.notifications.fire(
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ current step advances them. Finishing the final step awards the quest's bonus
|
|||||||
points, fires a ``taskmate_quest_completed`` event + celebration, and either
|
points, fires a ``taskmate_quest_completed`` event + celebration, and either
|
||||||
resets progress (repeatable quests) or marks the quest complete for that child.
|
resets progress (repeatable quests) or marks the quest complete for that child.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -72,17 +73,19 @@ class QuestsMixin:
|
|||||||
step = int(prog.get("step", 0))
|
step = int(prog.get("step", 0))
|
||||||
total = len(quest.steps)
|
total = len(quest.steps)
|
||||||
done = step >= total
|
done = step >= total
|
||||||
out.append({
|
out.append(
|
||||||
"quest_id": quest.id,
|
{
|
||||||
"name": quest.name,
|
"quest_id": quest.id,
|
||||||
"icon": quest.icon,
|
"name": quest.name,
|
||||||
"total_steps": total,
|
"icon": quest.icon,
|
||||||
"step": min(step, total),
|
"total_steps": total,
|
||||||
"done": done,
|
"step": min(step, total),
|
||||||
"times_completed": int(prog.get("completed_count", 0)),
|
"done": done,
|
||||||
"bonus_points": quest.bonus_points,
|
"times_completed": int(prog.get("completed_count", 0)),
|
||||||
"next_chore_id": quest.steps[step] if not done and step < total else "",
|
"bonus_points": quest.bonus_points,
|
||||||
})
|
"next_chore_id": quest.steps[step] if not done and step < total else "",
|
||||||
|
}
|
||||||
|
)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
# ── Progression ──────────────────────────────────────────────────────
|
# ── Progression ──────────────────────────────────────────────────────
|
||||||
@@ -129,24 +132,36 @@ class QuestsMixin:
|
|||||||
child.points += bonus
|
child.points += bonus
|
||||||
child.total_points_earned += bonus
|
child.total_points_earned += bonus
|
||||||
child.career_score = child.total_points_earned - child.total_penalties_received
|
child.career_score = child.total_points_earned - child.total_penalties_received
|
||||||
self.storage.add_points_transaction(PointsTransaction(
|
self.storage.add_points_transaction(
|
||||||
child_id=child.id, points=bonus,
|
PointsTransaction(
|
||||||
reason=f"Quest complete: {quest.name}", created_at=dt_util.now(),
|
child_id=child.id,
|
||||||
))
|
points=bonus,
|
||||||
|
reason=f"Quest complete: {quest.name}",
|
||||||
|
created_at=dt_util.now(),
|
||||||
|
)
|
||||||
|
)
|
||||||
if hasattr(self, "_maybe_level_up"):
|
if hasattr(self, "_maybe_level_up"):
|
||||||
await self._maybe_level_up(child)
|
await self._maybe_level_up(child)
|
||||||
self.storage.update_child(child)
|
self.storage.update_child(child)
|
||||||
|
|
||||||
self.hass.bus.async_fire("taskmate_quest_completed", {
|
self.hass.bus.async_fire(
|
||||||
"child_id": child.id, "child_name": child.name,
|
"taskmate_quest_completed",
|
||||||
"quest_id": quest.id, "quest_name": quest.name,
|
{
|
||||||
"bonus": bonus, "timestamp": dt_util.now().isoformat(),
|
"child_id": child.id,
|
||||||
})
|
"child_name": child.name,
|
||||||
|
"quest_id": quest.id,
|
||||||
|
"quest_name": quest.name,
|
||||||
|
"bonus": bonus,
|
||||||
|
"timestamp": dt_util.now().isoformat(),
|
||||||
|
},
|
||||||
|
)
|
||||||
if hasattr(self, "_celebrate"):
|
if hasattr(self, "_celebrate"):
|
||||||
await self._celebrate(
|
await self._celebrate(
|
||||||
child, "quest_completed",
|
child,
|
||||||
|
"quest_completed",
|
||||||
f"{child.name} completed the quest '{quest.name}'!",
|
f"{child.name} completed the quest '{quest.name}'!",
|
||||||
tier=3, extra={"quest_id": quest.id, "bonus": bonus},
|
tier=3,
|
||||||
|
extra={"quest_id": quest.id, "bonus": bonus},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Repeatable quests start over; one-shot quests stay complete.
|
# Repeatable quests start over; one-shot quests stay complete.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Reward operations mixin for TaskMateCoordinator."""
|
"""Reward operations mixin for TaskMateCoordinator."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -82,15 +83,11 @@ class RewardsMixin:
|
|||||||
if old and reward.cost < old.cost:
|
if old and reward.cost < old.cost:
|
||||||
self._refund_pool_excess(reward, "Pool refund (reward cost reduced)")
|
self._refund_pool_excess(reward, "Pool refund (reward cost reduced)")
|
||||||
became_unavailable = (
|
became_unavailable = (
|
||||||
self._reward_is_unavailable(reward)
|
self._reward_is_unavailable(reward) and old is not None and not self._reward_is_unavailable(old)
|
||||||
and old is not None
|
|
||||||
and not self._reward_is_unavailable(old)
|
|
||||||
)
|
)
|
||||||
if became_unavailable:
|
if became_unavailable:
|
||||||
reason = (
|
reason = (
|
||||||
"Pool refund (reward expired)"
|
"Pool refund (reward expired)" if self._reward_is_expired(reward) else "Pool refund (reward sold out)"
|
||||||
if self._reward_is_expired(reward)
|
|
||||||
else "Pool refund (reward sold out)"
|
|
||||||
)
|
)
|
||||||
self._refund_all_pool_allocations(reward, reason)
|
self._refund_all_pool_allocations(reward, reason)
|
||||||
await self.storage.async_save()
|
await self.storage.async_save()
|
||||||
@@ -140,8 +137,7 @@ class RewardsMixin:
|
|||||||
stays consistent with cost-reduction refunds.
|
stays consistent with cost-reduction refunds.
|
||||||
"""
|
"""
|
||||||
allocations = [
|
allocations = [
|
||||||
a for a in self.storage.get_pool_allocations()
|
a for a in self.storage.get_pool_allocations() if a.reward_id == reward.id and a.allocated_points > 0
|
||||||
if a.reward_id == reward.id and a.allocated_points > 0
|
|
||||||
]
|
]
|
||||||
for alloc in allocations:
|
for alloc in allocations:
|
||||||
self._apply_pool_refund(alloc, alloc.allocated_points, reward, reason)
|
self._apply_pool_refund(alloc, alloc.allocated_points, reward, reason)
|
||||||
@@ -154,8 +150,7 @@ class RewardsMixin:
|
|||||||
until the combined total matches the cost.
|
until the combined total matches the cost.
|
||||||
"""
|
"""
|
||||||
allocations = [
|
allocations = [
|
||||||
a for a in self.storage.get_pool_allocations()
|
a for a in self.storage.get_pool_allocations() if a.reward_id == reward.id and a.allocated_points > 0
|
||||||
if a.reward_id == reward.id and a.allocated_points > 0
|
|
||||||
]
|
]
|
||||||
if not allocations:
|
if not allocations:
|
||||||
return
|
return
|
||||||
@@ -173,13 +168,9 @@ class RewardsMixin:
|
|||||||
else:
|
else:
|
||||||
for alloc in allocations:
|
for alloc in allocations:
|
||||||
if alloc.allocated_points > reward.cost:
|
if alloc.allocated_points > reward.cost:
|
||||||
self._apply_pool_refund(
|
self._apply_pool_refund(alloc, alloc.allocated_points - reward.cost, reward, reason)
|
||||||
alloc, alloc.allocated_points - reward.cost, reward, reason
|
|
||||||
)
|
|
||||||
|
|
||||||
def _apply_pool_refund(
|
def _apply_pool_refund(self, allocation: PoolAllocation, refund: int, reward: Reward, reason: str) -> None:
|
||||||
self, allocation: PoolAllocation, refund: int, reward: Reward, reason: str
|
|
||||||
) -> None:
|
|
||||||
"""Refund `refund` points from `allocation` back to the child's wallet.
|
"""Refund `refund` points from `allocation` back to the child's wallet.
|
||||||
|
|
||||||
Updates or removes the allocation record and writes an audit transaction.
|
Updates or removes the allocation record and writes an audit transaction.
|
||||||
@@ -196,19 +187,23 @@ class RewardsMixin:
|
|||||||
if remaining <= 0:
|
if remaining <= 0:
|
||||||
self.storage.remove_pool_allocation(allocation.child_id, allocation.reward_id)
|
self.storage.remove_pool_allocation(allocation.child_id, allocation.reward_id)
|
||||||
else:
|
else:
|
||||||
self.storage.upsert_pool_allocation(PoolAllocation(
|
self.storage.upsert_pool_allocation(
|
||||||
child_id=allocation.child_id,
|
PoolAllocation(
|
||||||
reward_id=allocation.reward_id,
|
child_id=allocation.child_id,
|
||||||
allocated_points=remaining,
|
reward_id=allocation.reward_id,
|
||||||
id=allocation.id,
|
allocated_points=remaining,
|
||||||
))
|
id=allocation.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
self.storage.add_points_transaction(PointsTransaction(
|
self.storage.add_points_transaction(
|
||||||
child_id=allocation.child_id,
|
PointsTransaction(
|
||||||
points=refund,
|
child_id=allocation.child_id,
|
||||||
reason=f"{reason}: {reward.name}",
|
points=refund,
|
||||||
created_at=dt_util.now(),
|
reason=f"{reason}: {reward.name}",
|
||||||
))
|
created_at=dt_util.now(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
async def async_claim_reward(self, reward_id: str, child_id: str) -> RewardClaim:
|
async def async_claim_reward(self, reward_id: str, child_id: str) -> RewardClaim:
|
||||||
"""Child claims a reward — creates a pending claim awaiting parent approval.
|
"""Child claims a reward — creates a pending claim awaiting parent approval.
|
||||||
@@ -261,9 +256,7 @@ class RewardsMixin:
|
|||||||
available_points = child.points - committed
|
available_points = child.points - committed
|
||||||
|
|
||||||
if available_points < effective_cost:
|
if available_points < effective_cost:
|
||||||
raise ValueError(
|
raise ValueError(f"Not enough points. Need {effective_cost}, have {available_points} available")
|
||||||
f"Not enough points. Need {effective_cost}, have {available_points} available"
|
|
||||||
)
|
|
||||||
|
|
||||||
claim = RewardClaim(
|
claim = RewardClaim(
|
||||||
reward_id=reward_id,
|
reward_id=reward_id,
|
||||||
@@ -287,7 +280,10 @@ class RewardsMixin:
|
|||||||
await self.storage.async_save()
|
await self.storage.async_save()
|
||||||
await self.async_refresh()
|
await self.async_refresh()
|
||||||
await self._async_notify_pending_reward_claim(
|
await self._async_notify_pending_reward_claim(
|
||||||
child.name, reward.name, reward.cost, claim_id=claim.id,
|
child.name,
|
||||||
|
reward.name,
|
||||||
|
reward.cost,
|
||||||
|
claim_id=claim.id,
|
||||||
)
|
)
|
||||||
return claim
|
return claim
|
||||||
|
|
||||||
@@ -297,6 +293,7 @@ class RewardsMixin:
|
|||||||
if period == "monthly":
|
if period == "monthly":
|
||||||
return today.replace(day=1)
|
return today.replace(day=1)
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
return today - timedelta(days=today.weekday()) # Monday of this week
|
return today - timedelta(days=today.weekday()) # Monday of this week
|
||||||
|
|
||||||
def _spent_in_period(self, child_id: str) -> int:
|
def _spent_in_period(self, child_id: str) -> int:
|
||||||
@@ -324,9 +321,7 @@ class RewardsMixin:
|
|||||||
if cap <= 0:
|
if cap <= 0:
|
||||||
return
|
return
|
||||||
if self._spent_in_period(child_id) + cost > cap:
|
if self._spent_in_period(child_id) + cost > cap:
|
||||||
raise ValueError(
|
raise ValueError(f"Spending cap reached: {cap} per period already used")
|
||||||
f"Spending cap reached: {cap} per period already used"
|
|
||||||
)
|
|
||||||
|
|
||||||
async def async_approve_reward(self, claim_id: str) -> None:
|
async def async_approve_reward(self, claim_id: str) -> None:
|
||||||
"""Approve a reward claim and deduct points from the child.
|
"""Approve a reward claim and deduct points from the child.
|
||||||
@@ -371,7 +366,8 @@ class RewardsMixin:
|
|||||||
self._refund_pool_excess(reward, "Pool refund on redeem")
|
self._refund_pool_excess(reward, "Pool refund on redeem")
|
||||||
if reward.is_jackpot:
|
if reward.is_jackpot:
|
||||||
jackpot_allocs = [
|
jackpot_allocs = [
|
||||||
a for a in self.storage.get_pool_allocations()
|
a
|
||||||
|
for a in self.storage.get_pool_allocations()
|
||||||
if a.reward_id == claim.reward_id and a.allocated_points > 0
|
if a.reward_id == claim.reward_id and a.allocated_points > 0
|
||||||
]
|
]
|
||||||
for alloc in jackpot_allocs:
|
for alloc in jackpot_allocs:
|
||||||
@@ -381,9 +377,7 @@ class RewardsMixin:
|
|||||||
else:
|
else:
|
||||||
# Wallet mode: deduct directly from child.points
|
# Wallet mode: deduct directly from child.points
|
||||||
if child.points < effective_cost:
|
if child.points < effective_cost:
|
||||||
raise ValueError(
|
raise ValueError(f"Not enough points to approve. Need {effective_cost}, have {child.points}")
|
||||||
f"Not enough points to approve. Need {effective_cost}, have {child.points}"
|
|
||||||
)
|
|
||||||
child.points -= effective_cost
|
child.points -= effective_cost
|
||||||
self.storage.update_child(child)
|
self.storage.update_child(child)
|
||||||
|
|
||||||
@@ -393,9 +387,7 @@ class RewardsMixin:
|
|||||||
if reward.quantity == 0:
|
if reward.quantity == 0:
|
||||||
# Last unit claimed — refund any points other children
|
# Last unit claimed — refund any points other children
|
||||||
# still have earmarked for this reward's pool.
|
# still have earmarked for this reward's pool.
|
||||||
self._refund_all_pool_allocations(
|
self._refund_all_pool_allocations(reward, "Pool refund (reward sold out)")
|
||||||
reward, "Pool refund (reward sold out)"
|
|
||||||
)
|
|
||||||
|
|
||||||
claim.approved = True
|
claim.approved = True
|
||||||
claim.approved_at = dt_util.now()
|
claim.approved_at = dt_util.now()
|
||||||
@@ -405,20 +397,23 @@ class RewardsMixin:
|
|||||||
|
|
||||||
# Dismiss the mobile approval push now this claim is reviewed.
|
# Dismiss the mobile approval push now this claim is reviewed.
|
||||||
if getattr(self, "notifications", None):
|
if getattr(self, "notifications", None):
|
||||||
await self.notifications.clear_approval(
|
await self.notifications.clear_approval("pending_reward_claim", claim_id)
|
||||||
"pending_reward_claim", claim_id
|
|
||||||
)
|
|
||||||
|
|
||||||
# Timed unlock (#678): allowlisted entity on, auto-off later.
|
# Timed unlock (#678): allowlisted entity on, auto-off later.
|
||||||
await self.async_start_unlock(reward, child)
|
await self.async_start_unlock(reward, child)
|
||||||
|
|
||||||
self.hass.bus.async_fire("taskmate_reward_approved", {
|
self.hass.bus.async_fire(
|
||||||
"child_id": child.id, "child_name": child.name,
|
"taskmate_reward_approved",
|
||||||
"reward_id": reward.id, "reward_name": reward.name,
|
{
|
||||||
"claim_id": claim.id,
|
"child_id": child.id,
|
||||||
"cost": effective_cost,
|
"child_name": child.name,
|
||||||
"timestamp": dt_util.now().isoformat(),
|
"reward_id": reward.id,
|
||||||
})
|
"reward_name": reward.name,
|
||||||
|
"claim_id": claim.id,
|
||||||
|
"cost": effective_cost,
|
||||||
|
"timestamp": dt_util.now().isoformat(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
if getattr(self, "badges", None):
|
if getattr(self, "badges", None):
|
||||||
await self.badges.evaluate_for_child(claim.child_id, "reward_redeemed")
|
await self.badges.evaluate_for_child(claim.child_id, "reward_redeemed")
|
||||||
@@ -435,23 +430,22 @@ class RewardsMixin:
|
|||||||
if claim:
|
if claim:
|
||||||
reward = self.get_reward(claim.reward_id)
|
reward = self.get_reward(claim.reward_id)
|
||||||
child = self.get_child(claim.child_id)
|
child = self.get_child(claim.child_id)
|
||||||
self.hass.bus.async_fire("taskmate_reward_rejected", {
|
self.hass.bus.async_fire(
|
||||||
"child_id": claim.child_id,
|
"taskmate_reward_rejected",
|
||||||
"child_name": getattr(child, "name", ""),
|
{
|
||||||
"reward_id": claim.reward_id,
|
"child_id": claim.child_id,
|
||||||
"reward_name": getattr(reward, "name", ""),
|
"child_name": getattr(child, "name", ""),
|
||||||
"claim_id": claim.id,
|
"reward_id": claim.reward_id,
|
||||||
"timestamp": dt_util.now().isoformat(),
|
"reward_name": getattr(reward, "name", ""),
|
||||||
})
|
"claim_id": claim.id,
|
||||||
|
"timestamp": dt_util.now().isoformat(),
|
||||||
|
},
|
||||||
|
)
|
||||||
# Dismiss the mobile approval push for this reviewed claim.
|
# Dismiss the mobile approval push for this reviewed claim.
|
||||||
if getattr(self, "notifications", None):
|
if getattr(self, "notifications", None):
|
||||||
await self.notifications.clear_approval(
|
await self.notifications.clear_approval("pending_reward_claim", claim_id)
|
||||||
"pending_reward_claim", claim_id
|
|
||||||
)
|
|
||||||
|
|
||||||
async def async_allocate_points_to_pool(
|
async def async_allocate_points_to_pool(self, child_id: str, reward_id: str, points: int) -> PoolAllocation:
|
||||||
self, child_id: str, reward_id: str, points: int
|
|
||||||
) -> PoolAllocation:
|
|
||||||
"""Move `points` from a child's spendable balance into a reward pool.
|
"""Move `points` from a child's spendable balance into a reward pool.
|
||||||
|
|
||||||
Deducts immediately from child.points so the visible balance reflects the
|
Deducts immediately from child.points so the visible balance reflects the
|
||||||
@@ -539,6 +533,7 @@ class RewardsMixin:
|
|||||||
``restock_last`` stamp guards against restocking twice in a day.
|
``restock_last`` stamp guards against restocking twice in a day.
|
||||||
"""
|
"""
|
||||||
from homeassistant.util import dt as dt_util
|
from homeassistant.util import dt as dt_util
|
||||||
|
|
||||||
today = dt_util.now().date()
|
today = dt_util.now().date()
|
||||||
today_iso = today.isoformat()
|
today_iso = today.isoformat()
|
||||||
changed = False
|
changed = False
|
||||||
@@ -577,8 +572,7 @@ class RewardsMixin:
|
|||||||
if not self._reward_is_expired(reward):
|
if not self._reward_is_expired(reward):
|
||||||
continue
|
continue
|
||||||
allocations_before = [
|
allocations_before = [
|
||||||
a for a in self.storage.get_pool_allocations()
|
a for a in self.storage.get_pool_allocations() if a.reward_id == reward.id and a.allocated_points > 0
|
||||||
if a.reward_id == reward.id and a.allocated_points > 0
|
|
||||||
]
|
]
|
||||||
if not allocations_before:
|
if not allocations_before:
|
||||||
continue
|
continue
|
||||||
@@ -586,7 +580,9 @@ class RewardsMixin:
|
|||||||
changed = True
|
changed = True
|
||||||
_LOGGER.info(
|
_LOGGER.info(
|
||||||
"Reward '%s' expired on %s — refunded %d pool allocation(s)",
|
"Reward '%s' expired on %s — refunded %d pool allocation(s)",
|
||||||
reward.name, reward.expires_at, len(allocations_before),
|
reward.name,
|
||||||
|
reward.expires_at,
|
||||||
|
len(allocations_before),
|
||||||
)
|
)
|
||||||
|
|
||||||
if changed:
|
if changed:
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Template operations mixin for TaskMateCoordinator."""
|
"""Template operations mixin for TaskMateCoordinator."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -70,9 +71,7 @@ class TemplatesMixin:
|
|||||||
await self.async_refresh()
|
await self.async_refresh()
|
||||||
return created_ids
|
return created_ids
|
||||||
|
|
||||||
async def async_save_template_from_chores(
|
async def async_save_template_from_chores(self, chore_ids: list[str], name: str, icon: str) -> str:
|
||||||
self, chore_ids: list[str], name: str, icon: str
|
|
||||||
) -> str:
|
|
||||||
"""Save existing chores as a custom template pack."""
|
"""Save existing chores as a custom template pack."""
|
||||||
if not chore_ids:
|
if not chore_ids:
|
||||||
raise ValueError("At least one chore must be selected")
|
raise ValueError("At least one chore must be selected")
|
||||||
@@ -97,9 +96,7 @@ class TemplatesMixin:
|
|||||||
await self.storage.async_save()
|
await self.storage.async_save()
|
||||||
return tpl_id
|
return tpl_id
|
||||||
|
|
||||||
async def async_create_template(
|
async def async_create_template(self, name: str, icon: str, chores: list[dict]) -> str:
|
||||||
self, name: str, icon: str, chores: list[dict]
|
|
||||||
) -> str:
|
|
||||||
"""Create a new custom template from scratch."""
|
"""Create a new custom template from scratch."""
|
||||||
if not chores:
|
if not chores:
|
||||||
raise ValueError("Template must have at least one chore")
|
raise ValueError("Template must have at least one chore")
|
||||||
@@ -156,16 +153,19 @@ class TemplatesMixin:
|
|||||||
for tpl in self.storage.get_custom_templates():
|
for tpl in self.storage.get_custom_templates():
|
||||||
if wanted and tpl.get("id") not in wanted:
|
if wanted and tpl.get("id") not in wanted:
|
||||||
continue
|
continue
|
||||||
packed.append({
|
packed.append(
|
||||||
"name": tpl.get("name", ""),
|
{
|
||||||
"icon": tpl.get("icon", "mdi:clipboard-list-outline"),
|
"name": tpl.get("name", ""),
|
||||||
"chores": [
|
"icon": tpl.get("icon", "mdi:clipboard-list-outline"),
|
||||||
{k: v for k, v in chore.items() if k in TEMPLATE_CHORE_FIELDS}
|
"chores": [
|
||||||
for chore in tpl.get("chores", [])
|
{k: v for k, v in chore.items() if k in TEMPLATE_CHORE_FIELDS}
|
||||||
],
|
for chore in tpl.get("chores", [])
|
||||||
})
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
from homeassistant.util import dt as dt_util
|
from homeassistant.util import dt as dt_util
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"format": self.PACK_FORMAT,
|
"format": self.PACK_FORMAT,
|
||||||
"version": self.PACK_VERSION,
|
"version": self.PACK_VERSION,
|
||||||
@@ -191,8 +191,7 @@ class TemplatesMixin:
|
|||||||
raise ValueError("Pack version is not a number") from err
|
raise ValueError("Pack version is not a number") from err
|
||||||
if version > self.PACK_VERSION:
|
if version > self.PACK_VERSION:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"This pack needs a newer TaskMate (pack version {version}, "
|
f"This pack needs a newer TaskMate (pack version {version}, this one understands {self.PACK_VERSION})"
|
||||||
f"this one understands {self.PACK_VERSION})"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
templates = pack.get("templates")
|
templates = pack.get("templates")
|
||||||
@@ -227,11 +226,13 @@ class TemplatesMixin:
|
|||||||
cleaned["name"] = chore_name[:200]
|
cleaned["name"] = chore_name[:200]
|
||||||
chores.append(cleaned)
|
chores.append(cleaned)
|
||||||
|
|
||||||
clean.append({
|
clean.append(
|
||||||
"name": name[:120],
|
{
|
||||||
"icon": str(entry.get("icon", "") or "mdi:clipboard-list-outline"),
|
"name": name[:120],
|
||||||
"chores": chores,
|
"icon": str(entry.get("icon", "") or "mdi:clipboard-list-outline"),
|
||||||
})
|
"chores": chores,
|
||||||
|
}
|
||||||
|
)
|
||||||
return clean
|
return clean
|
||||||
|
|
||||||
async def async_import_pack(self, pack: dict) -> dict:
|
async def async_import_pack(self, pack: dict) -> dict:
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Timed task operations mixin for TaskMateCoordinator."""
|
"""Timed task operations mixin for TaskMateCoordinator."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -41,9 +42,7 @@ class TimedMixin:
|
|||||||
# Check daily cap before resuming
|
# Check daily cap before resuming
|
||||||
if chore.timed_max_daily_minutes > 0:
|
if chore.timed_max_daily_minutes > 0:
|
||||||
if existing.total_seconds_today >= chore.timed_max_daily_minutes * 60:
|
if existing.total_seconds_today >= chore.timed_max_daily_minutes * 60:
|
||||||
raise ValueError(
|
raise ValueError(f"Daily cap reached ({chore.timed_max_daily_minutes} min)")
|
||||||
f"Daily cap reached ({chore.timed_max_daily_minutes} min)"
|
|
||||||
)
|
|
||||||
existing.state = "running"
|
existing.state = "running"
|
||||||
existing.segments.append({"start": now.isoformat(), "end": None})
|
existing.segments.append({"start": now.isoformat(), "end": None})
|
||||||
self.storage.save_timed_session(existing)
|
self.storage.save_timed_session(existing)
|
||||||
@@ -52,9 +51,7 @@ class TimedMixin:
|
|||||||
if chore.timed_max_daily_minutes > 0:
|
if chore.timed_max_daily_minutes > 0:
|
||||||
old_session = self.storage.get_timed_session(chore_id, child_id, today)
|
old_session = self.storage.get_timed_session(chore_id, child_id, today)
|
||||||
if old_session and old_session.total_seconds_today >= chore.timed_max_daily_minutes * 60:
|
if old_session and old_session.total_seconds_today >= chore.timed_max_daily_minutes * 60:
|
||||||
raise ValueError(
|
raise ValueError(f"Daily cap reached ({chore.timed_max_daily_minutes} min)")
|
||||||
f"Daily cap reached ({chore.timed_max_daily_minutes} min)"
|
|
||||||
)
|
|
||||||
session = TimedSession(
|
session = TimedSession(
|
||||||
chore_id=chore_id,
|
chore_id=chore_id,
|
||||||
child_id=child_id,
|
child_id=child_id,
|
||||||
@@ -139,7 +136,10 @@ class TimedMixin:
|
|||||||
|
|
||||||
if chore.requires_approval:
|
if chore.requires_approval:
|
||||||
await self._async_notify_pending_approval(
|
await self._async_notify_pending_approval(
|
||||||
child.name, chore.name, pts, completion_id=completion.id,
|
child.name,
|
||||||
|
chore.name,
|
||||||
|
pts,
|
||||||
|
completion_id=completion.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
await self.async_refresh()
|
await self.async_refresh()
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Data coordinator for TaskMate integration."""
|
"""Data coordinator for TaskMate integration."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -107,11 +108,7 @@ class TaskMateCoordinator(
|
|||||||
resolved = tier if tier in DEFAULT_DIFFICULTY_MULTIPLIERS else DEFAULT_DIFFICULTY
|
resolved = tier if tier in DEFAULT_DIFFICULTY_MULTIPLIERS else DEFAULT_DIFFICULTY
|
||||||
default = DEFAULT_DIFFICULTY_MULTIPLIERS[resolved]
|
default = DEFAULT_DIFFICULTY_MULTIPLIERS[resolved]
|
||||||
try:
|
try:
|
||||||
return float(
|
return float(self.storage.get_setting(f"difficulty_multiplier_{resolved}", str(default)))
|
||||||
self.storage.get_setting(
|
|
||||||
f"difficulty_multiplier_{resolved}", str(default)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return default
|
return default
|
||||||
|
|
||||||
@@ -145,12 +142,14 @@ class TaskMateCoordinator(
|
|||||||
continue
|
continue
|
||||||
if end < start:
|
if end < start:
|
||||||
start, end = end, start
|
start, end = end, start
|
||||||
periods.append({
|
periods.append(
|
||||||
"id": str(entry.get("id") or "").strip() or start.isoformat(),
|
{
|
||||||
"name": str(entry.get("name") or "").strip(),
|
"id": str(entry.get("id") or "").strip() or start.isoformat(),
|
||||||
"start": start.isoformat(),
|
"name": str(entry.get("name") or "").strip(),
|
||||||
"end": end.isoformat(),
|
"start": start.isoformat(),
|
||||||
})
|
"end": end.isoformat(),
|
||||||
|
}
|
||||||
|
)
|
||||||
return sorted(periods, key=lambda p: p["start"])
|
return sorted(periods, key=lambda p: p["start"])
|
||||||
|
|
||||||
def active_vacation(self, on: date | None = None) -> dict | None:
|
def active_vacation(self, on: date | None = None) -> dict | None:
|
||||||
@@ -253,20 +252,20 @@ class TaskMateCoordinator(
|
|||||||
await self.async_refresh()
|
await self.async_refresh()
|
||||||
|
|
||||||
# ── Admin audit log ──────────────────────────────────────────────────
|
# ── Admin audit log ──────────────────────────────────────────────────
|
||||||
async def async_record_audit(
|
async def async_record_audit(self, user_id: str, user_name: str, action: str, target: str = "") -> None:
|
||||||
self, user_id: str, user_name: str, action: str, target: str = ""
|
|
||||||
) -> None:
|
|
||||||
"""Record an admin config action in the audit log and persist it."""
|
"""Record an admin config action in the audit log and persist it."""
|
||||||
from .models import generate_id
|
from .models import generate_id
|
||||||
|
|
||||||
self.storage.add_audit_entry({
|
self.storage.add_audit_entry(
|
||||||
"id": generate_id(),
|
{
|
||||||
"ts": dt_util.now().isoformat(),
|
"id": generate_id(),
|
||||||
"user_id": user_id or "",
|
"ts": dt_util.now().isoformat(),
|
||||||
"user_name": user_name or "",
|
"user_id": user_id or "",
|
||||||
"action": action,
|
"user_name": user_name or "",
|
||||||
"target": target or "",
|
"action": action,
|
||||||
})
|
"target": target or "",
|
||||||
|
}
|
||||||
|
)
|
||||||
await self.storage.async_save()
|
await self.storage.async_save()
|
||||||
|
|
||||||
async def async_initialize(self) -> None:
|
async def async_initialize(self) -> None:
|
||||||
@@ -296,16 +295,12 @@ class TaskMateCoordinator(
|
|||||||
self.hass, self._async_midnight_streak_check, hour=0, minute=0, second=5
|
self.hass, self._async_midnight_streak_check, hour=0, minute=0, second=5
|
||||||
)
|
)
|
||||||
# Schedule daily history pruning at 00:01:00
|
# Schedule daily history pruning at 00:01:00
|
||||||
self._unsub_prune = async_track_time_change(
|
self._unsub_prune = async_track_time_change(self.hass, self._async_scheduled_prune, hour=0, minute=1, second=0)
|
||||||
self.hass, self._async_scheduled_prune, hour=0, minute=1, second=0
|
|
||||||
)
|
|
||||||
# Re-evaluate availability-aware chore assignments when any HA entity
|
# Re-evaluate availability-aware chore assignments when any HA entity
|
||||||
# state changes. The callback filters cheaply on entity id so only
|
# state changes. The callback filters cheaply on entity id so only
|
||||||
# relevant flips trigger a recompute.
|
# relevant flips trigger a recompute.
|
||||||
self._refresh_tracked_availability_entities()
|
self._refresh_tracked_availability_entities()
|
||||||
self._unsub_availability = self.hass.bus.async_listen(
|
self._unsub_availability = self.hass.bus.async_listen("state_changed", self._availability_state_changed)
|
||||||
"state_changed", self._availability_state_changed
|
|
||||||
)
|
|
||||||
# Surprise-bonus daily roll at 16:00 (opt-in; no-op unless enabled)
|
# Surprise-bonus daily roll at 16:00 (opt-in; no-op unless enabled)
|
||||||
self._unsub_surprise = async_track_time_change(
|
self._unsub_surprise = async_track_time_change(
|
||||||
self.hass, self._async_surprise_bonus_check, hour=16, minute=0, second=0
|
self.hass, self._async_surprise_bonus_check, hour=16, minute=0, second=0
|
||||||
@@ -353,10 +348,7 @@ class TaskMateCoordinator(
|
|||||||
done[comp.child_id] = done.get(comp.child_id, 0) + 1
|
done[comp.child_id] = done.get(comp.child_id, 0) + 1
|
||||||
earned[comp.child_id] = earned.get(comp.child_id, 0) + (comp.points_awarded or 0)
|
earned[comp.child_id] = earned.get(comp.child_id, 0) + (comp.points_awarded or 0)
|
||||||
pts = self.storage.get_points_name()
|
pts = self.storage.get_points_name()
|
||||||
lines = [
|
lines = [f"• {c.name}: {done.get(c.id, 0)} chores, {earned.get(c.id, 0)} {pts} earned" for c in children]
|
||||||
f"• {c.name}: {done.get(c.id, 0)} chores, {earned.get(c.id, 0)} {pts} earned"
|
|
||||||
for c in children
|
|
||||||
]
|
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
async def _async_send_monthly_report(self) -> None:
|
async def _async_send_monthly_report(self) -> None:
|
||||||
@@ -367,10 +359,13 @@ class TaskMateCoordinator(
|
|||||||
summary = self._build_monthly_report(month_start, month_end)
|
summary = self._build_monthly_report(month_start, month_end)
|
||||||
if not summary:
|
if not summary:
|
||||||
return
|
return
|
||||||
await self.notifications.fire("monthly_report", {
|
await self.notifications.fire(
|
||||||
"summary": summary,
|
"monthly_report",
|
||||||
"month": month_start.strftime("%B %Y"),
|
{
|
||||||
})
|
"summary": summary,
|
||||||
|
"month": month_start.strftime("%B %Y"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
def _build_monthly_report(self, month_start: date, month_end: date) -> str:
|
def _build_monthly_report(self, month_start: date, month_end: date) -> str:
|
||||||
"""Per-child recap for [month_start, month_end]: chores, points, level, best streak."""
|
"""Per-child recap for [month_start, month_end]: chores, points, level, best streak."""
|
||||||
@@ -403,8 +398,7 @@ class TaskMateCoordinator(
|
|||||||
ym = dt_util.now().strftime("%Y-%m")
|
ym = dt_util.now().strftime("%Y-%m")
|
||||||
pts = self.storage.get_season_points(ym)
|
pts = self.storage.get_season_points(ym)
|
||||||
rows = [
|
rows = [
|
||||||
{"child_id": c.id, "name": c.name, "points": int(pts.get(c.id, 0))}
|
{"child_id": c.id, "name": c.name, "points": int(pts.get(c.id, 0))} for c in self.storage.get_children()
|
||||||
for c in self.storage.get_children()
|
|
||||||
]
|
]
|
||||||
rows.sort(key=lambda r: (-r["points"], r["name"].lower()))
|
rows.sort(key=lambda r: (-r["points"], r["name"].lower()))
|
||||||
for i, r in enumerate(rows):
|
for i, r in enumerate(rows):
|
||||||
@@ -434,13 +428,22 @@ class TaskMateCoordinator(
|
|||||||
await self.storage.async_save()
|
await self.storage.async_save()
|
||||||
name = str(self.storage.get_setting("family_goal_name", "") or "Family goal")
|
name = str(self.storage.get_setting("family_goal_name", "") or "Family goal")
|
||||||
reward = str(self.storage.get_setting("family_goal_reward", "") or "a treat")
|
reward = str(self.storage.get_setting("family_goal_reward", "") or "a treat")
|
||||||
self.hass.bus.async_fire("taskmate_family_goal_reached", {
|
self.hass.bus.async_fire(
|
||||||
"goal_name": name, "goal_reward": reward, "target": target,
|
"taskmate_family_goal_reached",
|
||||||
"timestamp": dt_util.now().isoformat(),
|
{
|
||||||
})
|
"goal_name": name,
|
||||||
await self.notifications.fire("family_goal_reached", {
|
"goal_reward": reward,
|
||||||
"goal_name": name, "goal_reward": reward,
|
"target": target,
|
||||||
})
|
"timestamp": dt_util.now().isoformat(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await self.notifications.fire(
|
||||||
|
"family_goal_reached",
|
||||||
|
{
|
||||||
|
"goal_name": name,
|
||||||
|
"goal_reward": reward,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
# ── Allowance payout ledger (FEAT-3) ─────────────────────────────────
|
# ── Allowance payout ledger (FEAT-3) ─────────────────────────────────
|
||||||
async def async_record_allowance_payout(self, child_id: str, points: int) -> dict:
|
async def async_record_allowance_payout(self, child_id: str, points: int) -> dict:
|
||||||
@@ -469,6 +472,7 @@ class TaskMateCoordinator(
|
|||||||
await self.async_remove_points(child_id, points, reason="Allowance payout")
|
await self.async_remove_points(child_id, points, reason="Allowance payout")
|
||||||
|
|
||||||
from .models import generate_id
|
from .models import generate_id
|
||||||
|
|
||||||
entry = {
|
entry = {
|
||||||
"id": generate_id(),
|
"id": generate_id(),
|
||||||
"child_id": child_id,
|
"child_id": child_id,
|
||||||
@@ -490,6 +494,7 @@ class TaskMateCoordinator(
|
|||||||
token = self.storage.get_setting("ics_token", "")
|
token = self.storage.get_setting("ics_token", "")
|
||||||
if not token:
|
if not token:
|
||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
token = secrets.token_urlsafe(24)
|
token = secrets.token_urlsafe(24)
|
||||||
self.storage.set_setting("ics_token", token)
|
self.storage.set_setting("ics_token", token)
|
||||||
await self.storage.async_save()
|
await self.storage.async_save()
|
||||||
@@ -498,6 +503,7 @@ class TaskMateCoordinator(
|
|||||||
async def async_regenerate_ics_token(self) -> str:
|
async def async_regenerate_ics_token(self) -> str:
|
||||||
"""Rotate the ICS feed token (invalidates existing subscriptions)."""
|
"""Rotate the ICS feed token (invalidates existing subscriptions)."""
|
||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
token = secrets.token_urlsafe(24)
|
token = secrets.token_urlsafe(24)
|
||||||
self.storage.set_setting("ics_token", token)
|
self.storage.set_setting("ics_token", token)
|
||||||
await self.storage.async_save()
|
await self.storage.async_save()
|
||||||
@@ -514,22 +520,34 @@ class TaskMateCoordinator(
|
|||||||
if not winners:
|
if not winners:
|
||||||
return
|
return
|
||||||
top = winners[0]
|
top = winners[0]
|
||||||
self.storage.add_season_champion({
|
self.storage.add_season_champion(
|
||||||
"month": ym,
|
{
|
||||||
"child_id": top["child_id"],
|
"month": ym,
|
||||||
"child_name": top["name"],
|
"child_id": top["child_id"],
|
||||||
"points": top["points"],
|
"child_name": top["name"],
|
||||||
})
|
"points": top["points"],
|
||||||
|
}
|
||||||
|
)
|
||||||
await self.storage.async_save()
|
await self.storage.async_save()
|
||||||
self.hass.bus.async_fire("taskmate_season_champion", {
|
self.hass.bus.async_fire(
|
||||||
"month": ym, "child_id": top["child_id"], "child_name": top["name"],
|
"taskmate_season_champion",
|
||||||
"points": top["points"], "timestamp": now.isoformat(),
|
{
|
||||||
})
|
"month": ym,
|
||||||
await self.notifications.fire("season_champion", {
|
"child_id": top["child_id"],
|
||||||
"child_name": top["name"], "points": top["points"],
|
"child_name": top["name"],
|
||||||
"month": prev_end.strftime("%B %Y"),
|
"points": top["points"],
|
||||||
"points_name": self.storage.get_points_name(),
|
"timestamp": now.isoformat(),
|
||||||
})
|
},
|
||||||
|
)
|
||||||
|
await self.notifications.fire(
|
||||||
|
"season_champion",
|
||||||
|
{
|
||||||
|
"child_name": top["name"],
|
||||||
|
"points": top["points"],
|
||||||
|
"month": prev_end.strftime("%B %Y"),
|
||||||
|
"points_name": self.storage.get_points_name(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _async_surprise_bonus_check(self, now: datetime) -> None:
|
def _async_surprise_bonus_check(self, now: datetime) -> None:
|
||||||
@@ -565,10 +583,15 @@ class TaskMateCoordinator(
|
|||||||
if pts <= 0:
|
if pts <= 0:
|
||||||
continue
|
continue
|
||||||
await self.async_add_points(child.id, pts, reason="Surprise bonus 🎉")
|
await self.async_add_points(child.id, pts, reason="Surprise bonus 🎉")
|
||||||
self.hass.bus.async_fire("taskmate_surprise_bonus", {
|
self.hass.bus.async_fire(
|
||||||
"child_id": child.id, "child_name": child.name,
|
"taskmate_surprise_bonus",
|
||||||
"points": pts, "timestamp": dt_util.now().isoformat(),
|
{
|
||||||
})
|
"child_id": child.id,
|
||||||
|
"child_name": child.name,
|
||||||
|
"points": pts,
|
||||||
|
"timestamp": dt_util.now().isoformat(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
async def _async_backfill_career_history(self) -> None:
|
async def _async_backfill_career_history(self) -> None:
|
||||||
"""Backfill career_score_history from completions and transactions.
|
"""Backfill career_score_history from completions and transactions.
|
||||||
@@ -618,13 +641,12 @@ class TaskMateCoordinator(
|
|||||||
running = start_score
|
running = start_score
|
||||||
for day in sorted_days:
|
for day in sorted_days:
|
||||||
running += daily_net[day]
|
running += daily_net[day]
|
||||||
self.storage.append_career_score_snapshot(
|
self.storage.append_career_score_snapshot(child.id, day, running)
|
||||||
child.id, day, running
|
|
||||||
)
|
|
||||||
needs_save = True
|
needs_save = True
|
||||||
_LOGGER.info(
|
_LOGGER.info(
|
||||||
"Backfilled %d career history entries for %s",
|
"Backfilled %d career history entries for %s",
|
||||||
len(sorted_days), child.name,
|
len(sorted_days),
|
||||||
|
child.name,
|
||||||
)
|
)
|
||||||
|
|
||||||
if needs_save:
|
if needs_save:
|
||||||
@@ -701,9 +723,7 @@ class TaskMateCoordinator(
|
|||||||
async def _async_sweep_orphan_photos(self) -> None:
|
async def _async_sweep_orphan_photos(self) -> None:
|
||||||
"""Delete evidence photos not referenced by any completion (SEC-2)."""
|
"""Delete evidence photos not referenced by any completion (SEC-2)."""
|
||||||
referenced = [
|
referenced = [
|
||||||
getattr(c, "photo_url", "")
|
getattr(c, "photo_url", "") for c in self.storage.get_completions() if getattr(c, "photo_url", "")
|
||||||
for c in self.storage.get_completions()
|
|
||||||
if getattr(c, "photo_url", "")
|
|
||||||
]
|
]
|
||||||
removed = await photos.async_sweep_orphan_photos(self.hass, referenced)
|
removed = await photos.async_sweep_orphan_photos(self.hass, referenced)
|
||||||
if removed:
|
if removed:
|
||||||
@@ -804,10 +824,34 @@ class TaskMateCoordinator(
|
|||||||
self.storage.remove_career_score_history_for_child(child_id)
|
self.storage.remove_career_score_history_for_child(child_id)
|
||||||
self.storage.remove_quest_progress_for_child(child_id)
|
self.storage.remove_quest_progress_for_child(child_id)
|
||||||
self.storage.remove_challenge_progress_for_child(child_id)
|
self.storage.remove_challenge_progress_for_child(child_id)
|
||||||
# Remove child from chore assigned_to lists
|
# Drop pending swap requests either side of this child (#785) — a
|
||||||
|
# handover to or from a deleted child can never complete, and the
|
||||||
|
# approval queue would render them as "?".
|
||||||
|
self.storage.remove_swap_requests_for_child(child_id)
|
||||||
|
# Remove child from chore assigned_to lists, and clear any approved swap
|
||||||
|
# override that pointed at them so the chore isn't left assigned to a
|
||||||
|
# child who no longer exists.
|
||||||
for chore in self.storage.get_chores():
|
for chore in self.storage.get_chores():
|
||||||
|
dirty = False
|
||||||
if child_id in chore.assigned_to:
|
if child_id in chore.assigned_to:
|
||||||
chore.assigned_to.remove(child_id)
|
chore.assigned_to.remove(child_id)
|
||||||
|
dirty = True
|
||||||
|
if getattr(chore, "assignment_swap_child_id", "") == child_id:
|
||||||
|
chore.assignment_swap_child_id = ""
|
||||||
|
chore.assignment_swap_date = ""
|
||||||
|
dirty = True
|
||||||
|
if dirty:
|
||||||
|
self.storage.update_chore(chore)
|
||||||
|
# A rotation pointer left on the deleted child hides the chore from
|
||||||
|
# everyone (#787): the child sensor includes a non-everyone chore only
|
||||||
|
# when assignment_current_child_id matches, and a deleted id matches
|
||||||
|
# nobody. Repoint at today's live pool rather than just blanking it, so
|
||||||
|
# the chore reappears for the survivors now instead of at midnight.
|
||||||
|
stale = [c for c in self.storage.get_chores() if getattr(c, "assignment_current_child_id", "") == child_id]
|
||||||
|
if stale:
|
||||||
|
daily = self._compute_daily_assignments()
|
||||||
|
for chore in stale:
|
||||||
|
chore.assignment_current_child_id = daily.get(chore.id, "")
|
||||||
self.storage.update_chore(chore)
|
self.storage.update_chore(chore)
|
||||||
await self.storage.async_save()
|
await self.storage.async_save()
|
||||||
await self.async_refresh()
|
await self.async_refresh()
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Frontend registration for TaskMate custom cards."""
|
"""Frontend registration for TaskMate custom cards."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -56,8 +57,8 @@ CARDS: Final = [
|
|||||||
# why blanket stale-cleanup was removed from async_register_cards).
|
# why blanket stale-cleanup was removed from async_register_cards).
|
||||||
RETIRED_CARDS: Final = [
|
RETIRED_CARDS: Final = [
|
||||||
"taskmate-task-groups-card.js", # removed #452
|
"taskmate-task-groups-card.js", # removed #452
|
||||||
"taskmate-templates-card.js", # removed #448
|
"taskmate-templates-card.js", # removed #448
|
||||||
"taskmate-reminders-card.js", # removed #450
|
"taskmate-reminders-card.js", # removed #450
|
||||||
]
|
]
|
||||||
|
|
||||||
# JS modules loaded on every HA frontend page (config flow sound preview).
|
# JS modules loaded on every HA frontend page (config flow sound preview).
|
||||||
@@ -86,9 +87,7 @@ async def _async_get_version(hass: HomeAssistant) -> str:
|
|||||||
"""Get version from manifest.json for cache busting (async-safe)."""
|
"""Get version from manifest.json for cache busting (async-safe)."""
|
||||||
manifest_path = Path(__file__).parent / "manifest.json"
|
manifest_path = Path(__file__).parent / "manifest.json"
|
||||||
try:
|
try:
|
||||||
content = await hass.async_add_executor_job(
|
content = await hass.async_add_executor_job(manifest_path.read_text, "utf-8")
|
||||||
manifest_path.read_text, "utf-8"
|
|
||||||
)
|
|
||||||
return json.loads(content).get("version", "1.0.0")
|
return json.loads(content).get("version", "1.0.0")
|
||||||
except (OSError, json.JSONDecodeError, AttributeError):
|
except (OSError, json.JSONDecodeError, AttributeError):
|
||||||
return "1.0.0"
|
return "1.0.0"
|
||||||
@@ -107,23 +106,31 @@ async def async_register_frontend(hass: HomeAssistant) -> None:
|
|||||||
_LOGGER.warning("www directory not found at %s", www_path)
|
_LOGGER.warning("www directory not found at %s", www_path)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Register the www folder as a static path
|
# Register the www folder as a static path.
|
||||||
await hass.http.async_register_static_paths(
|
#
|
||||||
[StaticPathConfig(URL_BASE, str(www_path), False)]
|
# This call is what sets our minimum Home Assistant version. Both
|
||||||
)
|
# async_register_static_paths and StaticPathConfig landed in 2024.7.0 and do
|
||||||
|
# not exist in 2024.6.0, so on anything older setup dies here with an
|
||||||
|
# AttributeError rather than degrading — hence the 2024.7.0 floor in
|
||||||
|
# hacs.json and README.md. Lowering that floor means adding a fallback to
|
||||||
|
# the old (since-removed) hass.http.register_static_path.
|
||||||
|
await hass.http.async_register_static_paths([StaticPathConfig(URL_BASE, str(www_path), False)])
|
||||||
|
|
||||||
_LOGGER.debug("Registered static path: %s -> %s", URL_BASE, www_path)
|
_LOGGER.debug("Registered static path: %s -> %s", URL_BASE, www_path)
|
||||||
|
|
||||||
# Authenticated upload/serve endpoints for chore evidence photos.
|
# Authenticated upload/serve endpoints for chore evidence photos.
|
||||||
from .http_photos import async_register_photo_views
|
from .http_photos import async_register_photo_views
|
||||||
|
|
||||||
async_register_photo_views(hass)
|
async_register_photo_views(hass)
|
||||||
|
|
||||||
# Admin-gated upload / authenticated serve for chore pictures (#750).
|
# Admin-gated upload / authenticated serve for chore pictures (#750).
|
||||||
from .http_images import async_register_image_views
|
from .http_images import async_register_image_views
|
||||||
|
|
||||||
async_register_image_views(hass)
|
async_register_image_views(hass)
|
||||||
|
|
||||||
# Token-gated ICS calendar feed (FEAT-10).
|
# Token-gated ICS calendar feed (FEAT-10).
|
||||||
from .http_calendar import async_register_calendar_view
|
from .http_calendar import async_register_calendar_view
|
||||||
|
|
||||||
async_register_calendar_view(hass)
|
async_register_calendar_view(hass)
|
||||||
|
|
||||||
# Register global JS modules (loaded on all pages, including config flow)
|
# Register global JS modules (loaded on all pages, including config flow)
|
||||||
@@ -205,9 +212,7 @@ async def async_register_cards(hass: HomeAssistant) -> None:
|
|||||||
|
|
||||||
if card_url not in existing:
|
if card_url not in existing:
|
||||||
# Card not registered yet — add it
|
# Card not registered yet — add it
|
||||||
await resources.async_create_item(
|
await resources.async_create_item({"url": versioned_url, "res_type": "module"})
|
||||||
{"url": versioned_url, "res_type": "module"}
|
|
||||||
)
|
|
||||||
_LOGGER.info("TaskMate: added resource: %s", versioned_url)
|
_LOGGER.info("TaskMate: added resource: %s", versioned_url)
|
||||||
else:
|
else:
|
||||||
item = existing[card_url]
|
item = existing[card_url]
|
||||||
@@ -220,7 +225,8 @@ async def async_register_cards(hass: HomeAssistant) -> None:
|
|||||||
)
|
)
|
||||||
_LOGGER.info(
|
_LOGGER.info(
|
||||||
"TaskMate: updated resource: %s -> %s",
|
"TaskMate: updated resource: %s -> %s",
|
||||||
current_url, versioned_url,
|
current_url,
|
||||||
|
versioned_url,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
_LOGGER.debug("TaskMate: resource up to date: %s", versioned_url)
|
_LOGGER.debug("TaskMate: resource up to date: %s", versioned_url)
|
||||||
@@ -237,13 +243,12 @@ async def async_register_cards(hass: HomeAssistant) -> None:
|
|||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
await resources.async_delete_item(item["id"])
|
await resources.async_delete_item(item["id"])
|
||||||
_LOGGER.info(
|
_LOGGER.info("TaskMate: removed retired resource: %s", item.get("url"))
|
||||||
"TaskMate: removed retired resource: %s", item.get("url")
|
|
||||||
)
|
|
||||||
except (AttributeError, KeyError, TypeError, OSError) as err:
|
except (AttributeError, KeyError, TypeError, OSError) as err:
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
"TaskMate: could not remove retired resource %s: %s",
|
"TaskMate: could not remove retired resource %s: %s",
|
||||||
item.get("url"), err,
|
item.get("url"),
|
||||||
|
err,
|
||||||
)
|
)
|
||||||
|
|
||||||
except (AttributeError, KeyError, TypeError, OSError) as err:
|
except (AttributeError, KeyError, TypeError, OSError) as err:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ is public-by-URL and authenticated by an unguessable per-instance token in the
|
|||||||
``?token=`` query param (compared in constant time). It serves a read-only feed
|
``?token=`` query param (compared in constant time). It serves a read-only feed
|
||||||
of upcoming chores; no mutation is possible.
|
of upcoming chores; no mutation is possible.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hmac
|
import hmac
|
||||||
@@ -28,6 +29,7 @@ HTTP_CAL_REGISTERED = "calendar_http_registered"
|
|||||||
|
|
||||||
def _get_coordinator(hass: HomeAssistant):
|
def _get_coordinator(hass: HomeAssistant):
|
||||||
from .coordinator import TaskMateCoordinator
|
from .coordinator import TaskMateCoordinator
|
||||||
|
|
||||||
for value in hass.data.get(DOMAIN, {}).values():
|
for value in hass.data.get(DOMAIN, {}).values():
|
||||||
if isinstance(value, TaskMateCoordinator):
|
if isinstance(value, TaskMateCoordinator):
|
||||||
return value
|
return value
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Two views, both auth-gated by ``HomeAssistantView`` (so photos are never public)
|
|||||||
Pure path/validation logic lives in :mod:`.photos` (unit-tested); this module is
|
Pure path/validation logic lives in :mod:`.photos` (unit-tested); this module is
|
||||||
the thin aiohttp wrapper, verified on the dev HA instance.
|
the thin aiohttp wrapper, verified on the dev HA instance.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -37,9 +38,7 @@ class TaskMatePhotoUploadView(HomeAssistantView):
|
|||||||
async def post(self, request: web.Request) -> web.Response:
|
async def post(self, request: web.Request) -> web.Response:
|
||||||
# Cheap pre-check on the declared length before reading the body.
|
# Cheap pre-check on the declared length before reading the body.
|
||||||
if request.content_length and request.content_length > photos.MAX_UPLOAD_BYTES:
|
if request.content_length and request.content_length > photos.MAX_UPLOAD_BYTES:
|
||||||
return self.json_message(
|
return self.json_message("File too large", HTTPStatus.REQUEST_ENTITY_TOO_LARGE)
|
||||||
"File too large", HTTPStatus.REQUEST_ENTITY_TOO_LARGE
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
reader = await request.multipart()
|
reader = await request.multipart()
|
||||||
@@ -61,22 +60,16 @@ class TaskMatePhotoUploadView(HomeAssistantView):
|
|||||||
break
|
break
|
||||||
data.extend(chunk)
|
data.extend(chunk)
|
||||||
if len(data) > photos.MAX_UPLOAD_BYTES:
|
if len(data) > photos.MAX_UPLOAD_BYTES:
|
||||||
return self.json_message(
|
return self.json_message("File too large", HTTPStatus.REQUEST_ENTITY_TOO_LARGE)
|
||||||
"File too large", HTTPStatus.REQUEST_ENTITY_TOO_LARGE
|
|
||||||
)
|
|
||||||
|
|
||||||
ext = photos.detect_image_ext(bytes(data))
|
ext = photos.detect_image_ext(bytes(data))
|
||||||
if ext is None:
|
if ext is None:
|
||||||
return self.json_message("Not a valid image", HTTPStatus.BAD_REQUEST)
|
return self.json_message("Not a valid image", HTTPStatus.BAD_REQUEST)
|
||||||
|
|
||||||
# DoS guard: reject if the photo store is already at its disk budget.
|
# DoS guard: reject if the photo store is already at its disk budget.
|
||||||
used = await self.hass.async_add_executor_job(
|
used = await self.hass.async_add_executor_job(photos.total_photos_bytes, self.hass)
|
||||||
photos.total_photos_bytes, self.hass
|
|
||||||
)
|
|
||||||
if used + len(data) > photos.MAX_TOTAL_BYTES:
|
if used + len(data) > photos.MAX_TOTAL_BYTES:
|
||||||
return self.json_message(
|
return self.json_message("Photo storage full", HTTPStatus.INSUFFICIENT_STORAGE)
|
||||||
"Photo storage full", HTTPStatus.INSUFFICIENT_STORAGE
|
|
||||||
)
|
|
||||||
|
|
||||||
name = f"{uuid.uuid4().hex}.{ext}"
|
name = f"{uuid.uuid4().hex}.{ext}"
|
||||||
directory = photos.photos_path(self.hass)
|
directory = photos.photos_path(self.hass)
|
||||||
@@ -90,9 +83,7 @@ class TaskMatePhotoUploadView(HomeAssistantView):
|
|||||||
await self.hass.async_add_executor_job(_write)
|
await self.hass.async_add_executor_job(_write)
|
||||||
except OSError as err:
|
except OSError as err:
|
||||||
_LOGGER.error("Failed to store evidence photo: %s", err)
|
_LOGGER.error("Failed to store evidence photo: %s", err)
|
||||||
return self.json_message(
|
return self.json_message("Could not store photo", HTTPStatus.INTERNAL_SERVER_ERROR)
|
||||||
"Could not store photo", HTTPStatus.INTERNAL_SERVER_ERROR
|
|
||||||
)
|
|
||||||
|
|
||||||
return self.json({"photo_url": f"{photos.URL_PREFIX}/{name}"})
|
return self.json({"photo_url": f"{photos.URL_PREFIX}/{name}"})
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Pure-ish helpers that turn the chore calendar projection into an RFC 5545 feed a
|
|||||||
calendar app (Google/Apple/Outlook) can subscribe to. Token auth + the HTTP view
|
calendar app (Google/Apple/Outlook) can subscribe to. Token auth + the HTTP view
|
||||||
live in ``http_calendar.py``; this module only builds text.
|
live in ``http_calendar.py``; this module only builds text.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -14,13 +15,7 @@ PRODID = "-//TaskMate//Chores//EN"
|
|||||||
|
|
||||||
def _escape(text: str) -> str:
|
def _escape(text: str) -> str:
|
||||||
"""Escape a value per RFC 5545 (backslash, comma, semicolon, newline)."""
|
"""Escape a value per RFC 5545 (backslash, comma, semicolon, newline)."""
|
||||||
return (
|
return str(text).replace("\\", "\\\\").replace("\n", "\\n").replace(",", "\\,").replace(";", "\\;")
|
||||||
str(text)
|
|
||||||
.replace("\\", "\\\\")
|
|
||||||
.replace("\n", "\\n")
|
|
||||||
.replace(",", "\\,")
|
|
||||||
.replace(";", "\\;")
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _fold(line: str) -> str:
|
def _fold(line: str) -> str:
|
||||||
@@ -97,31 +92,39 @@ def build_chore_events(coordinator, start_day: date, end_day: date) -> list[dict
|
|||||||
for chore in chores:
|
for chore in chores:
|
||||||
if not _chore_applies_to_child(coordinator, chore, child.id, day):
|
if not _chore_applies_to_child(coordinator, chore, child.id, day):
|
||||||
continue
|
continue
|
||||||
window = coordinator._time_category_window(
|
window = coordinator._time_category_window(getattr(chore, "time_category", "anytime"), day)
|
||||||
getattr(chore, "time_category", "anytime"), day
|
|
||||||
)
|
|
||||||
summary = f"{chore.name} — {child.name}"
|
summary = f"{chore.name} — {child.name}"
|
||||||
desc = _chore_description(chore)
|
desc = _chore_description(chore)
|
||||||
if window is None:
|
if window is None:
|
||||||
events.append({
|
events.append(
|
||||||
"uid": make_uid(chore.id, child.id, day.isoformat(), "allday"),
|
{
|
||||||
"summary": summary, "description": desc,
|
"uid": make_uid(chore.id, child.id, day.isoformat(), "allday"),
|
||||||
"start": day, "end": day + timedelta(days=1), "all_day": True,
|
"summary": summary,
|
||||||
})
|
"description": desc,
|
||||||
|
"start": day,
|
||||||
|
"end": day + timedelta(days=1),
|
||||||
|
"all_day": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
start_dt, end_dt = window
|
start_dt, end_dt = window
|
||||||
events.append({
|
events.append(
|
||||||
"uid": make_uid(chore.id, child.id, day.isoformat(), "timed"),
|
{
|
||||||
"summary": summary, "description": desc,
|
"uid": make_uid(chore.id, child.id, day.isoformat(), "timed"),
|
||||||
"start": _ensure_aware(start_dt),
|
"summary": summary,
|
||||||
"end": _ensure_aware(end_dt), "all_day": False,
|
"description": desc,
|
||||||
})
|
"start": _ensure_aware(start_dt),
|
||||||
|
"end": _ensure_aware(end_dt),
|
||||||
|
"all_day": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
day += timedelta(days=1)
|
day += timedelta(days=1)
|
||||||
return events
|
return events
|
||||||
|
|
||||||
|
|
||||||
def _ensure_aware(dt: datetime) -> datetime:
|
def _ensure_aware(dt: datetime) -> datetime:
|
||||||
from homeassistant.util import dt as dt_util
|
from homeassistant.util import dt as dt_util
|
||||||
|
|
||||||
if dt.tzinfo is None:
|
if dt.tzinfo is None:
|
||||||
return dt.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE)
|
return dt.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE)
|
||||||
return dt
|
return dt
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ the example sentences in ``custom_sentences/<lang>/taskmate.yaml`` into your HA
|
|||||||
config (see custom_sentences/README.md). The speech-building logic is kept in
|
config (see custom_sentences/README.md). The speech-building logic is kept in
|
||||||
pure helpers so it is unit-testable without the conversation stack.
|
pure helpers so it is unit-testable without the conversation stack.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -27,6 +28,7 @@ INTENT_POINTS = "TaskMatePoints"
|
|||||||
|
|
||||||
def _get_coordinator(hass: HomeAssistant):
|
def _get_coordinator(hass: HomeAssistant):
|
||||||
from .coordinator import TaskMateCoordinator
|
from .coordinator import TaskMateCoordinator
|
||||||
|
|
||||||
for value in hass.data.get(DOMAIN, {}).values():
|
for value in hass.data.get(DOMAIN, {}).values():
|
||||||
if isinstance(value, TaskMateCoordinator):
|
if isinstance(value, TaskMateCoordinator):
|
||||||
return value
|
return value
|
||||||
|
|||||||
@@ -17,5 +17,5 @@
|
|||||||
"iot_class": "calculated",
|
"iot_class": "calculated",
|
||||||
"issue_tracker": "https://github.com/tempus2016/taskmate/issues",
|
"issue_tracker": "https://github.com/tempus2016/taskmate/issues",
|
||||||
"requirements": [],
|
"requirements": [],
|
||||||
"version": "5.1.0"
|
"version": "5.1.1"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Data models for TaskMate integration."""
|
"""Data models for TaskMate integration."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -24,6 +25,7 @@ def generate_id() -> str:
|
|||||||
def dt_util_now_iso() -> str:
|
def dt_util_now_iso() -> str:
|
||||||
"""Current local time as an ISO string (module-level so dataclass defaults can use it)."""
|
"""Current local time as an ISO string (module-level so dataclass defaults can use it)."""
|
||||||
from homeassistant.util import dt as dt_util
|
from homeassistant.util import dt as dt_util
|
||||||
|
|
||||||
return dt_util.now().isoformat()
|
return dt_util.now().isoformat()
|
||||||
|
|
||||||
|
|
||||||
@@ -172,7 +174,7 @@ class Child:
|
|||||||
notify_service: str | None = None
|
notify_service: str | None = None
|
||||||
linked_user_id: str = "" # HA user id; when set, only that user (or an admin) may self-serve as this child
|
linked_user_id: str = "" # HA user id; when set, only that user (or an admin) may self-serve as this child
|
||||||
quiet_hours_start: str = "" # "HH:MM" — start of do-not-disturb window; empty = no quiet hours
|
quiet_hours_start: str = "" # "HH:MM" — start of do-not-disturb window; empty = no quiet hours
|
||||||
quiet_hours_end: str = "" # "HH:MM" — end of do-not-disturb window; start>end means overnight
|
quiet_hours_end: str = "" # "HH:MM" — end of do-not-disturb window; start>end means overnight
|
||||||
level: int = 1 # cached XP level (derived from total_points_earned)
|
level: int = 1 # cached XP level (derived from total_points_earned)
|
||||||
# Guest profiles (#690): a visiting cousin gets a temporary child that
|
# Guest profiles (#690): a visiting cousin gets a temporary child that
|
||||||
# expires on its own and stays out of the family leaderboard.
|
# expires on its own and stays out of the family leaderboard.
|
||||||
@@ -265,7 +267,9 @@ class Chore:
|
|||||||
# Optional uploaded photograph (#750). Takes precedence over `icon` at
|
# Optional uploaded photograph (#750). Takes precedence over `icon` at
|
||||||
# every render site; stored as a /api/taskmate/image/<name> URL.
|
# every render site; stored as a /api/taskmate/image/<name> URL.
|
||||||
image_url: str = ""
|
image_url: str = ""
|
||||||
difficulty: str = "medium" # easy | medium | hard — scales awarded points by the tier multiplier (medium = ×1.0 baseline)
|
difficulty: str = (
|
||||||
|
"medium" # easy | medium | hard — scales awarded points by the tier multiplier (medium = ×1.0 baseline)
|
||||||
|
)
|
||||||
# Scheduling
|
# Scheduling
|
||||||
# schedule_mode: "specific_days" = show on selected days of week (Mode A)
|
# schedule_mode: "specific_days" = show on selected days of week (Mode A)
|
||||||
# "recurring" = rolling window recurrence (Mode B)
|
# "recurring" = rolling window recurrence (Mode B)
|
||||||
@@ -273,7 +277,7 @@ class Chore:
|
|||||||
due_days: list[str] = field(default_factory=list) # Mode A: days to show chore
|
due_days: list[str] = field(default_factory=list) # Mode A: days to show chore
|
||||||
# Mode B fields
|
# Mode B fields
|
||||||
recurrence: str = "weekly" # every_2_days | weekly | every_2_weeks | monthly | every_3_months | every_6_months
|
recurrence: str = "weekly" # every_2_days | weekly | every_2_weeks | monthly | every_3_months | every_6_months
|
||||||
recurrence_day: str = "" # optional: which day of week for weekly/every_2_weeks
|
recurrence_day: str = "" # optional: which day of week for weekly/every_2_weeks
|
||||||
recurrence_start: str = "" # optional: ISO date anchor for every_2_days
|
recurrence_start: str = "" # optional: ISO date anchor for every_2_days
|
||||||
first_occurrence_mode: str = "available_immediately" # available_immediately | wait_for_first_occurrence
|
first_occurrence_mode: str = "available_immediately" # available_immediately | wait_for_first_occurrence
|
||||||
# Dynamic visibility
|
# Dynamic visibility
|
||||||
@@ -293,7 +297,9 @@ class Chore:
|
|||||||
# One-shot chore fields
|
# One-shot chore fields
|
||||||
enabled: bool = True # False = soft-disabled (completed or expired)
|
enabled: bool = True # False = soft-disabled (completed or expired)
|
||||||
disabled_for: list[str] = field(default_factory=list) # Child IDs this chore is disabled for
|
disabled_for: list[str] = field(default_factory=list) # Child IDs this chore is disabled for
|
||||||
depends_on: list[str] = field(default_factory=list) # Chore IDs that must be approved-completed today before this is available
|
depends_on: list[str] = field(
|
||||||
|
default_factory=list
|
||||||
|
) # Chore IDs that must be approved-completed today before this is available
|
||||||
created_date: str = "" # ISO date for one-shot expiry, e.g. "2026-04-16"
|
created_date: str = "" # ISO date for one-shot expiry, e.g. "2026-04-16"
|
||||||
expires_on: str = "" # optional ISO end date; chore auto-disables the day after
|
expires_on: str = "" # optional ISO end date; chore auto-disables the day after
|
||||||
# Reactive chores (#674): a short-lived chore raised by an automation, e.g.
|
# Reactive chores (#674): a short-lived chore raised by an automation, e.g.
|
||||||
@@ -316,11 +322,20 @@ class Chore:
|
|||||||
# Dynamic assignment (sibling rotation)
|
# Dynamic assignment (sibling rotation)
|
||||||
assignment_mode: str = "everyone" # everyone | alternating | random
|
assignment_mode: str = "everyone" # everyone | alternating | random
|
||||||
assignment_rotation_anchor: str = "" # ISO date; day-0 of the rotation for alternating
|
assignment_rotation_anchor: str = "" # ISO date; day-0 of the rotation for alternating
|
||||||
assignment_current_child_id: str = "" # cached active child ID for today (computed at midnight and on create/update)
|
assignment_current_child_id: str = (
|
||||||
|
"" # cached active child ID for today (computed at midnight and on create/update)
|
||||||
|
)
|
||||||
require_availability: bool = False # When True, skip children whose availability entity says they're unavailable
|
require_availability: bool = False # When True, skip children whose availability entity says they're unavailable
|
||||||
# Skip state (ephemeral: cleared at midnight when skip_date != today)
|
# Skip state (ephemeral: cleared at midnight when skip_date != today)
|
||||||
skip_date: str = "" # ISO date the skip applies to ("" = no active skip)
|
skip_date: str = "" # ISO date the skip applies to ("" = no active skip)
|
||||||
skip_count: int = 0 # number of times skipped today; added to rotation index
|
skip_count: int = 0 # number of times skipped today; added to rotation index
|
||||||
|
# Approved sibling swap (ephemeral, same shape as the skip pair above).
|
||||||
|
# This — not assignment_current_child_id — is the override every read-time
|
||||||
|
# eligibility gate consults, because assignment_current_child_id is itself
|
||||||
|
# *written from* _compute_active_children at midnight; reading it back there
|
||||||
|
# would pin the rotation to whoever it last cached (#781).
|
||||||
|
assignment_swap_child_id: str = "" # child the chore was swapped to
|
||||||
|
assignment_swap_date: str = "" # ISO date the swap applies to ("" = no swap)
|
||||||
# Calendar publish: list of HA calendar entity ids to mirror the chore to. Any number supported.
|
# Calendar publish: list of HA calendar entity ids to mirror the chore to. Any number supported.
|
||||||
publish_calendar_entities: list[str] = field(default_factory=list)
|
publish_calendar_entities: list[str] = field(default_factory=list)
|
||||||
# ISO dates already written to the configured calendars. Used for both
|
# ISO dates already written to the configured calendars. Used for both
|
||||||
@@ -392,6 +407,8 @@ class Chore:
|
|||||||
require_availability=data.get("require_availability", False),
|
require_availability=data.get("require_availability", False),
|
||||||
skip_date=data.get("skip_date", ""),
|
skip_date=data.get("skip_date", ""),
|
||||||
skip_count=int(data.get("skip_count", 0) or 0),
|
skip_count=int(data.get("skip_count", 0) or 0),
|
||||||
|
assignment_swap_child_id=data.get("assignment_swap_child_id", ""),
|
||||||
|
assignment_swap_date=data.get("assignment_swap_date", ""),
|
||||||
publish_calendar_entities=list(data.get("publish_calendar_entities", [])),
|
publish_calendar_entities=list(data.get("publish_calendar_entities", [])),
|
||||||
# Back-compat: old records stored a single ISO date in
|
# Back-compat: old records stored a single ISO date in
|
||||||
# `publish_calendar_last_date`. Seed the new list with it so we
|
# `publish_calendar_last_date`. Seed the new list with it so we
|
||||||
@@ -456,6 +473,8 @@ class Chore:
|
|||||||
"require_availability": self.require_availability,
|
"require_availability": self.require_availability,
|
||||||
"skip_date": self.skip_date,
|
"skip_date": self.skip_date,
|
||||||
"skip_count": self.skip_count,
|
"skip_count": self.skip_count,
|
||||||
|
"assignment_swap_child_id": self.assignment_swap_child_id,
|
||||||
|
"assignment_swap_date": self.assignment_swap_date,
|
||||||
"publish_calendar_entities": self.publish_calendar_entities,
|
"publish_calendar_entities": self.publish_calendar_entities,
|
||||||
"publish_calendar_published_dates": self.publish_calendar_published_dates,
|
"publish_calendar_published_dates": self.publish_calendar_published_dates,
|
||||||
"bonus_subtasks": [b.to_dict() for b in self.bonus_subtasks],
|
"bonus_subtasks": [b.to_dict() for b in self.bonus_subtasks],
|
||||||
@@ -561,7 +580,7 @@ class Quest:
|
|||||||
name: str
|
name: str
|
||||||
description: str = ""
|
description: str = ""
|
||||||
icon: str = "mdi:map-marker-path"
|
icon: str = "mdi:map-marker-path"
|
||||||
steps: list[str] = field(default_factory=list) # ordered chore IDs
|
steps: list[str] = field(default_factory=list) # ordered chore IDs
|
||||||
bonus_points: int = 25
|
bonus_points: int = 25
|
||||||
assigned_to: list[str] = field(default_factory=list) # child IDs; empty = all
|
assigned_to: list[str] = field(default_factory=list) # child IDs; empty = all
|
||||||
repeatable: bool = False
|
repeatable: bool = False
|
||||||
@@ -609,8 +628,8 @@ class Challenge:
|
|||||||
name: str
|
name: str
|
||||||
description: str = ""
|
description: str = ""
|
||||||
icon: str = "mdi:trophy-outline"
|
icon: str = "mdi:trophy-outline"
|
||||||
scope: str = "daily" # daily | weekly
|
scope: str = "daily" # daily | weekly
|
||||||
metric: str = "chores" # chores | points
|
metric: str = "chores" # chores | points
|
||||||
target: int = 3
|
target: int = 3
|
||||||
bonus_points: int = 15
|
bonus_points: int = 15
|
||||||
assigned_to: list[str] = field(default_factory=list) # child IDs; empty = all
|
assigned_to: list[str] = field(default_factory=list) # child IDs; empty = all
|
||||||
@@ -708,8 +727,8 @@ class MandatoryMiss:
|
|||||||
|
|
||||||
chore_id: str
|
chore_id: str
|
||||||
child_id: str
|
child_id: str
|
||||||
due_date: str # ISO date the chore was missed
|
due_date: str # ISO date the chore was missed
|
||||||
period_id: str # the window that closed ("anytime" for all-day)
|
period_id: str # the window that closed ("anytime" for all-day)
|
||||||
penalty_points: int = 0
|
penalty_points: int = 0
|
||||||
postpone_count: int = 0
|
postpone_count: int = 0
|
||||||
escalation_stage: int = 0 # 0=none 1=nudged 2=reminded 3=parent-alerted (FEAT-6)
|
escalation_stage: int = 0 # 0=none 1=nudged 2=reminded 3=parent-alerted (FEAT-6)
|
||||||
@@ -1207,10 +1226,7 @@ class NotificationConfig:
|
|||||||
return cls(
|
return cls(
|
||||||
type_id=data.get("type_id", ""),
|
type_id=data.get("type_id", ""),
|
||||||
master_enabled=bool(data.get("master_enabled", False)),
|
master_enabled=bool(data.get("master_enabled", False)),
|
||||||
routes={
|
routes={rid: NotificationRoute.from_dict(rdata) for rid, rdata in raw_routes.items()},
|
||||||
rid: NotificationRoute.from_dict(rdata)
|
|
||||||
for rid, rdata in raw_routes.items()
|
|
||||||
},
|
|
||||||
nav_url=data.get("nav_url", "") or "",
|
nav_url=data.get("nav_url", "") or "",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1231,8 +1247,8 @@ class CustomNotification:
|
|||||||
|
|
||||||
name: str
|
name: str
|
||||||
message_template: str
|
message_template: str
|
||||||
time: str # "HH:MM"
|
time: str # "HH:MM"
|
||||||
day_mask: int = 0b1111111 # bit0=Mon … bit6=Sun
|
day_mask: int = 0b1111111 # bit0=Mon … bit6=Sun
|
||||||
recipient_ids: list[str] = field(default_factory=list)
|
recipient_ids: list[str] = field(default_factory=list)
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
id: str = field(default_factory=generate_id)
|
id: str = field(default_factory=generate_id)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Lets the points-bearing knobs be read and changed from automations/scripts and
|
|||||||
the HA UI without a service call. Values are persisted through the same
|
the HA UI without a service call. Values are persisted through the same
|
||||||
settings store the panel uses, so panel and entity stay in sync.
|
settings store the panel uses, so panel and entity stay in sync.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from homeassistant.components.number import NumberEntity, NumberMode
|
from homeassistant.components.number import NumberEntity, NumberMode
|
||||||
@@ -23,9 +24,7 @@ _NUMBERS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None:
|
||||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
|
||||||
) -> None:
|
|
||||||
"""Set up the TaskMate setting-number entities."""
|
"""Set up the TaskMate setting-number entities."""
|
||||||
coordinator: TaskMateCoordinator = hass.data[DOMAIN][entry.entry_id]
|
coordinator: TaskMateCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||||
async_add_entities(TaskMateSettingNumber(coordinator, entry, *cfg) for cfg in _NUMBERS)
|
async_add_entities(TaskMateSettingNumber(coordinator, entry, *cfg) for cfg in _NUMBERS)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Sidebar panel registration for the TaskMate admin UI."""
|
"""Sidebar panel registration for the TaskMate admin UI."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ real HA install. The aiohttp views live in ``http_photos.py``.
|
|||||||
Photos are stored as ``<32 hex>.<ext>`` under ``<config>/taskmate_photos`` and
|
Photos are stored as ``<32 hex>.<ext>`` under ``<config>/taskmate_photos`` and
|
||||||
served (auth-gated) at ``/api/taskmate/photo/<name>``.
|
served (auth-gated) at ``/api/taskmate/photo/<name>``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -86,7 +87,7 @@ def is_taskmate_photo_url(photo_url: str) -> bool:
|
|||||||
prefix = URL_PREFIX + "/"
|
prefix = URL_PREFIX + "/"
|
||||||
if not photo_url.startswith(prefix):
|
if not photo_url.startswith(prefix):
|
||||||
return False
|
return False
|
||||||
return bool(FILENAME_RE.match(photo_url[len(prefix):]))
|
return bool(FILENAME_RE.match(photo_url[len(prefix) :]))
|
||||||
|
|
||||||
|
|
||||||
def photo_file_for_url(hass, photo_url: str) -> Path | None:
|
def photo_file_for_url(hass, photo_url: str) -> Path | None:
|
||||||
@@ -101,7 +102,7 @@ def photo_file_for_url(hass, photo_url: str) -> Path | None:
|
|||||||
prefix = URL_PREFIX + "/"
|
prefix = URL_PREFIX + "/"
|
||||||
if not photo_url.startswith(prefix):
|
if not photo_url.startswith(prefix):
|
||||||
return None
|
return None
|
||||||
name = photo_url[len(prefix):]
|
name = photo_url[len(prefix) :]
|
||||||
if not FILENAME_RE.match(name):
|
if not FILENAME_RE.match(name):
|
||||||
return None
|
return None
|
||||||
return photos_path(hass) / name
|
return photos_path(hass) / name
|
||||||
@@ -181,10 +182,7 @@ async def async_sweep_orphan_photos(hass, referenced_urls, max_age_hours: int =
|
|||||||
Returns the number of files deleted.
|
Returns the number of files deleted.
|
||||||
"""
|
"""
|
||||||
prefix = URL_PREFIX + "/"
|
prefix = URL_PREFIX + "/"
|
||||||
referenced = {
|
referenced = {url[len(prefix) :] for url in referenced_urls if url and url.startswith(prefix)}
|
||||||
url[len(prefix):] for url in referenced_urls
|
|
||||||
if url and url.startswith(prefix)
|
|
||||||
}
|
|
||||||
directory = photos_path(hass)
|
directory = photos_path(hass)
|
||||||
|
|
||||||
def _sweep() -> int:
|
def _sweep() -> int:
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Select platform — expose key choice TaskMate settings as entities (FEAT-9)."""
|
"""Select platform — expose key choice TaskMate settings as entities (FEAT-9)."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from homeassistant.components.select import SelectEntity
|
from homeassistant.components.select import SelectEntity
|
||||||
@@ -14,13 +15,17 @@ from .coordinator import TaskMateCoordinator
|
|||||||
# (setting_key, translation_key, options, default, icon)
|
# (setting_key, translation_key, options, default, icon)
|
||||||
_SELECTS = [
|
_SELECTS = [
|
||||||
("streak_reset_mode", "streak_reset_mode", ["reset", "pause"], "reset", "mdi:restart"),
|
("streak_reset_mode", "streak_reset_mode", ["reset", "pause"], "reset", "mdi:restart"),
|
||||||
("card_design", "card_design", ["classic", "playroom", "console", "cleanpro", "accessible"], "classic", "mdi:palette"),
|
(
|
||||||
|
"card_design",
|
||||||
|
"card_design",
|
||||||
|
["classic", "playroom", "console", "cleanpro", "accessible"],
|
||||||
|
"classic",
|
||||||
|
"mdi:palette",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None:
|
||||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
|
||||||
) -> None:
|
|
||||||
"""Set up the TaskMate setting-select entities."""
|
"""Set up the TaskMate setting-select entities."""
|
||||||
coordinator: TaskMateCoordinator = hass.data[DOMAIN][entry.entry_id]
|
coordinator: TaskMateCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||||
async_add_entities(TaskMateSettingSelect(coordinator, entry, *cfg) for cfg in _SELECTS)
|
async_add_entities(TaskMateSettingSelect(coordinator, entry, *cfg) for cfg in _SELECTS)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Sensor platform for TaskMate integration."""
|
"""Sensor platform for TaskMate integration."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -85,9 +86,7 @@ def _compute_common(coordinator: TaskMateCoordinator) -> dict:
|
|||||||
for comp in pending_completions:
|
for comp in pending_completions:
|
||||||
chore = chore_lookup.get(comp.chore_id)
|
chore = chore_lookup.get(comp.chore_id)
|
||||||
if chore:
|
if chore:
|
||||||
pending_points_by_child[comp.child_id] = (
|
pending_points_by_child[comp.child_id] = pending_points_by_child.get(comp.child_id, 0) + chore.points
|
||||||
pending_points_by_child.get(comp.child_id, 0) + chore.points
|
|
||||||
)
|
|
||||||
|
|
||||||
# Committed points per child (reward claims awaiting approval = points reserved).
|
# Committed points per child (reward claims awaiting approval = points reserved).
|
||||||
# Pool-mode pending claims are skipped because their cost was already deducted
|
# Pool-mode pending claims are skipped because their cost was already deducted
|
||||||
@@ -98,9 +97,7 @@ def _compute_common(coordinator: TaskMateCoordinator) -> dict:
|
|||||||
continue
|
continue
|
||||||
reward = reward_lookup.get(rc.reward_id)
|
reward = reward_lookup.get(rc.reward_id)
|
||||||
if reward:
|
if reward:
|
||||||
committed_points_by_child[rc.child_id] = (
|
committed_points_by_child[rc.child_id] = committed_points_by_child.get(rc.child_id, 0) + reward.cost
|
||||||
committed_points_by_child.get(rc.child_id, 0) + reward.cost
|
|
||||||
)
|
|
||||||
|
|
||||||
# Pool allocation lookups for v3.0 pool mode.
|
# Pool allocation lookups for v3.0 pool mode.
|
||||||
pool_by_child_reward: dict[str, dict[str, int]] = {}
|
pool_by_child_reward: dict[str, dict[str, int]] = {}
|
||||||
@@ -108,12 +105,8 @@ def _compute_common(coordinator: TaskMateCoordinator) -> dict:
|
|||||||
total_allocated_by_child: dict[str, int] = {}
|
total_allocated_by_child: dict[str, int] = {}
|
||||||
for pa in pool_alloc_objs:
|
for pa in pool_alloc_objs:
|
||||||
pool_by_child_reward.setdefault(pa.child_id, {})[pa.reward_id] = pa.allocated_points
|
pool_by_child_reward.setdefault(pa.child_id, {})[pa.reward_id] = pa.allocated_points
|
||||||
pool_total_by_reward[pa.reward_id] = (
|
pool_total_by_reward[pa.reward_id] = pool_total_by_reward.get(pa.reward_id, 0) + pa.allocated_points
|
||||||
pool_total_by_reward.get(pa.reward_id, 0) + pa.allocated_points
|
total_allocated_by_child[pa.child_id] = total_allocated_by_child.get(pa.child_id, 0) + pa.allocated_points
|
||||||
)
|
|
||||||
total_allocated_by_child[pa.child_id] = (
|
|
||||||
total_allocated_by_child.get(pa.child_id, 0) + pa.allocated_points
|
|
||||||
)
|
|
||||||
|
|
||||||
common = {
|
common = {
|
||||||
"data_id": data_id,
|
"data_id": data_id,
|
||||||
@@ -151,51 +144,57 @@ def _build_children_summary(coordinator: TaskMateCoordinator, common: dict) -> l
|
|||||||
for c in children:
|
for c in children:
|
||||||
committed_amount = committed.get(c.id, 0)
|
committed_amount = committed.get(c.id, 0)
|
||||||
lvl = coordinator.level_info(c)
|
lvl = coordinator.level_info(c)
|
||||||
summary.append({
|
summary.append(
|
||||||
"level": lvl["level"],
|
{
|
||||||
"level_progress": lvl["progress"],
|
"level": lvl["level"],
|
||||||
"level_target": lvl["target"],
|
"level_progress": lvl["progress"],
|
||||||
"id": c.id,
|
"level_target": lvl["target"],
|
||||||
"name": c.name,
|
"id": c.id,
|
||||||
"points": c.points,
|
"name": c.name,
|
||||||
"pending_points": pending.get(c.id, 0),
|
"points": c.points,
|
||||||
# Guest profiles (#690): cards filter these out of competitive views.
|
"pending_points": pending.get(c.id, 0),
|
||||||
**({"is_guest": True, "guest_expires_on": getattr(c, "guest_expires_on", "")}
|
# Guest profiles (#690): cards filter these out of competitive views.
|
||||||
if getattr(c, "is_guest", False) else {}),
|
**(
|
||||||
# Chore roulette (#677): today's pick + spins left, so the card can
|
{"is_guest": True, "guest_expires_on": getattr(c, "guest_expires_on", "")}
|
||||||
# show the result and disable the button once the allowance is used.
|
if getattr(c, "is_guest", False)
|
||||||
**(
|
else {}
|
||||||
{
|
),
|
||||||
"roulette": {
|
# Chore roulette (#677): today's pick + spins left, so the card can
|
||||||
**(coordinator.roulette_selection(c.id) or {}),
|
# show the result and disable the button once the allowance is used.
|
||||||
"spins_left": coordinator.roulette_spins_left(c.id),
|
**(
|
||||||
|
{
|
||||||
|
"roulette": {
|
||||||
|
**(coordinator.roulette_selection(c.id) or {}),
|
||||||
|
"spins_left": coordinator.roulette_spins_left(c.id),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
if coordinator.roulette_enabled()
|
||||||
if coordinator.roulette_enabled() else {}
|
else {}
|
||||||
),
|
),
|
||||||
"committed_points": committed_amount,
|
"committed_points": committed_amount,
|
||||||
"allocated_points": allocated.get(c.id, 0),
|
"allocated_points": allocated.get(c.id, 0),
|
||||||
# Allocations were deducted from child.points already, so spendable
|
# Allocations were deducted from child.points already, so spendable
|
||||||
# only needs to account for pending-claim commitments.
|
# only needs to account for pending-claim commitments.
|
||||||
"spendable_balance": max(0, c.points - committed_amount),
|
"spendable_balance": max(0, c.points - committed_amount),
|
||||||
"chore_order": c.chore_order,
|
"chore_order": c.chore_order,
|
||||||
"current_streak": getattr(c, 'current_streak', 0) or 0,
|
"current_streak": getattr(c, "current_streak", 0) or 0,
|
||||||
"best_streak": getattr(c, 'best_streak', 0) or 0,
|
"best_streak": getattr(c, "best_streak", 0) or 0,
|
||||||
"season_points": int(season.get(c.id, 0)),
|
"season_points": int(season.get(c.id, 0)),
|
||||||
"total_points_earned": getattr(c, 'total_points_earned', 0) or 0,
|
"total_points_earned": getattr(c, "total_points_earned", 0) or 0,
|
||||||
"total_chores_completed": getattr(c, 'total_chores_completed', 0) or 0,
|
"total_chores_completed": getattr(c, "total_chores_completed", 0) or 0,
|
||||||
"avatar": getattr(c, 'avatar', 'mdi:account-circle') or 'mdi:account-circle',
|
"avatar": getattr(c, "avatar", "mdi:account-circle") or "mdi:account-circle",
|
||||||
"last_completion_date": getattr(c, 'last_completion_date', None),
|
"last_completion_date": getattr(c, "last_completion_date", None),
|
||||||
"streak_paused": getattr(c, 'streak_paused', False),
|
"streak_paused": getattr(c, "streak_paused", False),
|
||||||
"on_vacation": coordinator._is_child_on_vacation(c),
|
"on_vacation": coordinator._is_child_on_vacation(c),
|
||||||
"streak_milestones_achieved": getattr(c, 'streak_milestones_achieved', None) or [],
|
"streak_milestones_achieved": getattr(c, "streak_milestones_achieved", None) or [],
|
||||||
"awarded_perfect_weeks": getattr(c, 'awarded_perfect_weeks', None) or [],
|
"awarded_perfect_weeks": getattr(c, "awarded_perfect_weeks", None) or [],
|
||||||
"career_score": getattr(c, 'career_score', 0) or 0,
|
"career_score": getattr(c, "career_score", 0) or 0,
|
||||||
"total_penalties_received": getattr(c, 'total_penalties_received', 0) or 0,
|
"total_penalties_received": getattr(c, "total_penalties_received", 0) or 0,
|
||||||
"quests": coordinator.quest_progress_for_child(c.id),
|
"quests": coordinator.quest_progress_for_child(c.id),
|
||||||
"avatar_options": coordinator.avatar_options_for_child(c),
|
"avatar_options": coordinator.avatar_options_for_child(c),
|
||||||
"challenges": coordinator.challenge_progress_for_child(c.id),
|
"challenges": coordinator.challenge_progress_for_child(c.id),
|
||||||
})
|
}
|
||||||
|
)
|
||||||
return summary
|
return summary
|
||||||
|
|
||||||
|
|
||||||
@@ -223,61 +222,61 @@ def _build_chores_list(coordinator: TaskMateCoordinator, common: dict) -> list[d
|
|||||||
"time_category": c.time_category,
|
"time_category": c.time_category,
|
||||||
"assigned_to": assigned_to,
|
"assigned_to": assigned_to,
|
||||||
"depends_on": depends_on,
|
"depends_on": depends_on,
|
||||||
"schedule_mode": getattr(c, 'schedule_mode', 'specific_days'),
|
"schedule_mode": getattr(c, "schedule_mode", "specific_days"),
|
||||||
"enabled": getattr(c, 'enabled', True),
|
"enabled": getattr(c, "enabled", True),
|
||||||
"assignment_mode": getattr(c, 'assignment_mode', 'everyone'),
|
"assignment_mode": getattr(c, "assignment_mode", "everyone"),
|
||||||
}
|
}
|
||||||
# Difficulty tier + the points it actually awards. Emitted only when
|
# Difficulty tier + the points it actually awards. Emitted only when
|
||||||
# non-default (medium / ×1.0) so simple chores stay compact.
|
# non-default (medium / ×1.0) so simple chores stay compact.
|
||||||
difficulty = getattr(c, 'difficulty', 'medium') or 'medium'
|
difficulty = getattr(c, "difficulty", "medium") or "medium"
|
||||||
if difficulty != 'medium':
|
if difficulty != "medium":
|
||||||
record["difficulty"] = difficulty
|
record["difficulty"] = difficulty
|
||||||
effective_points = coordinator.effective_chore_points(c)
|
effective_points = coordinator.effective_chore_points(c)
|
||||||
if effective_points != c.points:
|
if effective_points != c.points:
|
||||||
record["effective_points"] = effective_points
|
record["effective_points"] = effective_points
|
||||||
# Optional fields — emit only when non-default to save bytes.
|
# Optional fields — emit only when non-default to save bytes.
|
||||||
description = getattr(c, 'description', '') or ''
|
description = getattr(c, "description", "") or ""
|
||||||
if description:
|
if description:
|
||||||
record["description"] = description
|
record["description"] = description
|
||||||
daily_limit = getattr(c, 'daily_limit', 1)
|
daily_limit = getattr(c, "daily_limit", 1)
|
||||||
if daily_limit != 1:
|
if daily_limit != 1:
|
||||||
record["daily_limit"] = daily_limit
|
record["daily_limit"] = daily_limit
|
||||||
claim_allowance_minutes = getattr(c, 'claim_allowance_minutes', 0) or 0
|
claim_allowance_minutes = getattr(c, "claim_allowance_minutes", 0) or 0
|
||||||
if claim_allowance_minutes:
|
if claim_allowance_minutes:
|
||||||
record["claim_allowance_minutes"] = claim_allowance_minutes
|
record["claim_allowance_minutes"] = claim_allowance_minutes
|
||||||
due_days = getattr(c, 'due_days', []) or []
|
due_days = getattr(c, "due_days", []) or []
|
||||||
if due_days:
|
if due_days:
|
||||||
record["due_days"] = due_days
|
record["due_days"] = due_days
|
||||||
requires_approval = getattr(c, 'requires_approval', True)
|
requires_approval = getattr(c, "requires_approval", True)
|
||||||
if not requires_approval:
|
if not requires_approval:
|
||||||
record["requires_approval"] = False
|
record["requires_approval"] = False
|
||||||
# Mandatory chores (#532): emit only when set so the child card can
|
# Mandatory chores (#532): emit only when set so the child card can
|
||||||
# show the mandatory styling/badge. penalty rides along when non-zero.
|
# show the mandatory styling/badge. penalty rides along when non-zero.
|
||||||
if getattr(c, 'mandatory', False):
|
if getattr(c, "mandatory", False):
|
||||||
record["mandatory"] = True
|
record["mandatory"] = True
|
||||||
penalty = getattr(c, 'mandatory_penalty_points', 0) or 0
|
penalty = getattr(c, "mandatory_penalty_points", 0) or 0
|
||||||
if penalty:
|
if penalty:
|
||||||
record["mandatory_penalty_points"] = penalty
|
record["mandatory_penalty_points"] = penalty
|
||||||
if getattr(c, 'require_photo', False):
|
if getattr(c, "require_photo", False):
|
||||||
record["require_photo"] = True
|
record["require_photo"] = True
|
||||||
recurrence = getattr(c, 'recurrence', 'weekly')
|
recurrence = getattr(c, "recurrence", "weekly")
|
||||||
if recurrence != 'weekly':
|
if recurrence != "weekly":
|
||||||
record["recurrence"] = recurrence
|
record["recurrence"] = recurrence
|
||||||
recurrence_day = getattr(c, 'recurrence_day', '')
|
recurrence_day = getattr(c, "recurrence_day", "")
|
||||||
if recurrence_day:
|
if recurrence_day:
|
||||||
record["recurrence_day"] = recurrence_day
|
record["recurrence_day"] = recurrence_day
|
||||||
recurrence_start = getattr(c, 'recurrence_start', '')
|
recurrence_start = getattr(c, "recurrence_start", "")
|
||||||
if recurrence_start:
|
if recurrence_start:
|
||||||
record["recurrence_start"] = recurrence_start
|
record["recurrence_start"] = recurrence_start
|
||||||
visibility_entity = getattr(c, 'visibility_entity', '')
|
visibility_entity = getattr(c, "visibility_entity", "")
|
||||||
if visibility_entity:
|
if visibility_entity:
|
||||||
record["visibility_entity"] = visibility_entity
|
record["visibility_entity"] = visibility_entity
|
||||||
record["visibility_operator"] = getattr(c, 'visibility_operator', 'equals')
|
record["visibility_operator"] = getattr(c, "visibility_operator", "equals")
|
||||||
record["visibility_state"] = getattr(c, 'visibility_state', 'on')
|
record["visibility_state"] = getattr(c, "visibility_state", "on")
|
||||||
weather_entity = getattr(c, 'weather_entity', '')
|
weather_entity = getattr(c, "weather_entity", "")
|
||||||
if weather_entity:
|
if weather_entity:
|
||||||
record["weather_entity"] = weather_entity
|
record["weather_entity"] = weather_entity
|
||||||
record["weather_block_conditions"] = list(getattr(c, 'weather_block_conditions', []) or [])
|
record["weather_block_conditions"] = list(getattr(c, "weather_block_conditions", []) or [])
|
||||||
for limit in ("weather_temp_min", "weather_temp_max", "weather_wind_max"):
|
for limit in ("weather_temp_min", "weather_temp_max", "weather_wind_max"):
|
||||||
value = getattr(c, limit, None)
|
value = getattr(c, limit, None)
|
||||||
if value is not None:
|
if value is not None:
|
||||||
@@ -287,44 +286,41 @@ def _build_chores_list(coordinator: TaskMateCoordinator, common: dict) -> list[d
|
|||||||
reason = coordinator.weather_block_reason(c)
|
reason = coordinator.weather_block_reason(c)
|
||||||
if reason:
|
if reason:
|
||||||
record["weather_blocked"] = reason
|
record["weather_blocked"] = reason
|
||||||
deadline_at = getattr(c, 'deadline_at', '')
|
deadline_at = getattr(c, "deadline_at", "")
|
||||||
if deadline_at:
|
if deadline_at:
|
||||||
record["deadline_at"] = deadline_at
|
record["deadline_at"] = deadline_at
|
||||||
speed_bonus = getattr(c, 'speed_bonus_points', 0)
|
speed_bonus = getattr(c, "speed_bonus_points", 0)
|
||||||
if speed_bonus:
|
if speed_bonus:
|
||||||
record["speed_bonus_points"] = speed_bonus
|
record["speed_bonus_points"] = speed_bonus
|
||||||
disabled_for = getattr(c, 'disabled_for', [])
|
disabled_for = getattr(c, "disabled_for", [])
|
||||||
if disabled_for:
|
if disabled_for:
|
||||||
record["disabled_for"] = disabled_for
|
record["disabled_for"] = disabled_for
|
||||||
created_date = getattr(c, 'created_date', '')
|
created_date = getattr(c, "created_date", "")
|
||||||
if created_date:
|
if created_date:
|
||||||
record["created_date"] = created_date
|
record["created_date"] = created_date
|
||||||
assignment_current_child_id = getattr(c, 'assignment_current_child_id', '')
|
assignment_current_child_id = getattr(c, "assignment_current_child_id", "")
|
||||||
if assignment_current_child_id:
|
if assignment_current_child_id:
|
||||||
record["assignment_current_child_id"] = assignment_current_child_id
|
record["assignment_current_child_id"] = assignment_current_child_id
|
||||||
icon = getattr(c, 'icon', '')
|
icon = getattr(c, "icon", "")
|
||||||
if icon:
|
if icon:
|
||||||
record["icon"] = icon
|
record["icon"] = icon
|
||||||
# Signed so the card's <img> loads; emitted only when set, matching
|
# Signed so the card's <img> loads; emitted only when set, matching
|
||||||
# `icon` above, to keep records under the 16KB recorder limit.
|
# `icon` above, to keep records under the 16KB recorder limit.
|
||||||
image_url = getattr(c, 'image_url', '')
|
image_url = getattr(c, "image_url", "")
|
||||||
if image_url:
|
if image_url:
|
||||||
record["image_url"] = images.sign_image_url(common["hass"], image_url)
|
record["image_url"] = images.sign_image_url(common["hass"], image_url)
|
||||||
completion_sound = getattr(c, 'completion_sound', 'coin')
|
completion_sound = getattr(c, "completion_sound", "coin")
|
||||||
if completion_sound and completion_sound != 'coin':
|
if completion_sound and completion_sound != "coin":
|
||||||
record["completion_sound"] = completion_sound
|
record["completion_sound"] = completion_sound
|
||||||
task_type = getattr(c, 'task_type', 'standard')
|
task_type = getattr(c, "task_type", "standard")
|
||||||
if task_type == "timed":
|
if task_type == "timed":
|
||||||
record["task_type"] = "timed"
|
record["task_type"] = "timed"
|
||||||
record["timed_rate_points"] = getattr(c, 'timed_rate_points', 10)
|
record["timed_rate_points"] = getattr(c, "timed_rate_points", 10)
|
||||||
record["timed_rate_minutes"] = getattr(c, 'timed_rate_minutes', 5)
|
record["timed_rate_minutes"] = getattr(c, "timed_rate_minutes", 5)
|
||||||
record["timed_max_daily_minutes"] = getattr(c, 'timed_max_daily_minutes', 0)
|
record["timed_max_daily_minutes"] = getattr(c, "timed_max_daily_minutes", 0)
|
||||||
bonus_subtasks = getattr(c, 'bonus_subtasks', [])
|
bonus_subtasks = getattr(c, "bonus_subtasks", [])
|
||||||
if bonus_subtasks:
|
if bonus_subtasks:
|
||||||
record["bonus_subtasks"] = [
|
record["bonus_subtasks"] = [{"id": b.id, "name": b.name, "points": b.points} for b in bonus_subtasks]
|
||||||
{"id": b.id, "name": b.name, "points": b.points}
|
|
||||||
for b in bonus_subtasks
|
|
||||||
]
|
|
||||||
chores_list.append(record)
|
chores_list.append(record)
|
||||||
return chores_list
|
return chores_list
|
||||||
|
|
||||||
@@ -356,9 +352,9 @@ def _build_todays_completions(common: dict) -> list[dict]:
|
|||||||
out = []
|
out = []
|
||||||
for comp in common["all_completions"]:
|
for comp in common["all_completions"]:
|
||||||
comp_dt = comp.completed_at
|
comp_dt = comp.completed_at
|
||||||
if hasattr(comp_dt, 'astimezone'):
|
if hasattr(comp_dt, "astimezone"):
|
||||||
comp_dt = dt_util.as_local(comp_dt)
|
comp_dt = dt_util.as_local(comp_dt)
|
||||||
comp_date = comp_dt.date() if hasattr(comp_dt, 'date') else comp_dt
|
comp_date = comp_dt.date() if hasattr(comp_dt, "date") else comp_dt
|
||||||
if comp_date != today:
|
if comp_date != today:
|
||||||
continue
|
continue
|
||||||
matched_chore = chore_lookup.get(comp.chore_id)
|
matched_chore = chore_lookup.get(comp.chore_id)
|
||||||
@@ -379,11 +375,15 @@ def _build_todays_completions(common: dict) -> list[dict]:
|
|||||||
"completion_id": comp.id,
|
"completion_id": comp.id,
|
||||||
"chore_id": comp.chore_id,
|
"chore_id": comp.chore_id,
|
||||||
"child_id": comp.child_id,
|
"child_id": comp.child_id,
|
||||||
"child_name": "Parent" if comp.child_id == "__parent__" else (child_lookup[comp.child_id].name if comp.child_id in child_lookup else ""),
|
"child_name": "Parent"
|
||||||
|
if comp.child_id == "__parent__"
|
||||||
|
else (child_lookup[comp.child_id].name if comp.child_id in child_lookup else ""),
|
||||||
"chore_name": display_name,
|
"chore_name": display_name,
|
||||||
"points": display_points,
|
"points": display_points,
|
||||||
"approved": comp.approved,
|
"approved": comp.approved,
|
||||||
"completed_at": comp.completed_at.isoformat() if hasattr(comp.completed_at, 'isoformat') else str(comp.completed_at),
|
"completed_at": comp.completed_at.isoformat()
|
||||||
|
if hasattr(comp.completed_at, "isoformat")
|
||||||
|
else str(comp.completed_at),
|
||||||
"bonus_subtask_id": bonus_subtask_id,
|
"bonus_subtask_id": bonus_subtask_id,
|
||||||
}
|
}
|
||||||
if timed_secs > 0:
|
if timed_secs > 0:
|
||||||
@@ -402,7 +402,7 @@ def _build_active_timed_sessions(coordinator: TaskMateCoordinator) -> list[dict]
|
|||||||
sessions = coordinator.data.get("timed_sessions", [])
|
sessions = coordinator.data.get("timed_sessions", [])
|
||||||
out = []
|
out = []
|
||||||
for s in sessions:
|
for s in sessions:
|
||||||
if hasattr(s, 'state'):
|
if hasattr(s, "state"):
|
||||||
state = s.state
|
state = s.state
|
||||||
if state not in ("running", "paused"):
|
if state not in ("running", "paused"):
|
||||||
continue
|
continue
|
||||||
@@ -412,13 +412,15 @@ def _build_active_timed_sessions(coordinator: TaskMateCoordinator) -> list[dict]
|
|||||||
last_seg = segments[-1]
|
last_seg = segments[-1]
|
||||||
if isinstance(last_seg, dict) and last_seg.get("end") is None:
|
if isinstance(last_seg, dict) and last_seg.get("end") is None:
|
||||||
current_segment_start = last_seg.get("start", "")
|
current_segment_start = last_seg.get("start", "")
|
||||||
out.append({
|
out.append(
|
||||||
"chore_id": s.chore_id,
|
{
|
||||||
"child_id": s.child_id,
|
"chore_id": s.chore_id,
|
||||||
"state": state,
|
"child_id": s.child_id,
|
||||||
"total_seconds_today": s.total_seconds_today,
|
"state": state,
|
||||||
"current_segment_start": current_segment_start,
|
"total_seconds_today": s.total_seconds_today,
|
||||||
})
|
"current_segment_start": current_segment_start,
|
||||||
|
}
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
state = s.get("state", "stopped") if isinstance(s, dict) else "stopped"
|
state = s.get("state", "stopped") if isinstance(s, dict) else "stopped"
|
||||||
if state not in ("running", "paused"):
|
if state not in ("running", "paused"):
|
||||||
@@ -429,13 +431,15 @@ def _build_active_timed_sessions(coordinator: TaskMateCoordinator) -> list[dict]
|
|||||||
last_seg = segments[-1]
|
last_seg = segments[-1]
|
||||||
if isinstance(last_seg, dict) and last_seg.get("end") is None:
|
if isinstance(last_seg, dict) and last_seg.get("end") is None:
|
||||||
current_segment_start = last_seg.get("start", "")
|
current_segment_start = last_seg.get("start", "")
|
||||||
out.append({
|
out.append(
|
||||||
"chore_id": s.get("chore_id", "") if isinstance(s, dict) else "",
|
{
|
||||||
"child_id": s.get("child_id", "") if isinstance(s, dict) else "",
|
"chore_id": s.get("chore_id", "") if isinstance(s, dict) else "",
|
||||||
"state": state,
|
"child_id": s.get("child_id", "") if isinstance(s, dict) else "",
|
||||||
"total_seconds_today": s.get("total_seconds_today", 0) if isinstance(s, dict) else 0,
|
"state": state,
|
||||||
"current_segment_start": current_segment_start,
|
"total_seconds_today": s.get("total_seconds_today", 0) if isinstance(s, dict) else 0,
|
||||||
})
|
"current_segment_start": current_segment_start,
|
||||||
|
}
|
||||||
|
)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -448,20 +452,12 @@ def _build_rewards_list(common: dict) -> list[dict]:
|
|||||||
today = dt_util.now().date()
|
today = dt_util.now().date()
|
||||||
out = []
|
out = []
|
||||||
for r in rewards:
|
for r in rewards:
|
||||||
assigned = (
|
assigned = r.assigned_to if isinstance(r.assigned_to, list) and r.assigned_to else [c.id for c in children]
|
||||||
r.assigned_to
|
|
||||||
if isinstance(r.assigned_to, list) and r.assigned_to
|
|
||||||
else [c.id for c in children]
|
|
||||||
)
|
|
||||||
calculated_costs = {child_id: r.cost for child_id in assigned}
|
calculated_costs = {child_id: r.cost for child_id in assigned}
|
||||||
reward_pool_allocations = {
|
reward_pool_allocations = {cid: pool_by_child_reward.get(cid, {}).get(r.id, 0) for cid in assigned}
|
||||||
cid: pool_by_child_reward.get(cid, {}).get(r.id, 0) for cid in assigned
|
jackpot_pool_total = pool_total_by_reward.get(r.id, 0) if getattr(r, "is_jackpot", False) else None
|
||||||
}
|
quantity = getattr(r, "quantity", None)
|
||||||
jackpot_pool_total = (
|
expires_at = getattr(r, "expires_at", None)
|
||||||
pool_total_by_reward.get(r.id, 0) if getattr(r, 'is_jackpot', False) else None
|
|
||||||
)
|
|
||||||
quantity = getattr(r, 'quantity', None)
|
|
||||||
expires_at = getattr(r, 'expires_at', None)
|
|
||||||
is_sold_out = quantity is not None and quantity <= 0
|
is_sold_out = quantity is not None and quantity <= 0
|
||||||
is_expired = False
|
is_expired = False
|
||||||
days_until_expiry: int | None = None
|
days_until_expiry: int | None = None
|
||||||
@@ -472,25 +468,27 @@ def _build_rewards_list(common: dict) -> list[dict]:
|
|||||||
days_until_expiry = (deadline - today).days
|
days_until_expiry = (deadline - today).days
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
pass
|
pass
|
||||||
out.append({
|
out.append(
|
||||||
"id": r.id,
|
{
|
||||||
"name": r.name,
|
"id": r.id,
|
||||||
"cost": r.cost,
|
"name": r.name,
|
||||||
"description": getattr(r, 'description', ''),
|
"cost": r.cost,
|
||||||
"icon": r.icon,
|
"description": getattr(r, "description", ""),
|
||||||
"assigned_to": r.assigned_to if isinstance(r.assigned_to, list) else [],
|
"icon": r.icon,
|
||||||
"is_jackpot": getattr(r, 'is_jackpot', False),
|
"assigned_to": r.assigned_to if isinstance(r.assigned_to, list) else [],
|
||||||
"pool_enabled": getattr(r, 'pool_enabled', False),
|
"is_jackpot": getattr(r, "is_jackpot", False),
|
||||||
"calculated_costs": calculated_costs,
|
"pool_enabled": getattr(r, "pool_enabled", False),
|
||||||
"pool_allocations": reward_pool_allocations,
|
"calculated_costs": calculated_costs,
|
||||||
"jackpot_pool_total": jackpot_pool_total,
|
"pool_allocations": reward_pool_allocations,
|
||||||
"quantity": quantity,
|
"jackpot_pool_total": jackpot_pool_total,
|
||||||
"expires_at": expires_at,
|
"quantity": quantity,
|
||||||
"is_sold_out": is_sold_out,
|
"expires_at": expires_at,
|
||||||
"is_expired": is_expired,
|
"is_sold_out": is_sold_out,
|
||||||
"is_available": not (is_sold_out or is_expired),
|
"is_expired": is_expired,
|
||||||
"days_until_expiry": days_until_expiry,
|
"is_available": not (is_sold_out or is_expired),
|
||||||
})
|
"days_until_expiry": days_until_expiry,
|
||||||
|
}
|
||||||
|
)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -504,17 +502,19 @@ def _build_pending_reward_claims(common: dict) -> list[dict]:
|
|||||||
child = child_lookup.get(rc.child_id)
|
child = child_lookup.get(rc.child_id)
|
||||||
if not reward or not child:
|
if not reward or not child:
|
||||||
continue
|
continue
|
||||||
out.append({
|
out.append(
|
||||||
"claim_id": rc.id,
|
{
|
||||||
"reward_id": rc.reward_id,
|
"claim_id": rc.id,
|
||||||
"child_id": rc.child_id,
|
"reward_id": rc.reward_id,
|
||||||
"child_name": child.name,
|
"child_id": rc.child_id,
|
||||||
"child_avatar": getattr(child, 'avatar', 'mdi:account-circle') or 'mdi:account-circle',
|
"child_name": child.name,
|
||||||
"reward_name": reward.name,
|
"child_avatar": getattr(child, "avatar", "mdi:account-circle") or "mdi:account-circle",
|
||||||
"reward_icon": reward.icon or 'mdi:gift',
|
"reward_name": reward.name,
|
||||||
"cost": reward.cost,
|
"reward_icon": reward.icon or "mdi:gift",
|
||||||
"claimed_at": rc.claimed_at.isoformat() if hasattr(rc.claimed_at, 'isoformat') else str(rc.claimed_at),
|
"cost": reward.cost,
|
||||||
})
|
"claimed_at": rc.claimed_at.isoformat() if hasattr(rc.claimed_at, "isoformat") else str(rc.claimed_at),
|
||||||
|
}
|
||||||
|
)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -536,16 +536,22 @@ def _build_recent_completions(common: dict, limit: int = 35) -> list[dict]:
|
|||||||
rate_seconds = matched_chore.timed_rate_minutes * 60
|
rate_seconds = matched_chore.timed_rate_minutes * 60
|
||||||
if rate_seconds > 0:
|
if rate_seconds > 0:
|
||||||
display_points = (timed_secs // rate_seconds) * matched_chore.timed_rate_points
|
display_points = (timed_secs // rate_seconds) * matched_chore.timed_rate_points
|
||||||
out.append({
|
out.append(
|
||||||
"completion_id": comp.id,
|
{
|
||||||
"chore_id": comp.chore_id,
|
"completion_id": comp.id,
|
||||||
"child_id": comp.child_id,
|
"chore_id": comp.chore_id,
|
||||||
"child_name": "Parent" if comp.child_id == "__parent__" else (child_lookup[comp.child_id].name if comp.child_id in child_lookup else ""),
|
"child_id": comp.child_id,
|
||||||
"chore_name": matched_chore.name if matched_chore else "",
|
"child_name": "Parent"
|
||||||
"points": display_points,
|
if comp.child_id == "__parent__"
|
||||||
"approved": comp.approved,
|
else (child_lookup[comp.child_id].name if comp.child_id in child_lookup else ""),
|
||||||
"completed_at": comp.completed_at.isoformat() if hasattr(comp.completed_at, 'isoformat') else str(comp.completed_at),
|
"chore_name": matched_chore.name if matched_chore else "",
|
||||||
})
|
"points": display_points,
|
||||||
|
"approved": comp.approved,
|
||||||
|
"completed_at": comp.completed_at.isoformat()
|
||||||
|
if hasattr(comp.completed_at, "isoformat")
|
||||||
|
else str(comp.completed_at),
|
||||||
|
}
|
||||||
|
)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -558,21 +564,23 @@ def _build_photo_gallery(common: dict, limit: int = 40) -> list[dict]:
|
|||||||
"""
|
"""
|
||||||
child_lookup = common["child_lookup"]
|
child_lookup = common["child_lookup"]
|
||||||
chore_lookup = common["chore_lookup"]
|
chore_lookup = common["chore_lookup"]
|
||||||
with_photos = [
|
with_photos = [c for c in common["all_completions"] if getattr(c, "photo_url", "")]
|
||||||
c for c in common["all_completions"] if getattr(c, "photo_url", "")
|
|
||||||
]
|
|
||||||
recent = sorted(with_photos, key=lambda c: c.completed_at, reverse=True)[:limit]
|
recent = sorted(with_photos, key=lambda c: c.completed_at, reverse=True)[:limit]
|
||||||
out = []
|
out = []
|
||||||
for comp in recent:
|
for comp in recent:
|
||||||
chore = chore_lookup.get(comp.chore_id)
|
chore = chore_lookup.get(comp.chore_id)
|
||||||
out.append({
|
out.append(
|
||||||
"completion_id": comp.id,
|
{
|
||||||
"child_name": child_lookup[comp.child_id].name if comp.child_id in child_lookup else "",
|
"completion_id": comp.id,
|
||||||
"chore_name": chore.name if chore else "",
|
"child_name": child_lookup[comp.child_id].name if comp.child_id in child_lookup else "",
|
||||||
"approved": comp.approved,
|
"chore_name": chore.name if chore else "",
|
||||||
"completed_at": comp.completed_at.isoformat() if hasattr(comp.completed_at, "isoformat") else str(comp.completed_at),
|
"approved": comp.approved,
|
||||||
"photo_url": comp.photo_url,
|
"completed_at": comp.completed_at.isoformat()
|
||||||
})
|
if hasattr(comp.completed_at, "isoformat")
|
||||||
|
else str(comp.completed_at),
|
||||||
|
"photo_url": comp.photo_url,
|
||||||
|
}
|
||||||
|
)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -593,15 +601,17 @@ def _build_recent_transactions(common: dict, limit: int = 20) -> list[dict]:
|
|||||||
child = child_lookup.get(t.child_id)
|
child = child_lookup.get(t.child_id)
|
||||||
if not child:
|
if not child:
|
||||||
continue
|
continue
|
||||||
events.append({
|
events.append(
|
||||||
"transaction_id": t.id,
|
{
|
||||||
"type": "points_added" if t.points > 0 else "points_removed",
|
"transaction_id": t.id,
|
||||||
"child_id": t.child_id,
|
"type": "points_added" if t.points > 0 else "points_removed",
|
||||||
"child_name": child.name,
|
"child_id": t.child_id,
|
||||||
"points": t.points,
|
"child_name": child.name,
|
||||||
"reason": t.reason or "",
|
"points": t.points,
|
||||||
"created_at": t.created_at.isoformat() if hasattr(t.created_at, 'isoformat') else str(t.created_at),
|
"reason": t.reason or "",
|
||||||
})
|
"created_at": t.created_at.isoformat() if hasattr(t.created_at, "isoformat") else str(t.created_at),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
for rc in all_reward_claims:
|
for rc in all_reward_claims:
|
||||||
child = child_lookup.get(rc.child_id)
|
child = child_lookup.get(rc.child_id)
|
||||||
@@ -610,43 +620,51 @@ def _build_recent_transactions(common: dict, limit: int = 20) -> list[dict]:
|
|||||||
continue
|
continue
|
||||||
event_type = "reward_approved" if rc.approved else "reward_claimed"
|
event_type = "reward_approved" if rc.approved else "reward_claimed"
|
||||||
timestamp = rc.approved_at if rc.approved and rc.approved_at else rc.claimed_at
|
timestamp = rc.approved_at if rc.approved and rc.approved_at else rc.claimed_at
|
||||||
events.append({
|
events.append(
|
||||||
"transaction_id": rc.id,
|
{
|
||||||
"type": event_type,
|
"transaction_id": rc.id,
|
||||||
"child_id": rc.child_id,
|
"type": event_type,
|
||||||
"child_name": child.name,
|
"child_id": rc.child_id,
|
||||||
"reward_id": rc.reward_id,
|
"child_name": child.name,
|
||||||
"reward_name": reward.name,
|
"reward_id": rc.reward_id,
|
||||||
"reward_icon": reward.icon or "mdi:gift",
|
"reward_name": reward.name,
|
||||||
"points": -reward.cost,
|
"reward_icon": reward.icon or "mdi:gift",
|
||||||
"approved": rc.approved,
|
"points": -reward.cost,
|
||||||
"created_at": timestamp.isoformat() if hasattr(timestamp, 'isoformat') else str(timestamp),
|
"approved": rc.approved,
|
||||||
})
|
"created_at": timestamp.isoformat() if hasattr(timestamp, "isoformat") else str(timestamp),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
events.sort(key=lambda e: e["created_at"], reverse=True)
|
events.sort(key=lambda e: e["created_at"], reverse=True)
|
||||||
return events[:limit]
|
return events[:limit]
|
||||||
|
|
||||||
|
|
||||||
def _build_penalties_list(common: dict) -> list[dict]:
|
def _build_penalties_list(common: dict) -> list[dict]:
|
||||||
return [{
|
return [
|
||||||
"id": p.id,
|
{
|
||||||
"name": p.name,
|
"id": p.id,
|
||||||
"points": p.points,
|
"name": p.name,
|
||||||
"description": p.description,
|
"points": p.points,
|
||||||
"icon": p.icon,
|
"description": p.description,
|
||||||
"assigned_to": p.assigned_to or [],
|
"icon": p.icon,
|
||||||
} for p in common["data"].get("penalties", [])]
|
"assigned_to": p.assigned_to or [],
|
||||||
|
}
|
||||||
|
for p in common["data"].get("penalties", [])
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _build_bonuses_list(common: dict) -> list[dict]:
|
def _build_bonuses_list(common: dict) -> list[dict]:
|
||||||
return [{
|
return [
|
||||||
"id": b.id,
|
{
|
||||||
"name": b.name,
|
"id": b.id,
|
||||||
"points": b.points,
|
"name": b.name,
|
||||||
"description": b.description,
|
"points": b.points,
|
||||||
"icon": b.icon,
|
"description": b.description,
|
||||||
"assigned_to": b.assigned_to or [],
|
"icon": b.icon,
|
||||||
} for b in common["data"].get("bonuses", [])]
|
"assigned_to": b.assigned_to or [],
|
||||||
|
}
|
||||||
|
for b in common["data"].get("bonuses", [])
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
@@ -795,8 +813,10 @@ class TaskMateOverallStatsSensor(_CachedAttrsSensor):
|
|||||||
|
|
||||||
# Legacy shape kept for cards that still read the four fixed keys.
|
# Legacy shape kept for cards that still read the four fixed keys.
|
||||||
legacy_defaults = {
|
legacy_defaults = {
|
||||||
"morning": ("06:00", "12:00"), "afternoon": ("12:00", "17:00"),
|
"morning": ("06:00", "12:00"),
|
||||||
"evening": ("17:00", "21:00"), "night": ("21:00", "23:59"),
|
"afternoon": ("12:00", "17:00"),
|
||||||
|
"evening": ("17:00", "21:00"),
|
||||||
|
"night": ("21:00", "23:59"),
|
||||||
}
|
}
|
||||||
time_boundaries = {}
|
time_boundaries = {}
|
||||||
for cat, (def_start, def_end) in legacy_defaults.items():
|
for cat, (def_start, def_end) in legacy_defaults.items():
|
||||||
@@ -813,7 +833,8 @@ class TaskMateOverallStatsSensor(_CachedAttrsSensor):
|
|||||||
"perfect_week_enabled": settings.get("perfect_week_enabled", "true") == "true",
|
"perfect_week_enabled": settings.get("perfect_week_enabled", "true") == "true",
|
||||||
"perfect_week_bonus": _safe_int(settings.get("perfect_week_bonus"), 50),
|
"perfect_week_bonus": _safe_int(settings.get("perfect_week_bonus"), 50),
|
||||||
"streak_requires_all_chores": settings.get("streak_requires_all_chores", "false") in (True, "true"),
|
"streak_requires_all_chores": settings.get("streak_requires_all_chores", "false") in (True, "true"),
|
||||||
"perfect_week_requires_all_chores": settings.get("perfect_week_requires_all_chores", "false") in (True, "true"),
|
"perfect_week_requires_all_chores": settings.get("perfect_week_requires_all_chores", "false")
|
||||||
|
in (True, "true"),
|
||||||
"total_children": len(children),
|
"total_children": len(children),
|
||||||
"total_chores": len(chores),
|
"total_chores": len(chores),
|
||||||
"total_rewards": len(rewards),
|
"total_rewards": len(rewards),
|
||||||
@@ -1117,6 +1138,7 @@ class ChildStatsSensor(TaskMateBaseSensor):
|
|||||||
# drop it once any pool member has completed it today (so a parent
|
# drop it once any pool member has completed it today (so a parent
|
||||||
# crediting the off-rotation child clears the chore for everyone).
|
# crediting the off-rotation child clears the chore for everyone).
|
||||||
chores = self.coordinator.data.get("chores", [])
|
chores = self.coordinator.data.get("chores", [])
|
||||||
|
|
||||||
def _included(c):
|
def _included(c):
|
||||||
if not (child.id in c.assigned_to or not c.assigned_to):
|
if not (child.id in c.assigned_to or not c.assigned_to):
|
||||||
return False
|
return False
|
||||||
@@ -1128,6 +1150,7 @@ class ChildStatsSensor(TaskMateBaseSensor):
|
|||||||
if self.coordinator._is_rotation_done_today(c):
|
if self.coordinator._is_rotation_done_today(c):
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
assigned_chores = [c for c in chores if _included(c)]
|
assigned_chores = [c for c in chores if _included(c)]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1141,7 +1164,10 @@ class ChildStatsSensor(TaskMateBaseSensor):
|
|||||||
"best_streak": child.best_streak,
|
"best_streak": child.best_streak,
|
||||||
"career_score": child.career_score,
|
"career_score": child.career_score,
|
||||||
"total_penalties_received": child.total_penalties_received,
|
"total_penalties_received": child.total_penalties_received,
|
||||||
"assigned_chores": [{"id": c.id, "name": c.name, "points": c.points, "time_category": c.time_category} for c in assigned_chores],
|
"assigned_chores": [
|
||||||
|
{"id": c.id, "name": c.name, "points": c.points, "time_category": c.time_category}
|
||||||
|
for c in assigned_chores
|
||||||
|
],
|
||||||
"chore_order": child.chore_order,
|
"chore_order": child.chore_order,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1166,9 +1192,7 @@ class ChildBadgesSensor(TaskMateBaseSensor):
|
|||||||
@property
|
@property
|
||||||
def native_value(self) -> int:
|
def native_value(self) -> int:
|
||||||
"""Number of badges earned by this child."""
|
"""Number of badges earned by this child."""
|
||||||
return len(
|
return len(self.coordinator.storage.get_awarded_badges_for_child(self.child_id))
|
||||||
self.coordinator.storage.get_awarded_badges_for_child(self.child_id)
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def extra_state_attributes(self) -> dict:
|
def extra_state_attributes(self) -> dict:
|
||||||
@@ -1180,10 +1204,7 @@ class ChildBadgesSensor(TaskMateBaseSensor):
|
|||||||
return {"earned": [], "available": [], "total_badges": 0}
|
return {"earned": [], "available": [], "total_badges": 0}
|
||||||
|
|
||||||
all_badges = [b for b in storage.get_badges() if b.enabled]
|
all_badges = [b for b in storage.get_badges() if b.enabled]
|
||||||
applicable = [
|
applicable = [b for b in all_badges if not b.assigned_to or self.child_id in b.assigned_to]
|
||||||
b for b in all_badges
|
|
||||||
if not b.assigned_to or self.child_id in b.assigned_to
|
|
||||||
]
|
|
||||||
|
|
||||||
awarded_records = storage.get_awarded_badges_for_child(self.child_id)
|
awarded_records = storage.get_awarded_badges_for_child(self.child_id)
|
||||||
record_by_id = {a.badge_id: a for a in awarded_records}
|
record_by_id = {a.badge_id: a for a in awarded_records}
|
||||||
@@ -1193,35 +1214,46 @@ class ChildBadgesSensor(TaskMateBaseSensor):
|
|||||||
for b in applicable:
|
for b in applicable:
|
||||||
if b.id in record_by_id:
|
if b.id in record_by_id:
|
||||||
rec = record_by_id[b.id]
|
rec = record_by_id[b.id]
|
||||||
earned.append({
|
earned.append(
|
||||||
"badge_id": b.id,
|
{
|
||||||
"name": b.name,
|
"badge_id": b.id,
|
||||||
"icon": b.icon,
|
"name": b.name,
|
||||||
"tier": b.tier,
|
"icon": b.icon,
|
||||||
"earned_at": rec.earned_at.isoformat() if rec.earned_at else None,
|
"tier": b.tier,
|
||||||
"manually_awarded": rec.manually_awarded,
|
"earned_at": rec.earned_at.isoformat() if rec.earned_at else None,
|
||||||
"silent": rec.silent,
|
"manually_awarded": rec.manually_awarded,
|
||||||
})
|
"silent": rec.silent,
|
||||||
|
}
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
if not b.criteria:
|
# The criterion that actually gates the award: for an AND badge
|
||||||
|
# that is the weakest one, for an OR badge the strongest. Using
|
||||||
|
# min() for both understated OR badges (and could report <100%
|
||||||
|
# for one already earned) — see coord_badges.BadgeEvaluator.
|
||||||
|
combinator = (getattr(b, "combinator", "AND") or "AND").upper()
|
||||||
|
scored = []
|
||||||
|
for c in b.criteria:
|
||||||
|
cur = resolve_metric(c.metric, child, storage)
|
||||||
|
pct = min(100, int(100 * cur / max(c.value, 1)))
|
||||||
|
scored.append((pct, c.metric, cur, c.value))
|
||||||
|
if not scored:
|
||||||
progress_pct = 0
|
progress_pct = 0
|
||||||
|
closest = None
|
||||||
else:
|
else:
|
||||||
pcts = []
|
pick = max(scored) if combinator == "OR" else min(scored)
|
||||||
for c in b.criteria:
|
progress_pct = pick[0]
|
||||||
cur = resolve_metric(c.metric, child, storage)
|
closest = {"metric": pick[1], "current": pick[2], "target": pick[3]}
|
||||||
target = max(c.value, 1)
|
available.append(
|
||||||
pcts.append(min(100, int(100 * cur / target)))
|
{
|
||||||
progress_pct = min(pcts) if pcts else 0
|
"badge_id": b.id,
|
||||||
available.append({
|
"name": b.name,
|
||||||
"badge_id": b.id,
|
"icon": b.icon,
|
||||||
"name": b.name,
|
"tier": b.tier,
|
||||||
"icon": b.icon,
|
"progress_pct": progress_pct,
|
||||||
"tier": b.tier,
|
"closest_criterion": closest,
|
||||||
"progress_pct": progress_pct,
|
"criteria_summary": ", ".join(f"{c.metric} >= {c.value}" for c in b.criteria),
|
||||||
"criteria_summary": ", ".join(
|
}
|
||||||
f"{c.metric} >= {c.value}" for c in b.criteria
|
)
|
||||||
),
|
|
||||||
})
|
|
||||||
|
|
||||||
earned.sort(key=lambda e: e.get("earned_at") or "", reverse=True)
|
earned.sort(key=lambda e: e.get("earned_at") or "", reverse=True)
|
||||||
|
|
||||||
@@ -1274,9 +1306,7 @@ class PendingApprovalsSensor(TaskMateBaseSensor):
|
|||||||
# list render them identically.
|
# list render them identically.
|
||||||
bonus_subtask_id = getattr(comp, "bonus_subtask_id", "") or ""
|
bonus_subtask_id = getattr(comp, "bonus_subtask_id", "") or ""
|
||||||
if bonus_subtask_id:
|
if bonus_subtask_id:
|
||||||
subtask = next(
|
subtask = next((b for b in chore.bonus_subtasks if b.id == bonus_subtask_id), None)
|
||||||
(b for b in chore.bonus_subtasks if b.id == bonus_subtask_id), None
|
|
||||||
)
|
|
||||||
chore_name = f"{chore.name} › {subtask.name}" if subtask else chore.name
|
chore_name = f"{chore.name} › {subtask.name}" if subtask else chore.name
|
||||||
pts = subtask.points if subtask else 0
|
pts = subtask.points if subtask else 0
|
||||||
else:
|
else:
|
||||||
@@ -1303,9 +1333,7 @@ class PendingApprovalsSensor(TaskMateBaseSensor):
|
|||||||
detail["timed_duration_seconds"] = timed_secs
|
detail["timed_duration_seconds"] = timed_secs
|
||||||
photo = getattr(comp, "photo_url", "") or ""
|
photo = getattr(comp, "photo_url", "") or ""
|
||||||
if photo:
|
if photo:
|
||||||
detail["photo_url"] = photos.sign_photo_url(
|
detail["photo_url"] = photos.sign_photo_url(self.coordinator.hass, photo)
|
||||||
self.coordinator.hass, photo
|
|
||||||
)
|
|
||||||
completion_details.append(detail)
|
completion_details.append(detail)
|
||||||
|
|
||||||
reward_details = []
|
reward_details = []
|
||||||
@@ -1313,16 +1341,18 @@ class PendingApprovalsSensor(TaskMateBaseSensor):
|
|||||||
child = self.coordinator.get_child(claim.child_id)
|
child = self.coordinator.get_child(claim.child_id)
|
||||||
reward = self.coordinator.get_reward(claim.reward_id)
|
reward = self.coordinator.get_reward(claim.reward_id)
|
||||||
if child and reward:
|
if child and reward:
|
||||||
reward_details.append({
|
reward_details.append(
|
||||||
"claim_id": claim.id,
|
{
|
||||||
"type": "reward",
|
"claim_id": claim.id,
|
||||||
"child_name": child.name,
|
"type": "reward",
|
||||||
"child_id": child.id,
|
"child_name": child.name,
|
||||||
"reward_name": reward.name,
|
"child_id": child.id,
|
||||||
"reward_id": reward.id,
|
"reward_name": reward.name,
|
||||||
"cost": reward.cost,
|
"reward_id": reward.id,
|
||||||
"claimed_at": claim.claimed_at.isoformat(),
|
"cost": reward.cost,
|
||||||
})
|
"claimed_at": claim.claimed_at.isoformat(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
mandatory_misses = self.coordinator.mandatory_misses_state()
|
mandatory_misses = self.coordinator.mandatory_misses_state()
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Storage management for TaskMate integration."""
|
"""Storage management for TaskMate integration."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -120,6 +121,15 @@ class TaskMateStorage:
|
|||||||
if "scheduled_changes" not in self._data:
|
if "scheduled_changes" not in self._data:
|
||||||
self._data["scheduled_changes"] = []
|
self._data["scheduled_changes"] = []
|
||||||
|
|
||||||
|
# Drop settled swap requests (#783) and ones pointing at a chore or child
|
||||||
|
# that no longer exists (#785). Approval used to only flip a status flag,
|
||||||
|
# and deletes did not cascade, so existing installs carry both kinds —
|
||||||
|
# the orphans surface in the parent's approval queue as an unclearable
|
||||||
|
# "? wants to swap ?" row. Runs on every load rather than behind a
|
||||||
|
# one-shot flag: it is a cheap list filter, and it also sweeps up records
|
||||||
|
# left by a downgrade to a version without the cascades.
|
||||||
|
self._drop_dead_swap_requests()
|
||||||
|
|
||||||
# Notifications overhaul (v3.9.0)
|
# Notifications overhaul (v3.9.0)
|
||||||
if "parent_recipients" not in self._data:
|
if "parent_recipients" not in self._data:
|
||||||
self._data["parent_recipients"] = []
|
self._data["parent_recipients"] = []
|
||||||
@@ -182,8 +192,8 @@ class TaskMateStorage:
|
|||||||
self._data["_pool_semantics_version"] = 2
|
self._data["_pool_semantics_version"] = 2
|
||||||
if adjusted:
|
if adjusted:
|
||||||
_LOGGER.info(
|
_LOGGER.info(
|
||||||
"TaskMate: migrated %d pool allocation(s) to beta2 semantics "
|
"TaskMate: migrated %d pool allocation(s) to beta2 semantics (points now deducted at allocation time)",
|
||||||
"(points now deducted at allocation time)", adjusted
|
adjusted,
|
||||||
)
|
)
|
||||||
await self.async_save()
|
await self.async_save()
|
||||||
|
|
||||||
@@ -232,15 +242,13 @@ class TaskMateStorage:
|
|||||||
"Migrating chore '%s' assigned_to: '%s' -> '%s' (name to ID)",
|
"Migrating chore '%s' assigned_to: '%s' -> '%s' (name to ID)",
|
||||||
chore.get("name", "unknown"),
|
chore.get("name", "unknown"),
|
||||||
assignment,
|
assignment,
|
||||||
name_to_id[assignment]
|
name_to_id[assignment],
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Unknown value, keep it but log a warning
|
# Unknown value, keep it but log a warning
|
||||||
new_assigned_to.append(assignment)
|
new_assigned_to.append(assignment)
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
"Chore '%s' has unknown assigned_to value: '%s'",
|
"Chore '%s' has unknown assigned_to value: '%s'", chore.get("name", "unknown"), assignment
|
||||||
chore.get("name", "unknown"),
|
|
||||||
assignment
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if chore_modified:
|
if chore_modified:
|
||||||
@@ -269,10 +277,7 @@ class TaskMateStorage:
|
|||||||
|
|
||||||
self._data["_career_score_initialized"] = True
|
self._data["_career_score_initialized"] = True
|
||||||
if children:
|
if children:
|
||||||
_LOGGER.info(
|
_LOGGER.info("TaskMate: initialized career_score for %d child(ren) from total_points_earned", len(children))
|
||||||
"TaskMate: initialized career_score for %d child(ren) "
|
|
||||||
"from total_points_earned", len(children)
|
|
||||||
)
|
|
||||||
await self.async_save()
|
await self.async_save()
|
||||||
|
|
||||||
async def async_save(self) -> None:
|
async def async_save(self) -> None:
|
||||||
@@ -336,9 +341,7 @@ class TaskMateStorage:
|
|||||||
|
|
||||||
def remove_child(self, child_id: str) -> None:
|
def remove_child(self, child_id: str) -> None:
|
||||||
"""Remove a child and cascade-delete their awarded badges."""
|
"""Remove a child and cascade-delete their awarded badges."""
|
||||||
self._data["children"] = [
|
self._data["children"] = [c for c in self._data.get("children", []) if c.get("id") != child_id]
|
||||||
c for c in self._data.get("children", []) if c.get("id") != child_id
|
|
||||||
]
|
|
||||||
self.remove_awards_for_child(child_id)
|
self.remove_awards_for_child(child_id)
|
||||||
|
|
||||||
# Chores management
|
# Chores management
|
||||||
@@ -370,9 +373,7 @@ class TaskMateStorage:
|
|||||||
|
|
||||||
def remove_chore(self, chore_id: str) -> None:
|
def remove_chore(self, chore_id: str) -> None:
|
||||||
"""Remove a chore."""
|
"""Remove a chore."""
|
||||||
self._data["chores"] = [
|
self._data["chores"] = [c for c in self._data.get("chores", []) if c.get("id") != chore_id]
|
||||||
c for c in self._data.get("chores", []) if c.get("id") != chore_id
|
|
||||||
]
|
|
||||||
order = self._data.get("chore_display_order", [])
|
order = self._data.get("chore_display_order", [])
|
||||||
if chore_id in order:
|
if chore_id in order:
|
||||||
order.remove(chore_id)
|
order.remove(chore_id)
|
||||||
@@ -414,9 +415,7 @@ class TaskMateStorage:
|
|||||||
|
|
||||||
def remove_reward(self, reward_id: str) -> None:
|
def remove_reward(self, reward_id: str) -> None:
|
||||||
"""Remove a reward."""
|
"""Remove a reward."""
|
||||||
self._data["rewards"] = [
|
self._data["rewards"] = [r for r in self._data.get("rewards", []) if r.get("id") != reward_id]
|
||||||
r for r in self._data.get("rewards", []) if r.get("id") != reward_id
|
|
||||||
]
|
|
||||||
|
|
||||||
# Completions management
|
# Completions management
|
||||||
def get_completions(self) -> list[ChoreCompletion]:
|
def get_completions(self) -> list[ChoreCompletion]:
|
||||||
@@ -447,9 +446,7 @@ class TaskMateStorage:
|
|||||||
|
|
||||||
def remove_completion(self, completion_id: str) -> None:
|
def remove_completion(self, completion_id: str) -> None:
|
||||||
"""Remove a completion record."""
|
"""Remove a completion record."""
|
||||||
self._data["completions"] = [
|
self._data["completions"] = [c for c in self._data.get("completions", []) if c.get("id") != completion_id]
|
||||||
c for c in self._data.get("completions", []) if c.get("id") != completion_id
|
|
||||||
]
|
|
||||||
|
|
||||||
# Mandatory-miss management (#532)
|
# Mandatory-miss management (#532)
|
||||||
def get_mandatory_misses(self) -> list[MandatoryMiss]:
|
def get_mandatory_misses(self) -> list[MandatoryMiss]:
|
||||||
@@ -470,9 +467,7 @@ class TaskMateStorage:
|
|||||||
|
|
||||||
def remove_mandatory_miss(self, miss_id: str) -> None:
|
def remove_mandatory_miss(self, miss_id: str) -> None:
|
||||||
"""Remove a mandatory-miss item by id."""
|
"""Remove a mandatory-miss item by id."""
|
||||||
self._data["mandatory_misses"] = [
|
self._data["mandatory_misses"] = [m for m in self._data.get("mandatory_misses", []) if m.get("id") != miss_id]
|
||||||
m for m in self._data.get("mandatory_misses", []) if m.get("id") != miss_id
|
|
||||||
]
|
|
||||||
|
|
||||||
def replace_mandatory_misses(self, misses: list[MandatoryMiss]) -> None:
|
def replace_mandatory_misses(self, misses: list[MandatoryMiss]) -> None:
|
||||||
"""Replace the whole mandatory-miss collection."""
|
"""Replace the whole mandatory-miss collection."""
|
||||||
@@ -507,9 +502,7 @@ class TaskMateStorage:
|
|||||||
|
|
||||||
def remove_reward_claim(self, claim_id: str) -> None:
|
def remove_reward_claim(self, claim_id: str) -> None:
|
||||||
"""Remove a reward claim."""
|
"""Remove a reward claim."""
|
||||||
self._data["reward_claims"] = [
|
self._data["reward_claims"] = [c for c in self._data.get("reward_claims", []) if c.get("id") != claim_id]
|
||||||
c for c in self._data.get("reward_claims", []) if c.get("id") != claim_id
|
|
||||||
]
|
|
||||||
|
|
||||||
# Penalties management
|
# Penalties management
|
||||||
def get_penalties(self) -> list[Penalty]:
|
def get_penalties(self) -> list[Penalty]:
|
||||||
@@ -538,9 +531,7 @@ class TaskMateStorage:
|
|||||||
|
|
||||||
def remove_penalty(self, penalty_id: str) -> None:
|
def remove_penalty(self, penalty_id: str) -> None:
|
||||||
"""Remove a penalty."""
|
"""Remove a penalty."""
|
||||||
self._data["penalties"] = [
|
self._data["penalties"] = [p for p in self._data.get("penalties", []) if p.get("id") != penalty_id]
|
||||||
p for p in self._data.get("penalties", []) if p.get("id") != penalty_id
|
|
||||||
]
|
|
||||||
|
|
||||||
# Bonuses management
|
# Bonuses management
|
||||||
def get_bonuses(self) -> list[Bonus]:
|
def get_bonuses(self) -> list[Bonus]:
|
||||||
@@ -569,9 +560,7 @@ class TaskMateStorage:
|
|||||||
|
|
||||||
def remove_bonus(self, bonus_id: str) -> None:
|
def remove_bonus(self, bonus_id: str) -> None:
|
||||||
"""Remove a bonus."""
|
"""Remove a bonus."""
|
||||||
self._data["bonuses"] = [
|
self._data["bonuses"] = [b for b in self._data.get("bonuses", []) if b.get("id") != bonus_id]
|
||||||
b for b in self._data.get("bonuses", []) if b.get("id") != bonus_id
|
|
||||||
]
|
|
||||||
|
|
||||||
# Badges management
|
# Badges management
|
||||||
def get_badges(self) -> list[Badge]:
|
def get_badges(self) -> list[Badge]:
|
||||||
@@ -600,9 +589,7 @@ class TaskMateStorage:
|
|||||||
|
|
||||||
def remove_badge(self, badge_id: str) -> None:
|
def remove_badge(self, badge_id: str) -> None:
|
||||||
"""Remove a badge and cascade-delete its awards."""
|
"""Remove a badge and cascade-delete its awards."""
|
||||||
self._data["badges"] = [
|
self._data["badges"] = [b for b in self._data.get("badges", []) if b.get("id") != badge_id]
|
||||||
b for b in self._data.get("badges", []) if b.get("id") != badge_id
|
|
||||||
]
|
|
||||||
self.remove_awards_for_badge(badge_id)
|
self.remove_awards_for_badge(badge_id)
|
||||||
|
|
||||||
# Awarded badges management
|
# Awarded badges management
|
||||||
@@ -620,9 +607,7 @@ class TaskMateStorage:
|
|||||||
|
|
||||||
def remove_awarded_badge(self, awarded_id: str) -> None:
|
def remove_awarded_badge(self, awarded_id: str) -> None:
|
||||||
"""Remove an awarded-badge record by id."""
|
"""Remove an awarded-badge record by id."""
|
||||||
self._data["awarded_badges"] = [
|
self._data["awarded_badges"] = [a for a in self._data.get("awarded_badges", []) if a.get("id") != awarded_id]
|
||||||
a for a in self._data.get("awarded_badges", []) if a.get("id") != awarded_id
|
|
||||||
]
|
|
||||||
|
|
||||||
def remove_awards_for_badge(self, badge_id: str) -> None:
|
def remove_awards_for_badge(self, badge_id: str) -> None:
|
||||||
"""Cascade-delete all awards referencing a badge id."""
|
"""Cascade-delete all awards referencing a badge id."""
|
||||||
@@ -705,9 +690,7 @@ class TaskMateStorage:
|
|||||||
cfg = NotificationConfig(
|
cfg = NotificationConfig(
|
||||||
type_id=tid,
|
type_id=tid,
|
||||||
master_enabled=True,
|
master_enabled=True,
|
||||||
routes={
|
routes={seeded_parent_id: NotificationRoute(enabled=True)} if seeded_parent_id else {},
|
||||||
seeded_parent_id: NotificationRoute(enabled=True)
|
|
||||||
} if seeded_parent_id else {},
|
|
||||||
)
|
)
|
||||||
nc[tid] = cfg.to_dict()
|
nc[tid] = cfg.to_dict()
|
||||||
|
|
||||||
@@ -728,8 +711,7 @@ class TaskMateStorage:
|
|||||||
|
|
||||||
def delete_parent_recipient(self, parent_id: str) -> None:
|
def delete_parent_recipient(self, parent_id: str) -> None:
|
||||||
self._data["parent_recipients"] = [
|
self._data["parent_recipients"] = [
|
||||||
r for r in self._data.get("parent_recipients", [])
|
r for r in self._data.get("parent_recipients", []) if r.get("id") != parent_id
|
||||||
if r.get("id") != parent_id
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# --- notification config ---
|
# --- notification config ---
|
||||||
@@ -749,9 +731,7 @@ class TaskMateStorage:
|
|||||||
cfg.nav_url = nav_url
|
cfg.nav_url = nav_url
|
||||||
self._data.setdefault("notification_config", {})[type_id] = cfg.to_dict()
|
self._data.setdefault("notification_config", {})[type_id] = cfg.to_dict()
|
||||||
|
|
||||||
def set_notification_route(
|
def set_notification_route(self, type_id: str, recipient_id: str, route: NotificationRoute) -> None:
|
||||||
self, type_id: str, recipient_id: str, route: NotificationRoute
|
|
||||||
) -> None:
|
|
||||||
cfg = self.get_notification_config(type_id)
|
cfg = self.get_notification_config(type_id)
|
||||||
cfg.routes[recipient_id] = route
|
cfg.routes[recipient_id] = route
|
||||||
self._data.setdefault("notification_config", {})[type_id] = cfg.to_dict()
|
self._data.setdefault("notification_config", {})[type_id] = cfg.to_dict()
|
||||||
@@ -764,10 +744,7 @@ class TaskMateStorage:
|
|||||||
|
|
||||||
# --- custom notifications ---
|
# --- custom notifications ---
|
||||||
def get_custom_notifications(self) -> list[CustomNotification]:
|
def get_custom_notifications(self) -> list[CustomNotification]:
|
||||||
return [
|
return [CustomNotification.from_dict(d) for d in self._data.get("custom_notifications", [])]
|
||||||
CustomNotification.from_dict(d)
|
|
||||||
for d in self._data.get("custom_notifications", [])
|
|
||||||
]
|
|
||||||
|
|
||||||
def upsert_custom_notification(self, n: CustomNotification) -> None:
|
def upsert_custom_notification(self, n: CustomNotification) -> None:
|
||||||
rows = self._data.setdefault("custom_notifications", [])
|
rows = self._data.setdefault("custom_notifications", [])
|
||||||
@@ -779,15 +756,12 @@ class TaskMateStorage:
|
|||||||
|
|
||||||
def delete_custom_notification(self, custom_id: str) -> None:
|
def delete_custom_notification(self, custom_id: str) -> None:
|
||||||
self._data["custom_notifications"] = [
|
self._data["custom_notifications"] = [
|
||||||
r for r in self._data.get("custom_notifications", [])
|
r for r in self._data.get("custom_notifications", []) if r.get("id") != custom_id
|
||||||
if r.get("id") != custom_id
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# --- streak-at-risk cutoff ---
|
# --- streak-at-risk cutoff ---
|
||||||
def get_streak_at_risk_cutoff(self) -> str:
|
def get_streak_at_risk_cutoff(self) -> str:
|
||||||
return (self._data.get("settings", {}) or {}).get(
|
return (self._data.get("settings", {}) or {}).get("streak_at_risk_cutoff_time", "20:00")
|
||||||
"streak_at_risk_cutoff_time", "20:00"
|
|
||||||
)
|
|
||||||
|
|
||||||
def set_streak_at_risk_cutoff(self, hhmm: str) -> None:
|
def set_streak_at_risk_cutoff(self, hhmm: str) -> None:
|
||||||
self._data.setdefault("settings", {})["streak_at_risk_cutoff_time"] = hhmm
|
self._data.setdefault("settings", {})["streak_at_risk_cutoff_time"] = hhmm
|
||||||
@@ -811,16 +785,14 @@ class TaskMateStorage:
|
|||||||
def get_escalation_reminder_minutes(self) -> int:
|
def get_escalation_reminder_minutes(self) -> int:
|
||||||
"""Minutes after a mandatory miss before the child reminder escalates."""
|
"""Minutes after a mandatory miss before the child reminder escalates."""
|
||||||
try:
|
try:
|
||||||
return max(1, int((self._data.get("settings", {}) or {}).get(
|
return max(1, int((self._data.get("settings", {}) or {}).get("mandatory_escalation_reminder_minutes", 30)))
|
||||||
"mandatory_escalation_reminder_minutes", 30)))
|
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return 30
|
return 30
|
||||||
|
|
||||||
def get_escalation_parent_minutes(self) -> int:
|
def get_escalation_parent_minutes(self) -> int:
|
||||||
"""Minutes after a mandatory miss before the parent alert escalates."""
|
"""Minutes after a mandatory miss before the parent alert escalates."""
|
||||||
try:
|
try:
|
||||||
return max(1, int((self._data.get("settings", {}) or {}).get(
|
return max(1, int((self._data.get("settings", {}) or {}).get("mandatory_escalation_parent_minutes", 120)))
|
||||||
"mandatory_escalation_parent_minutes", 120)))
|
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return 120
|
return 120
|
||||||
|
|
||||||
@@ -863,9 +835,7 @@ class TaskMateStorage:
|
|||||||
|
|
||||||
def remove_task_group(self, group_id: str) -> None:
|
def remove_task_group(self, group_id: str) -> None:
|
||||||
"""Remove a task group."""
|
"""Remove a task group."""
|
||||||
self._data["task_groups"] = [
|
self._data["task_groups"] = [g for g in self._data.get("task_groups", []) if g.get("id") != group_id]
|
||||||
g for g in self._data.get("task_groups", []) if g.get("id") != group_id
|
|
||||||
]
|
|
||||||
|
|
||||||
def remove_chore_from_task_groups(self, chore_id: str) -> None:
|
def remove_chore_from_task_groups(self, chore_id: str) -> None:
|
||||||
"""Strip a chore ID from every group (used on chore delete)."""
|
"""Strip a chore ID from every group (used on chore delete)."""
|
||||||
@@ -921,9 +891,7 @@ class TaskMateStorage:
|
|||||||
# the single choke point all awards flow through — the rolling 200-cap on
|
# the single choke point all awards flow through — the rolling 200-cap on
|
||||||
# transactions makes them unreliable for a monthly total (FEAT-2).
|
# transactions makes them unreliable for a monthly total (FEAT-2).
|
||||||
if transaction.points > 0:
|
if transaction.points > 0:
|
||||||
self.record_season_points(
|
self.record_season_points(transaction.child_id, transaction.points, transaction.created_at)
|
||||||
transaction.child_id, transaction.points, transaction.created_at
|
|
||||||
)
|
|
||||||
|
|
||||||
# Keep only the last 200 transactions to avoid unbounded storage growth
|
# Keep only the last 200 transactions to avoid unbounded storage growth
|
||||||
if len(self._data["points_transactions"]) > 200:
|
if len(self._data["points_transactions"]) > 200:
|
||||||
@@ -1019,6 +987,57 @@ class TaskMateStorage:
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _drop_dead_swap_requests(self) -> None:
|
||||||
|
"""Prune swap requests that are settled or dangling (#783, #785).
|
||||||
|
|
||||||
|
Called from ``async_load``. A request survives only when it is still
|
||||||
|
pending *and* every id it names still resolves — the chore, the
|
||||||
|
requester, and ``from_child_id`` when set (it is "" for a chore with no
|
||||||
|
cached assignee yet, which is a normal request, not a dead reference).
|
||||||
|
"""
|
||||||
|
swap_requests = self._data.get("swap_requests")
|
||||||
|
if not swap_requests:
|
||||||
|
return
|
||||||
|
|
||||||
|
chore_ids = {c.get("id") for c in self._data.get("chores", [])}
|
||||||
|
child_ids = {c.get("id") for c in self._data.get("children", [])}
|
||||||
|
|
||||||
|
def _alive(req: dict) -> bool:
|
||||||
|
if req.get("status") != "pending":
|
||||||
|
return False
|
||||||
|
if req.get("chore_id") not in chore_ids:
|
||||||
|
return False
|
||||||
|
if req.get("requester_id") not in child_ids:
|
||||||
|
return False
|
||||||
|
from_id = req.get("from_child_id") or ""
|
||||||
|
return not from_id or from_id in child_ids
|
||||||
|
|
||||||
|
kept = [r for r in swap_requests if _alive(r)]
|
||||||
|
if len(kept) != len(swap_requests):
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Dropped %d settled or orphaned swap request(s) from storage",
|
||||||
|
len(swap_requests) - len(kept),
|
||||||
|
)
|
||||||
|
self._data["swap_requests"] = kept
|
||||||
|
|
||||||
|
def remove_swap_requests_for_chore(self, chore_id: str) -> None:
|
||||||
|
"""Drop a deleted chore's swap requests so they can't linger in the
|
||||||
|
approval queue with nothing to approve."""
|
||||||
|
self._data["swap_requests"] = [r for r in self._data.get("swap_requests", []) if r.get("chore_id") != chore_id]
|
||||||
|
|
||||||
|
def remove_swap_requests_for_child(self, child_id: str) -> None:
|
||||||
|
"""Drop swap requests a deleted child is either side of.
|
||||||
|
|
||||||
|
Both ends matter: the requester is who the handover goes *to*, and
|
||||||
|
`from_child_id` is who it comes from and is rendered in the queue.
|
||||||
|
Either being gone makes the request undeliverable.
|
||||||
|
"""
|
||||||
|
self._data["swap_requests"] = [
|
||||||
|
r
|
||||||
|
for r in self._data.get("swap_requests", [])
|
||||||
|
if r.get("requester_id") != child_id and r.get("from_child_id") != child_id
|
||||||
|
]
|
||||||
|
|
||||||
# ── Quests (chore chains) ────────────────────────────────────────────
|
# ── Quests (chore chains) ────────────────────────────────────────────
|
||||||
def get_quests(self) -> list[Quest]:
|
def get_quests(self) -> list[Quest]:
|
||||||
return [Quest.from_dict(q) for q in self._data.get("quests", [])]
|
return [Quest.from_dict(q) for q in self._data.get("quests", [])]
|
||||||
@@ -1041,9 +1060,7 @@ class TaskMateStorage:
|
|||||||
self.add_quest(quest)
|
self.add_quest(quest)
|
||||||
|
|
||||||
def remove_quest(self, quest_id: str) -> None:
|
def remove_quest(self, quest_id: str) -> None:
|
||||||
self._data["quests"] = [
|
self._data["quests"] = [q for q in self._data.get("quests", []) if q.get("id") != quest_id]
|
||||||
q for q in self._data.get("quests", []) if q.get("id") != quest_id
|
|
||||||
]
|
|
||||||
# Drop any progress tracked for this quest
|
# Drop any progress tracked for this quest
|
||||||
prog = self._data.get("quest_progress", {})
|
prog = self._data.get("quest_progress", {})
|
||||||
prog.pop(quest_id, None)
|
prog.pop(quest_id, None)
|
||||||
@@ -1084,9 +1101,7 @@ class TaskMateStorage:
|
|||||||
self.add_challenge(challenge)
|
self.add_challenge(challenge)
|
||||||
|
|
||||||
def remove_challenge(self, challenge_id: str) -> None:
|
def remove_challenge(self, challenge_id: str) -> None:
|
||||||
self._data["challenges"] = [
|
self._data["challenges"] = [c for c in self._data.get("challenges", []) if c.get("id") != challenge_id]
|
||||||
c for c in self._data.get("challenges", []) if c.get("id") != challenge_id
|
|
||||||
]
|
|
||||||
self._data.get("challenge_progress", {}).pop(challenge_id, None)
|
self._data.get("challenge_progress", {}).pop(challenge_id, None)
|
||||||
|
|
||||||
def get_challenge_progress(self) -> dict:
|
def get_challenge_progress(self) -> dict:
|
||||||
@@ -1107,6 +1122,7 @@ class TaskMateStorage:
|
|||||||
def export_data(self) -> dict:
|
def export_data(self) -> dict:
|
||||||
"""Return a deep copy of the full stored data (for backup/export)."""
|
"""Return a deep copy of the full stored data (for backup/export)."""
|
||||||
import copy
|
import copy
|
||||||
|
|
||||||
return copy.deepcopy(self._data)
|
return copy.deepcopy(self._data)
|
||||||
|
|
||||||
def import_data(self, data: dict) -> None:
|
def import_data(self, data: dict) -> None:
|
||||||
@@ -1116,14 +1132,29 @@ class TaskMateStorage:
|
|||||||
a partial import.
|
a partial import.
|
||||||
"""
|
"""
|
||||||
import copy
|
import copy
|
||||||
|
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
raise ValueError("import data must be an object")
|
raise ValueError("import data must be an object")
|
||||||
self._data = copy.deepcopy(data)
|
self._data = copy.deepcopy(data)
|
||||||
list_keys = (
|
list_keys = (
|
||||||
"children", "chores", "rewards", "penalties", "bonuses",
|
"children",
|
||||||
"task_groups", "completions", "mandatory_misses", "reward_claims", "points_transactions",
|
"chores",
|
||||||
"pool_allocations", "badges", "awarded_badges", "parent_recipients",
|
"rewards",
|
||||||
"audit_log", "timed_sessions", "quests", "challenges",
|
"penalties",
|
||||||
|
"bonuses",
|
||||||
|
"task_groups",
|
||||||
|
"completions",
|
||||||
|
"mandatory_misses",
|
||||||
|
"reward_claims",
|
||||||
|
"points_transactions",
|
||||||
|
"pool_allocations",
|
||||||
|
"badges",
|
||||||
|
"awarded_badges",
|
||||||
|
"parent_recipients",
|
||||||
|
"audit_log",
|
||||||
|
"timed_sessions",
|
||||||
|
"quests",
|
||||||
|
"challenges",
|
||||||
)
|
)
|
||||||
for k in list_keys:
|
for k in list_keys:
|
||||||
if not isinstance(self._data.get(k), list):
|
if not isinstance(self._data.get(k), list):
|
||||||
@@ -1146,6 +1177,7 @@ class TaskMateStorage:
|
|||||||
well-formed photo URLs so the panel never renders a foreign/dangerous one.
|
well-formed photo URLs so the panel never renders a foreign/dangerous one.
|
||||||
"""
|
"""
|
||||||
from .photos import is_taskmate_photo_url
|
from .photos import is_taskmate_photo_url
|
||||||
|
|
||||||
for comp in self._data.get("completions", []):
|
for comp in self._data.get("completions", []):
|
||||||
if not isinstance(comp, dict):
|
if not isinstance(comp, dict):
|
||||||
continue
|
continue
|
||||||
@@ -1163,21 +1195,15 @@ class TaskMateStorage:
|
|||||||
|
|
||||||
def remove_completions_for_child(self, child_id: str) -> None:
|
def remove_completions_for_child(self, child_id: str) -> None:
|
||||||
"""Remove all completions for a given child."""
|
"""Remove all completions for a given child."""
|
||||||
self._data["completions"] = [
|
self._data["completions"] = [c for c in self._data.get("completions", []) if c.get("child_id") != child_id]
|
||||||
c for c in self._data.get("completions", []) if c.get("child_id") != child_id
|
|
||||||
]
|
|
||||||
|
|
||||||
def remove_completions_for_chore(self, chore_id: str) -> None:
|
def remove_completions_for_chore(self, chore_id: str) -> None:
|
||||||
"""Remove all completions for a given chore."""
|
"""Remove all completions for a given chore."""
|
||||||
self._data["completions"] = [
|
self._data["completions"] = [c for c in self._data.get("completions", []) if c.get("chore_id") != chore_id]
|
||||||
c for c in self._data.get("completions", []) if c.get("chore_id") != chore_id
|
|
||||||
]
|
|
||||||
|
|
||||||
def remove_reward_claims_for_child(self, child_id: str) -> None:
|
def remove_reward_claims_for_child(self, child_id: str) -> None:
|
||||||
"""Remove all reward claims for a given child."""
|
"""Remove all reward claims for a given child."""
|
||||||
self._data["reward_claims"] = [
|
self._data["reward_claims"] = [c for c in self._data.get("reward_claims", []) if c.get("child_id") != child_id]
|
||||||
c for c in self._data.get("reward_claims", []) if c.get("child_id") != child_id
|
|
||||||
]
|
|
||||||
|
|
||||||
def remove_reward_claims_for_reward(self, reward_id: str) -> None:
|
def remove_reward_claims_for_reward(self, reward_id: str) -> None:
|
||||||
"""Remove all reward claims for a given reward."""
|
"""Remove all reward claims for a given reward."""
|
||||||
@@ -1209,7 +1235,8 @@ class TaskMateStorage:
|
|||||||
def remove_pool_allocation(self, child_id: str, reward_id: str) -> None:
|
def remove_pool_allocation(self, child_id: str, reward_id: str) -> None:
|
||||||
"""Remove a pool allocation for a specific (child, reward) pair."""
|
"""Remove a pool allocation for a specific (child, reward) pair."""
|
||||||
self._data["pool_allocations"] = [
|
self._data["pool_allocations"] = [
|
||||||
a for a in self._data.get("pool_allocations", [])
|
a
|
||||||
|
for a in self._data.get("pool_allocations", [])
|
||||||
if not (a.get("child_id") == child_id and a.get("reward_id") == reward_id)
|
if not (a.get("child_id") == child_id and a.get("reward_id") == reward_id)
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1309,18 +1336,22 @@ class TaskMateStorage:
|
|||||||
def get_timed_session(self, chore_id: str, child_id: str, session_date: str) -> TimedSession | None:
|
def get_timed_session(self, chore_id: str, child_id: str, session_date: str) -> TimedSession | None:
|
||||||
"""Get a timed session for a specific chore/child/date."""
|
"""Get a timed session for a specific chore/child/date."""
|
||||||
for s in self._data.get("timed_sessions", []):
|
for s in self._data.get("timed_sessions", []):
|
||||||
if (s.get("chore_id") == chore_id
|
if (
|
||||||
and s.get("child_id") == child_id
|
s.get("chore_id") == chore_id
|
||||||
and s.get("session_date") == session_date):
|
and s.get("child_id") == child_id
|
||||||
|
and s.get("session_date") == session_date
|
||||||
|
):
|
||||||
return TimedSession.from_dict(s)
|
return TimedSession.from_dict(s)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_active_timed_session(self, chore_id: str, child_id: str) -> TimedSession | None:
|
def get_active_timed_session(self, chore_id: str, child_id: str) -> TimedSession | None:
|
||||||
"""Get a running or paused session for a chore/child pair."""
|
"""Get a running or paused session for a chore/child pair."""
|
||||||
for s in self._data.get("timed_sessions", []):
|
for s in self._data.get("timed_sessions", []):
|
||||||
if (s.get("chore_id") == chore_id
|
if (
|
||||||
and s.get("child_id") == child_id
|
s.get("chore_id") == chore_id
|
||||||
and s.get("state") in ("running", "paused")):
|
and s.get("child_id") == child_id
|
||||||
|
and s.get("state") in ("running", "paused")
|
||||||
|
):
|
||||||
return TimedSession.from_dict(s)
|
return TimedSession.from_dict(s)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -1335,10 +1366,7 @@ class TaskMateStorage:
|
|||||||
|
|
||||||
def remove_timed_session(self, session_id: str) -> None:
|
def remove_timed_session(self, session_id: str) -> None:
|
||||||
"""Remove a timed session."""
|
"""Remove a timed session."""
|
||||||
self._data["timed_sessions"] = [
|
self._data["timed_sessions"] = [s for s in self._data.get("timed_sessions", []) if s.get("id") != session_id]
|
||||||
s for s in self._data.get("timed_sessions", [])
|
|
||||||
if s.get("id") != session_id
|
|
||||||
]
|
|
||||||
|
|
||||||
# Generic settings
|
# Generic settings
|
||||||
def get_setting(self, key: str, default: Any = "") -> Any:
|
def get_setting(self, key: str, default: Any = "") -> Any:
|
||||||
|
|||||||
@@ -1,15 +1,35 @@
|
|||||||
"""Built-in chore template packs for TaskMate."""
|
"""Built-in chore template packs for TaskMate."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
TEMPLATE_CHORE_FIELDS = (
|
TEMPLATE_CHORE_FIELDS = (
|
||||||
"name", "points", "description", "requires_approval", "time_category",
|
"name",
|
||||||
"daily_limit", "completion_sound", "schedule_mode", "due_days",
|
"points",
|
||||||
"recurrence", "recurrence_day", "recurrence_start", "first_occurrence_mode",
|
"description",
|
||||||
"assignment_mode", "require_availability", "visibility_entity",
|
"requires_approval",
|
||||||
"visibility_state", "visibility_operator",
|
"time_category",
|
||||||
"weather_entity", "weather_block_conditions", "weather_temp_min",
|
"daily_limit",
|
||||||
"weather_temp_max", "weather_wind_max", "task_type",
|
"completion_sound",
|
||||||
"timed_rate_points", "timed_rate_minutes", "timed_max_daily_minutes",
|
"schedule_mode",
|
||||||
|
"due_days",
|
||||||
|
"recurrence",
|
||||||
|
"recurrence_day",
|
||||||
|
"recurrence_start",
|
||||||
|
"first_occurrence_mode",
|
||||||
|
"assignment_mode",
|
||||||
|
"require_availability",
|
||||||
|
"visibility_entity",
|
||||||
|
"visibility_state",
|
||||||
|
"visibility_operator",
|
||||||
|
"weather_entity",
|
||||||
|
"weather_block_conditions",
|
||||||
|
"weather_temp_min",
|
||||||
|
"weather_temp_max",
|
||||||
|
"weather_wind_max",
|
||||||
|
"task_type",
|
||||||
|
"timed_rate_points",
|
||||||
|
"timed_rate_minutes",
|
||||||
|
"timed_max_daily_minutes",
|
||||||
)
|
)
|
||||||
|
|
||||||
_WEEKDAYS = ["monday", "tuesday", "wednesday", "thursday", "friday"]
|
_WEEKDAYS = ["monday", "tuesday", "wednesday", "thursday", "friday"]
|
||||||
@@ -22,10 +42,50 @@ BUILT_IN_TEMPLATES: list[dict] = [
|
|||||||
"icon": "mdi:weather-sunny",
|
"icon": "mdi:weather-sunny",
|
||||||
"builtin": True,
|
"builtin": True,
|
||||||
"chores": [
|
"chores": [
|
||||||
{"name": "Make bed", "points": 2, "time_category": "morning", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
|
{
|
||||||
{"name": "Brush teeth", "points": 1, "time_category": "morning", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
|
"name": "Make bed",
|
||||||
{"name": "Get dressed", "points": 1, "time_category": "morning", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
|
"points": 2,
|
||||||
{"name": "Pack school bag", "points": 2, "time_category": "morning", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
|
"time_category": "morning",
|
||||||
|
"schedule_mode": "specific_days",
|
||||||
|
"due_days": list(_WEEKDAYS),
|
||||||
|
"requires_approval": False,
|
||||||
|
"assignment_mode": "everyone",
|
||||||
|
"daily_limit": 1,
|
||||||
|
"completion_sound": "coin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Brush teeth",
|
||||||
|
"points": 1,
|
||||||
|
"time_category": "morning",
|
||||||
|
"schedule_mode": "specific_days",
|
||||||
|
"due_days": list(_WEEKDAYS),
|
||||||
|
"requires_approval": False,
|
||||||
|
"assignment_mode": "everyone",
|
||||||
|
"daily_limit": 1,
|
||||||
|
"completion_sound": "coin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Get dressed",
|
||||||
|
"points": 1,
|
||||||
|
"time_category": "morning",
|
||||||
|
"schedule_mode": "specific_days",
|
||||||
|
"due_days": list(_WEEKDAYS),
|
||||||
|
"requires_approval": False,
|
||||||
|
"assignment_mode": "everyone",
|
||||||
|
"daily_limit": 1,
|
||||||
|
"completion_sound": "coin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Pack school bag",
|
||||||
|
"points": 2,
|
||||||
|
"time_category": "morning",
|
||||||
|
"schedule_mode": "specific_days",
|
||||||
|
"due_days": list(_WEEKDAYS),
|
||||||
|
"requires_approval": False,
|
||||||
|
"assignment_mode": "everyone",
|
||||||
|
"daily_limit": 1,
|
||||||
|
"completion_sound": "coin",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -34,10 +94,50 @@ BUILT_IN_TEMPLATES: list[dict] = [
|
|||||||
"icon": "mdi:weather-night",
|
"icon": "mdi:weather-night",
|
||||||
"builtin": True,
|
"builtin": True,
|
||||||
"chores": [
|
"chores": [
|
||||||
{"name": "Brush teeth", "points": 1, "time_category": "evening", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
|
{
|
||||||
{"name": "Put on pyjamas", "points": 1, "time_category": "evening", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
|
"name": "Brush teeth",
|
||||||
{"name": "Tidy room", "points": 2, "time_category": "evening", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
|
"points": 1,
|
||||||
{"name": "Set out clothes for tomorrow", "points": 1, "time_category": "evening", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
|
"time_category": "evening",
|
||||||
|
"schedule_mode": "specific_days",
|
||||||
|
"due_days": list(_WEEKDAYS),
|
||||||
|
"requires_approval": False,
|
||||||
|
"assignment_mode": "everyone",
|
||||||
|
"daily_limit": 1,
|
||||||
|
"completion_sound": "coin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Put on pyjamas",
|
||||||
|
"points": 1,
|
||||||
|
"time_category": "evening",
|
||||||
|
"schedule_mode": "specific_days",
|
||||||
|
"due_days": list(_WEEKDAYS),
|
||||||
|
"requires_approval": False,
|
||||||
|
"assignment_mode": "everyone",
|
||||||
|
"daily_limit": 1,
|
||||||
|
"completion_sound": "coin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Tidy room",
|
||||||
|
"points": 2,
|
||||||
|
"time_category": "evening",
|
||||||
|
"schedule_mode": "specific_days",
|
||||||
|
"due_days": list(_WEEKDAYS),
|
||||||
|
"requires_approval": False,
|
||||||
|
"assignment_mode": "everyone",
|
||||||
|
"daily_limit": 1,
|
||||||
|
"completion_sound": "coin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Set out clothes for tomorrow",
|
||||||
|
"points": 1,
|
||||||
|
"time_category": "evening",
|
||||||
|
"schedule_mode": "specific_days",
|
||||||
|
"due_days": list(_WEEKDAYS),
|
||||||
|
"requires_approval": False,
|
||||||
|
"assignment_mode": "everyone",
|
||||||
|
"daily_limit": 1,
|
||||||
|
"completion_sound": "coin",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -46,10 +146,50 @@ BUILT_IN_TEMPLATES: list[dict] = [
|
|||||||
"icon": "mdi:silverware-fork-knife",
|
"icon": "mdi:silverware-fork-knife",
|
||||||
"builtin": True,
|
"builtin": True,
|
||||||
"chores": [
|
"chores": [
|
||||||
{"name": "Set table", "points": 2, "time_category": "evening", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
|
{
|
||||||
{"name": "Clear plates", "points": 2, "time_category": "evening", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
|
"name": "Set table",
|
||||||
{"name": "Load dishwasher", "points": 3, "time_category": "evening", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
|
"points": 2,
|
||||||
{"name": "Wipe counters", "points": 2, "time_category": "evening", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
|
"time_category": "evening",
|
||||||
|
"schedule_mode": "specific_days",
|
||||||
|
"due_days": list(_WEEKDAYS),
|
||||||
|
"requires_approval": False,
|
||||||
|
"assignment_mode": "everyone",
|
||||||
|
"daily_limit": 1,
|
||||||
|
"completion_sound": "coin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Clear plates",
|
||||||
|
"points": 2,
|
||||||
|
"time_category": "evening",
|
||||||
|
"schedule_mode": "specific_days",
|
||||||
|
"due_days": list(_WEEKDAYS),
|
||||||
|
"requires_approval": False,
|
||||||
|
"assignment_mode": "everyone",
|
||||||
|
"daily_limit": 1,
|
||||||
|
"completion_sound": "coin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Load dishwasher",
|
||||||
|
"points": 3,
|
||||||
|
"time_category": "evening",
|
||||||
|
"schedule_mode": "specific_days",
|
||||||
|
"due_days": list(_WEEKDAYS),
|
||||||
|
"requires_approval": False,
|
||||||
|
"assignment_mode": "everyone",
|
||||||
|
"daily_limit": 1,
|
||||||
|
"completion_sound": "coin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Wipe counters",
|
||||||
|
"points": 2,
|
||||||
|
"time_category": "evening",
|
||||||
|
"schedule_mode": "specific_days",
|
||||||
|
"due_days": list(_WEEKDAYS),
|
||||||
|
"requires_approval": False,
|
||||||
|
"assignment_mode": "everyone",
|
||||||
|
"daily_limit": 1,
|
||||||
|
"completion_sound": "coin",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -58,10 +198,50 @@ BUILT_IN_TEMPLATES: list[dict] = [
|
|||||||
"icon": "mdi:broom",
|
"icon": "mdi:broom",
|
||||||
"builtin": True,
|
"builtin": True,
|
||||||
"chores": [
|
"chores": [
|
||||||
{"name": "Tidy bedroom", "points": 3, "time_category": "anytime", "schedule_mode": "specific_days", "due_days": list(_WEEKENDS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
|
{
|
||||||
{"name": "Hoover", "points": 4, "time_category": "anytime", "schedule_mode": "specific_days", "due_days": list(_WEEKENDS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
|
"name": "Tidy bedroom",
|
||||||
{"name": "Help with laundry", "points": 3, "time_category": "anytime", "schedule_mode": "specific_days", "due_days": list(_WEEKENDS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
|
"points": 3,
|
||||||
{"name": "Take bins out", "points": 2, "time_category": "anytime", "schedule_mode": "specific_days", "due_days": list(_WEEKENDS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
|
"time_category": "anytime",
|
||||||
|
"schedule_mode": "specific_days",
|
||||||
|
"due_days": list(_WEEKENDS),
|
||||||
|
"requires_approval": False,
|
||||||
|
"assignment_mode": "everyone",
|
||||||
|
"daily_limit": 1,
|
||||||
|
"completion_sound": "coin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Hoover",
|
||||||
|
"points": 4,
|
||||||
|
"time_category": "anytime",
|
||||||
|
"schedule_mode": "specific_days",
|
||||||
|
"due_days": list(_WEEKENDS),
|
||||||
|
"requires_approval": False,
|
||||||
|
"assignment_mode": "everyone",
|
||||||
|
"daily_limit": 1,
|
||||||
|
"completion_sound": "coin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Help with laundry",
|
||||||
|
"points": 3,
|
||||||
|
"time_category": "anytime",
|
||||||
|
"schedule_mode": "specific_days",
|
||||||
|
"due_days": list(_WEEKENDS),
|
||||||
|
"requires_approval": False,
|
||||||
|
"assignment_mode": "everyone",
|
||||||
|
"daily_limit": 1,
|
||||||
|
"completion_sound": "coin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Take bins out",
|
||||||
|
"points": 2,
|
||||||
|
"time_category": "anytime",
|
||||||
|
"schedule_mode": "specific_days",
|
||||||
|
"due_days": list(_WEEKENDS),
|
||||||
|
"requires_approval": False,
|
||||||
|
"assignment_mode": "everyone",
|
||||||
|
"daily_limit": 1,
|
||||||
|
"completion_sound": "coin",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -70,10 +250,50 @@ BUILT_IN_TEMPLATES: list[dict] = [
|
|||||||
"icon": "mdi:paw",
|
"icon": "mdi:paw",
|
||||||
"builtin": True,
|
"builtin": True,
|
||||||
"chores": [
|
"chores": [
|
||||||
{"name": "Feed pet", "points": 2, "time_category": "morning", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
|
{
|
||||||
{"name": "Fill water bowl", "points": 1, "time_category": "morning", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
|
"name": "Feed pet",
|
||||||
{"name": "Walk dog", "points": 3, "time_category": "afternoon", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
|
"points": 2,
|
||||||
{"name": "Clean litter tray", "points": 3, "time_category": "anytime", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
|
"time_category": "morning",
|
||||||
|
"schedule_mode": "specific_days",
|
||||||
|
"due_days": list(_WEEKDAYS),
|
||||||
|
"requires_approval": False,
|
||||||
|
"assignment_mode": "everyone",
|
||||||
|
"daily_limit": 1,
|
||||||
|
"completion_sound": "coin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Fill water bowl",
|
||||||
|
"points": 1,
|
||||||
|
"time_category": "morning",
|
||||||
|
"schedule_mode": "specific_days",
|
||||||
|
"due_days": list(_WEEKDAYS),
|
||||||
|
"requires_approval": False,
|
||||||
|
"assignment_mode": "everyone",
|
||||||
|
"daily_limit": 1,
|
||||||
|
"completion_sound": "coin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Walk dog",
|
||||||
|
"points": 3,
|
||||||
|
"time_category": "afternoon",
|
||||||
|
"schedule_mode": "specific_days",
|
||||||
|
"due_days": list(_WEEKDAYS),
|
||||||
|
"requires_approval": False,
|
||||||
|
"assignment_mode": "everyone",
|
||||||
|
"daily_limit": 1,
|
||||||
|
"completion_sound": "coin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Clean litter tray",
|
||||||
|
"points": 3,
|
||||||
|
"time_category": "anytime",
|
||||||
|
"schedule_mode": "specific_days",
|
||||||
|
"due_days": list(_WEEKDAYS),
|
||||||
|
"requires_approval": False,
|
||||||
|
"assignment_mode": "everyone",
|
||||||
|
"daily_limit": 1,
|
||||||
|
"completion_sound": "coin",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -82,9 +302,39 @@ BUILT_IN_TEMPLATES: list[dict] = [
|
|||||||
"icon": "mdi:book-open-variant",
|
"icon": "mdi:book-open-variant",
|
||||||
"builtin": True,
|
"builtin": True,
|
||||||
"chores": [
|
"chores": [
|
||||||
{"name": "Do homework", "points": 3, "time_category": "afternoon", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
|
{
|
||||||
{"name": "Read for 20 minutes", "points": 2, "time_category": "anytime", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
|
"name": "Do homework",
|
||||||
{"name": "Practice instrument", "points": 2, "time_category": "anytime", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
|
"points": 3,
|
||||||
|
"time_category": "afternoon",
|
||||||
|
"schedule_mode": "specific_days",
|
||||||
|
"due_days": list(_WEEKDAYS),
|
||||||
|
"requires_approval": False,
|
||||||
|
"assignment_mode": "everyone",
|
||||||
|
"daily_limit": 1,
|
||||||
|
"completion_sound": "coin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Read for 20 minutes",
|
||||||
|
"points": 2,
|
||||||
|
"time_category": "anytime",
|
||||||
|
"schedule_mode": "specific_days",
|
||||||
|
"due_days": list(_WEEKDAYS),
|
||||||
|
"requires_approval": False,
|
||||||
|
"assignment_mode": "everyone",
|
||||||
|
"daily_limit": 1,
|
||||||
|
"completion_sound": "coin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Practice instrument",
|
||||||
|
"points": 2,
|
||||||
|
"time_category": "anytime",
|
||||||
|
"schedule_mode": "specific_days",
|
||||||
|
"due_days": list(_WEEKDAYS),
|
||||||
|
"requires_approval": False,
|
||||||
|
"assignment_mode": "everyone",
|
||||||
|
"daily_limit": 1,
|
||||||
|
"completion_sound": "coin",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ today (via the shared get_due_chores_for_child). Checking an item off completes
|
|||||||
the chore, so the native HA to-do card and voice assistants can drive TaskMate
|
the chore, so the native HA to-do card and voice assistants can drive TaskMate
|
||||||
without the custom cards.
|
without the custom cards.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from homeassistant.components.todo import (
|
from homeassistant.components.todo import (
|
||||||
@@ -23,9 +24,7 @@ from .const import DOMAIN
|
|||||||
from .coordinator import TaskMateCoordinator
|
from .coordinator import TaskMateCoordinator
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None:
|
||||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
|
||||||
) -> None:
|
|
||||||
"""Set up a to-do list per child, adding new children as they appear."""
|
"""Set up a to-do list per child, adding new children as they appear."""
|
||||||
coordinator: TaskMateCoordinator = hass.data[DOMAIN][entry.entry_id]
|
coordinator: TaskMateCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||||
tracked: set[str] = set()
|
tracked: set[str] = set()
|
||||||
|
|||||||
+1016
-683
File diff suppressed because it is too large
Load Diff
@@ -153,6 +153,7 @@
|
|||||||
"badges.editor_help": "Konfigurieren Sie per YAML: Setzen Sie entity auf Ihren Badges-Sensor (z.B. sensor.taskmate_badges_mia).",
|
"badges.editor_help": "Konfigurieren Sie per YAML: Setzen Sie entity auf Ihren Badges-Sensor (z.B. sensor.taskmate_badges_mia).",
|
||||||
"badges.entity_not_found": "{entity} nicht gefunden",
|
"badges.entity_not_found": "{entity} nicht gefunden",
|
||||||
"badges.label": "Abzeichen",
|
"badges.label": "Abzeichen",
|
||||||
|
"badges.next_up": "Als Nächstes",
|
||||||
"badges.title_with_name": "Abzeichen von {name}",
|
"badges.title_with_name": "Abzeichen von {name}",
|
||||||
"bonuses.add_bonus": "Bonus hinzufügen",
|
"bonuses.add_bonus": "Bonus hinzufügen",
|
||||||
"bonuses.applying_to": "Beantragung bei {childName} – aktuelles Guthaben: {points} {pointsName}",
|
"bonuses.applying_to": "Beantragung bei {childName} – aktuelles Guthaben: {points} {pointsName}",
|
||||||
|
|||||||
@@ -154,6 +154,7 @@
|
|||||||
"badges.editor_help": "Configure via YAML: set entity to your badges sensor (e.g. sensor.taskmate_badges_mia).",
|
"badges.editor_help": "Configure via YAML: set entity to your badges sensor (e.g. sensor.taskmate_badges_mia).",
|
||||||
"badges.entity_not_found": "{entity} not found",
|
"badges.entity_not_found": "{entity} not found",
|
||||||
"badges.label": "Badges",
|
"badges.label": "Badges",
|
||||||
|
"badges.next_up": "Next up",
|
||||||
"badges.title_with_name": "{name}'s Badges",
|
"badges.title_with_name": "{name}'s Badges",
|
||||||
"bonuses.add_bonus": "Add Bonus",
|
"bonuses.add_bonus": "Add Bonus",
|
||||||
"bonuses.applying_to": "Applying to {childName} — current balance: {points} {pointsName}",
|
"bonuses.applying_to": "Applying to {childName} — current balance: {points} {pointsName}",
|
||||||
|
|||||||
@@ -154,6 +154,7 @@
|
|||||||
"badges.editor_help": "Configure via YAML: set entity to your badges sensor (e.g. sensor.taskmate_badges_mia).",
|
"badges.editor_help": "Configure via YAML: set entity to your badges sensor (e.g. sensor.taskmate_badges_mia).",
|
||||||
"badges.entity_not_found": "{entity} not found",
|
"badges.entity_not_found": "{entity} not found",
|
||||||
"badges.label": "Badges",
|
"badges.label": "Badges",
|
||||||
|
"badges.next_up": "Next up",
|
||||||
"badges.title_with_name": "{name}'s Badges",
|
"badges.title_with_name": "{name}'s Badges",
|
||||||
"bonuses.add_bonus": "Add Bonus",
|
"bonuses.add_bonus": "Add Bonus",
|
||||||
"bonuses.applying_to": "Applying to {childName} — current balance: {points} {pointsName}",
|
"bonuses.applying_to": "Applying to {childName} — current balance: {points} {pointsName}",
|
||||||
|
|||||||
@@ -153,6 +153,7 @@
|
|||||||
"badges.editor_help": "Configurez via YAML : définissez entity sur votre capteur de badges (ex. sensor.taskmate_badges_mia).",
|
"badges.editor_help": "Configurez via YAML : définissez entity sur votre capteur de badges (ex. sensor.taskmate_badges_mia).",
|
||||||
"badges.entity_not_found": "{entity} introuvable",
|
"badges.entity_not_found": "{entity} introuvable",
|
||||||
"badges.label": "Badges",
|
"badges.label": "Badges",
|
||||||
|
"badges.next_up": "Prochain",
|
||||||
"badges.title_with_name": "Badges de {name}",
|
"badges.title_with_name": "Badges de {name}",
|
||||||
"bonuses.add_bonus": "Ajouter un bonus",
|
"bonuses.add_bonus": "Ajouter un bonus",
|
||||||
"bonuses.applying_to": "Application à {childName} — solde actuel : {points} {pointsName}",
|
"bonuses.applying_to": "Application à {childName} — solde actuel : {points} {pointsName}",
|
||||||
|
|||||||
@@ -153,6 +153,7 @@
|
|||||||
"badges.editor_help": "Konfigurer via YAML: sett entity til din badge-sensor (f.eks. sensor.taskmate_badges_mia).",
|
"badges.editor_help": "Konfigurer via YAML: sett entity til din badge-sensor (f.eks. sensor.taskmate_badges_mia).",
|
||||||
"badges.entity_not_found": "{entity} ikke funnet",
|
"badges.entity_not_found": "{entity} ikke funnet",
|
||||||
"badges.label": "Merker",
|
"badges.label": "Merker",
|
||||||
|
"badges.next_up": "Neste",
|
||||||
"badges.title_with_name": "{name}s merker",
|
"badges.title_with_name": "{name}s merker",
|
||||||
"bonuses.add_bonus": "Legg til bonus",
|
"bonuses.add_bonus": "Legg til bonus",
|
||||||
"bonuses.applying_to": "Brukes på {childName} — nåværende saldo: {points} {pointsName}",
|
"bonuses.applying_to": "Brukes på {childName} — nåværende saldo: {points} {pointsName}",
|
||||||
|
|||||||
@@ -153,6 +153,7 @@
|
|||||||
"badges.editor_help": "Konfigurer via YAML: sett entity til din badge-sensor (t.d. sensor.taskmate_badges_mia).",
|
"badges.editor_help": "Konfigurer via YAML: sett entity til din badge-sensor (t.d. sensor.taskmate_badges_mia).",
|
||||||
"badges.entity_not_found": "{entity} ikkje funne",
|
"badges.entity_not_found": "{entity} ikkje funne",
|
||||||
"badges.label": "Merke",
|
"badges.label": "Merke",
|
||||||
|
"badges.next_up": "Neste",
|
||||||
"badges.title_with_name": "Merka til {name}",
|
"badges.title_with_name": "Merka til {name}",
|
||||||
"bonuses.add_bonus": "Legg til bonus",
|
"bonuses.add_bonus": "Legg til bonus",
|
||||||
"bonuses.applying_to": "Vert brukt på {childName} — noverande saldo: {points} {pointsName}",
|
"bonuses.applying_to": "Vert brukt på {childName} — noverande saldo: {points} {pointsName}",
|
||||||
|
|||||||
@@ -153,6 +153,7 @@
|
|||||||
"badges.editor_help": "Configure via YAML: defina entity para o sensor de emblemas (ex. sensor.taskmate_badges_mia).",
|
"badges.editor_help": "Configure via YAML: defina entity para o sensor de emblemas (ex. sensor.taskmate_badges_mia).",
|
||||||
"badges.entity_not_found": "{entity} não encontrado",
|
"badges.entity_not_found": "{entity} não encontrado",
|
||||||
"badges.label": "Conquistas",
|
"badges.label": "Conquistas",
|
||||||
|
"badges.next_up": "A seguir",
|
||||||
"badges.title_with_name": "Conquistas de {name}",
|
"badges.title_with_name": "Conquistas de {name}",
|
||||||
"bonuses.add_bonus": "Adicionar Bónus",
|
"bonuses.add_bonus": "Adicionar Bónus",
|
||||||
"bonuses.applying_to": "A aplicar a {childName} — saldo atual: {points} {pointsName}",
|
"bonuses.applying_to": "A aplicar a {childName} — saldo atual: {points} {pointsName}",
|
||||||
|
|||||||
@@ -153,6 +153,7 @@
|
|||||||
"badges.editor_help": "Configure via YAML: defina entity para o sensor de emblemas (ex. sensor.taskmate_badges_mia).",
|
"badges.editor_help": "Configure via YAML: defina entity para o sensor de emblemas (ex. sensor.taskmate_badges_mia).",
|
||||||
"badges.entity_not_found": "{entity} não encontrado",
|
"badges.entity_not_found": "{entity} não encontrado",
|
||||||
"badges.label": "Conquistas",
|
"badges.label": "Conquistas",
|
||||||
|
"badges.next_up": "A seguir",
|
||||||
"badges.title_with_name": "Conquistas de {name}",
|
"badges.title_with_name": "Conquistas de {name}",
|
||||||
"bonuses.add_bonus": "Adicionar Bónus",
|
"bonuses.add_bonus": "Adicionar Bónus",
|
||||||
"bonuses.applying_to": "A aplicar a {childName} — saldo atual: {points} {pointsName}",
|
"bonuses.applying_to": "A aplicar a {childName} — saldo atual: {points} {pointsName}",
|
||||||
|
|||||||
@@ -160,6 +160,13 @@ class TaskMateChildCard extends LitElement {
|
|||||||
}[tier] || '#888';
|
}[tier] || '#888';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_badgeKeydown(e) {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
this._openBadgesView();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_openBadgesView() {
|
_openBadgesView() {
|
||||||
const slug = this.config?.child_id
|
const slug = this.config?.child_id
|
||||||
? String(this.config.child_id).toLowerCase().replace(/\s+/g, '_')
|
? String(this.config.child_id).toLowerCase().replace(/\s+/g, '_')
|
||||||
@@ -1906,6 +1913,74 @@ class TaskMateChildCard extends LitElement {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Next badge progress (#780) ── */
|
||||||
|
.next-badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 1px solid var(--divider-color, #e0e0e0);
|
||||||
|
}
|
||||||
|
.next-badge:hover { background: var(--secondary-background-color, rgba(0,0,0,0.03)); }
|
||||||
|
.next-badge:focus-visible {
|
||||||
|
outline: 2px solid var(--primary-color);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
.next-badge-icon {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
--mdc-icon-size: 16px;
|
||||||
|
color: #1a1a1a;
|
||||||
|
border: 2px dashed var(--t);
|
||||||
|
background: transparent;
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
.next-badge-body { flex: 1; min-width: 0; }
|
||||||
|
.next-badge-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
.next-badge-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--secondary-text-color);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.next-badge-name {
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary-text-color);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.next-badge-count {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--secondary-text-color);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.next-badge-bar {
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--divider-color, #e0e0e0);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.next-badge-bar i {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: width 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
/* ══════════════════════════════════════════════════════════════════
|
/* ══════════════════════════════════════════════════════════════════
|
||||||
DESIGNED STYLES (playroom / console / cleanpro)
|
DESIGNED STYLES (playroom / console / cleanpro)
|
||||||
Shared .tmd kit + tokens come from taskmate-design.js styles().
|
Shared .tmd kit + tokens come from taskmate-design.js styles().
|
||||||
@@ -2042,6 +2117,49 @@ class TaskMateChildCard extends LitElement {
|
|||||||
.tmd-badge-mini ha-icon { --mdc-icon-size: 16px; color: #fff; }
|
.tmd-badge-mini ha-icon { --mdc-icon-size: 16px; color: #fff; }
|
||||||
.tmd-badges .more { font-size: 11px; font-weight: 800; color: var(--tmd-accent); }
|
.tmd-badges .more { font-size: 11px; font-weight: 800; color: var(--tmd-accent); }
|
||||||
|
|
||||||
|
/* Designed: next badge progress (#780) */
|
||||||
|
.tmd-next-badge {
|
||||||
|
display: flex; align-items: center; gap: 9px;
|
||||||
|
padding: 9px 11px; margin-bottom: 11px;
|
||||||
|
background: var(--tmd-surface-2); border: 1px solid var(--tmd-border);
|
||||||
|
border-radius: var(--tmd-radius-sm); cursor: pointer;
|
||||||
|
}
|
||||||
|
.tmd-next-badge:focus-visible { outline: 2px solid var(--tmd-accent); outline-offset: 1px; }
|
||||||
|
.tmd-next-badge .ic {
|
||||||
|
width: 26px; height: 26px; border-radius: 50%; display: grid; place-items: center;
|
||||||
|
border: 2px dashed var(--t, var(--tmd-accent)); color: var(--tmd-dim); flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.tmd-next-badge .ic ha-icon { --mdc-icon-size: 15px; }
|
||||||
|
.tmd-next-badge .bd { flex: 1; min-width: 0; }
|
||||||
|
.tmd-next-badge .top { display: flex; align-items: baseline; gap: 6px; margin-bottom: 5px; }
|
||||||
|
.tmd-next-badge .lbl {
|
||||||
|
font-size: 11px; font-weight: 800; color: var(--tmd-dim);
|
||||||
|
text-transform: uppercase; letter-spacing: .04em; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.tmd-next-badge .nm {
|
||||||
|
font-size: 12.5px; font-weight: 700; color: var(--tmd-text);
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.tmd-next-badge .cnt {
|
||||||
|
margin-left: auto; font-size: 11px; font-weight: 800;
|
||||||
|
color: var(--tmd-dim); white-space: nowrap;
|
||||||
|
}
|
||||||
|
.tmd-next-badge .bar {
|
||||||
|
height: 6px; border-radius: 999px; background: var(--tmd-border); overflow: hidden;
|
||||||
|
}
|
||||||
|
.tmd-next-badge .bar i {
|
||||||
|
display: block; height: 100%; border-radius: 999px; transition: width .4s ease;
|
||||||
|
}
|
||||||
|
/* Playroom is chunkier, console squares everything off, accessible needs
|
||||||
|
a solid ring rather than a dashed one to stay legible. */
|
||||||
|
:host([data-tm-design="playroom"]) .tmd-next-badge .bar { height: 8px; }
|
||||||
|
:host([data-tm-design="console"]) .tmd-next-badge .bar,
|
||||||
|
:host([data-tm-design="console"]) .tmd-next-badge .bar i { border-radius: 0; }
|
||||||
|
:host([data-tm-design="accessible"]) .tmd-next-badge .ic { border-style: solid; }
|
||||||
|
:host([data-tm-design="accessible"]) .tmd-next-badge .bar { height: 10px; }
|
||||||
|
:host([data-tm-design="accessible"]) .tmd-next-badge .nm,
|
||||||
|
:host([data-tm-design="accessible"]) .tmd-next-badge .cnt { font-size: 14px; }
|
||||||
|
|
||||||
/* Designed: vacation banner + swappable section */
|
/* Designed: vacation banner + swappable section */
|
||||||
.tmd-vacation {
|
.tmd-vacation {
|
||||||
display: flex; align-items: center; gap: 7px; padding: 9px 11px; margin-bottom: 11px;
|
display: flex; align-items: center; gap: 7px; padding: 9px 11px; margin-bottom: 11px;
|
||||||
@@ -2072,6 +2190,7 @@ class TaskMateChildCard extends LitElement {
|
|||||||
show_countdown: true, // Show midnight reset countdown below section title
|
show_countdown: true, // Show midnight reset countdown below section title
|
||||||
show_due_days_only: true, // Whether to apply due_days filtering at all
|
show_due_days_only: true, // Whether to apply due_days filtering at all
|
||||||
show_badges: true, // Show badge strip between points and chores
|
show_badges: true, // Show badge strip between points and chores
|
||||||
|
show_next_badge: true, // Show progress toward the closest unearned badge
|
||||||
header_color: '#9b59b6',
|
header_color: '#9b59b6',
|
||||||
...config,
|
...config,
|
||||||
};
|
};
|
||||||
@@ -2172,6 +2291,7 @@ class TaskMateChildCard extends LitElement {
|
|||||||
const badgesEntity = this._resolveBadgesEntity(child);
|
const badgesEntity = this._resolveBadgesEntity(child);
|
||||||
const earnedBadges = (badgesEntity?.attributes?.earned) || [];
|
const earnedBadges = (badgesEntity?.attributes?.earned) || [];
|
||||||
const showBadges = this.config.show_badges !== false && earnedBadges.length > 0;
|
const showBadges = this.config.show_badges !== false && earnedBadges.length > 0;
|
||||||
|
const nextBadge = this._nextBadge(badgesEntity);
|
||||||
|
|
||||||
// Get pending points for this child
|
// Get pending points for this child
|
||||||
const pendingPoints = child.pending_points || 0;
|
const pendingPoints = child.pending_points || 0;
|
||||||
@@ -2256,6 +2376,28 @@ class TaskMateChildCard extends LitElement {
|
|||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
|
|
||||||
|
${nextBadge ? html`
|
||||||
|
<div class="next-badge" role="button" tabindex="0"
|
||||||
|
aria-label="${this._t('badges.next_up')} — ${nextBadge.name} ${nextBadge.label}"
|
||||||
|
@click=${() => this._openBadgesView()}
|
||||||
|
@keydown=${(e) => this._badgeKeydown(e)}>
|
||||||
|
<div class="next-badge-icon" style="--t: ${this._tierColor(nextBadge.badge.tier)}">
|
||||||
|
<ha-icon icon="${nextBadge.badge.icon || 'mdi:trophy-outline'}"></ha-icon>
|
||||||
|
</div>
|
||||||
|
<div class="next-badge-body">
|
||||||
|
<div class="next-badge-top">
|
||||||
|
<span class="next-badge-label">${this._t('badges.next_up')}</span>
|
||||||
|
<span class="next-badge-name">${nextBadge.name}</span>
|
||||||
|
<span class="next-badge-count">${nextBadge.label}</span>
|
||||||
|
</div>
|
||||||
|
<div class="next-badge-bar" role="progressbar"
|
||||||
|
aria-valuenow="${nextBadge.pct}" aria-valuemin="0" aria-valuemax="100">
|
||||||
|
<i style="width: ${nextBadge.pct}%; background: ${this._tierColor(nextBadge.badge.tier)}"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
<div class="chores-container">
|
<div class="chores-container">
|
||||||
${childChores.length === 0
|
${childChores.length === 0
|
||||||
? this._renderEmptyState()
|
? this._renderEmptyState()
|
||||||
@@ -2346,6 +2488,39 @@ class TaskMateChildCard extends LitElement {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The single closest unearned badge (#780) — the kid-facing "next up" line.
|
||||||
|
`available[]` is already sorted by nothing in particular, so pick the
|
||||||
|
highest progress_pct. Badges nobody has started (0%) are skipped: a bar
|
||||||
|
stuck at zero is noise, not motivation. Returns null when there is
|
||||||
|
nothing worth showing. */
|
||||||
|
_nextBadge(badgesEntity) {
|
||||||
|
if (this.config.show_next_badge === false) return null;
|
||||||
|
const available = badgesEntity?.attributes?.available || [];
|
||||||
|
let best = null;
|
||||||
|
for (const b of available) {
|
||||||
|
const pct = Math.max(0, Math.min(100, Number(b.progress_pct) || 0));
|
||||||
|
if (pct <= 0) continue;
|
||||||
|
if (!best || pct > best.pct) best = { badge: b, pct };
|
||||||
|
}
|
||||||
|
if (!best) return null;
|
||||||
|
const c = best.badge.closest_criterion;
|
||||||
|
// Older backends (and criteria-free, manual-award badges) have no
|
||||||
|
// closest_criterion — fall back to the percentage.
|
||||||
|
best.label = c && c.target ? `${c.current} / ${c.target}` : `${best.pct}%`;
|
||||||
|
best.name = this._badgeName(best.badge);
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Built-in badge names arrive from the sensor in English; the localised
|
||||||
|
// name lives under badge.name_<suffix> (same scheme as the panel).
|
||||||
|
_badgeName(b) {
|
||||||
|
const id = b.badge_id || "";
|
||||||
|
if (!id.startsWith("builtin.")) return b.name;
|
||||||
|
const key = "badge.name_" + id.slice("builtin.".length);
|
||||||
|
const t = this._t(key);
|
||||||
|
return t !== key ? t : b.name;
|
||||||
|
}
|
||||||
|
|
||||||
_designTone(i) { return `var(--tmd-c${(i % 6) + 1})`; }
|
_designTone(i) { return `var(--tmd-c${(i % 6) + 1})`; }
|
||||||
|
|
||||||
_av(child, tone, size) {
|
_av(child, tone, size) {
|
||||||
@@ -2489,6 +2664,7 @@ class TaskMateChildCard extends LitElement {
|
|||||||
const badgesEntity = this._resolveBadgesEntity(child);
|
const badgesEntity = this._resolveBadgesEntity(child);
|
||||||
const earnedBadges = (badgesEntity?.attributes?.earned) || [];
|
const earnedBadges = (badgesEntity?.attributes?.earned) || [];
|
||||||
const showBadges = this.config.show_badges !== false && earnedBadges.length > 0;
|
const showBadges = this.config.show_badges !== false && earnedBadges.length > 0;
|
||||||
|
const nextBadge = this._nextBadge(badgesEntity);
|
||||||
const pendingPoints = child.pending_points || 0;
|
const pendingPoints = child.pending_points || 0;
|
||||||
const countdown = this.config.show_countdown !== false ? this._getMidnightCountdown() : null;
|
const countdown = this.config.show_countdown !== false ? this._getMidnightCountdown() : null;
|
||||||
|
|
||||||
@@ -2532,6 +2708,26 @@ class TaskMateChildCard extends LitElement {
|
|||||||
</div>`)}
|
</div>`)}
|
||||||
${earnedBadges.length > 5 ? html`<span class="more">+${earnedBadges.length - 5} →</span>` : ""}
|
${earnedBadges.length > 5 ? html`<span class="more">+${earnedBadges.length - 5} →</span>` : ""}
|
||||||
</div>` : ""}
|
</div>` : ""}
|
||||||
|
${nextBadge ? html`
|
||||||
|
<div class="tmd-next-badge" role="button" tabindex="0"
|
||||||
|
aria-label="${this._t("badges.next_up")} — ${nextBadge.name} ${nextBadge.label}"
|
||||||
|
@click=${() => this._openBadgesView()}
|
||||||
|
@keydown=${(e) => this._badgeKeydown(e)}>
|
||||||
|
<div class="ic" style="--t:${this._tierColor(nextBadge.badge.tier)}">
|
||||||
|
<ha-icon icon="${nextBadge.badge.icon || "mdi:trophy-outline"}"></ha-icon>
|
||||||
|
</div>
|
||||||
|
<div class="bd">
|
||||||
|
<div class="top">
|
||||||
|
<span class="lbl">${this._t("badges.next_up")}</span>
|
||||||
|
<span class="nm">${nextBadge.name}</span>
|
||||||
|
<span class="cnt">${nextBadge.label}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bar" role="progressbar"
|
||||||
|
aria-valuenow="${nextBadge.pct}" aria-valuemin="0" aria-valuemax="100">
|
||||||
|
<i style="width:${nextBadge.pct}%;background:${this._tierColor(nextBadge.badge.tier)}"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>` : ""}
|
||||||
${sectionLine}
|
${sectionLine}
|
||||||
${this._renderRoulette(child, childChores, pointsIcon)}
|
${this._renderRoulette(child, childChores, pointsIcon)}
|
||||||
${body}
|
${body}
|
||||||
@@ -4668,4 +4864,4 @@ console.info(
|
|||||||
"%c TASKMATE CHILD CARD %c v" + _tmVersion + " ",
|
"%c TASKMATE CHILD CARD %c v" + _tmVersion + " ",
|
||||||
"background:#9b59b6;color:white;font-weight:bold;padding:2px 4px;border-radius:4px 0 0 4px;",
|
"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;"
|
"background:#2c3e50;color:white;font-weight:bold;padding:2px 4px;border-radius:0 4px 4px 0;"
|
||||||
);
|
);
|
||||||
|
|||||||
+21
-21
@@ -15,9 +15,9 @@
|
|||||||
"latest_source": "https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/images/ThirdReality/SmartPlug_Zigbee_PROD_OTA_V101_1.01.01.ota",
|
"latest_source": "https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/images/ThirdReality/SmartPlug_Zigbee_PROD_OTA_V101_1.01.01.ota",
|
||||||
"latest_release_notes": null
|
"latest_release_notes": null
|
||||||
},
|
},
|
||||||
"power": 0.5,
|
"power": 0.4,
|
||||||
"linkquality": 116,
|
"linkquality": 116,
|
||||||
"current": 1.23,
|
"current": 0.03,
|
||||||
"power_on_behavior": "on"
|
"power_on_behavior": "on"
|
||||||
},
|
},
|
||||||
"0xffffb40e0607af27": {
|
"0xffffb40e0607af27": {
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
"led_brightness": 100,
|
"led_brightness": 100,
|
||||||
"countdown_to_turn_off": 0,
|
"countdown_to_turn_off": 0,
|
||||||
"countdown_to_turn_on": 0,
|
"countdown_to_turn_on": 0,
|
||||||
"power": 2.1,
|
"power": 2.7,
|
||||||
"current": 0.12,
|
"current": 0.12,
|
||||||
"energy": 28.37,
|
"energy": 28.37,
|
||||||
"power_factor": 0.18,
|
"power_factor": 0.18,
|
||||||
@@ -45,10 +45,10 @@
|
|||||||
"state": "ON",
|
"state": "ON",
|
||||||
"led_brightness": 100,
|
"led_brightness": 100,
|
||||||
"countdown_to_turn_off": 0,
|
"countdown_to_turn_off": 0,
|
||||||
"voltage": 120.7,
|
"voltage": 121.1,
|
||||||
"countdown_to_turn_on": 0,
|
"countdown_to_turn_on": 0,
|
||||||
"energy": 55.33,
|
"energy": 55.35,
|
||||||
"power_factor": 0.89,
|
"power_factor": 0.2,
|
||||||
"ac_frequency": 60,
|
"ac_frequency": 60,
|
||||||
"update": {
|
"update": {
|
||||||
"state": "idle",
|
"state": "idle",
|
||||||
@@ -58,8 +58,8 @@
|
|||||||
"latest_release_notes": null
|
"latest_release_notes": null
|
||||||
},
|
},
|
||||||
"linkquality": 134,
|
"linkquality": 134,
|
||||||
"power": 84.6,
|
"power": 0.5,
|
||||||
"current": 0.8,
|
"current": 0.04,
|
||||||
"power_on_behavior": "on"
|
"power_on_behavior": "on"
|
||||||
},
|
},
|
||||||
"0xb40e060fffe031e3": {
|
"0xb40e060fffe031e3": {
|
||||||
@@ -74,13 +74,13 @@
|
|||||||
"led_brightness": 100,
|
"led_brightness": 100,
|
||||||
"countdown_to_turn_off": 0,
|
"countdown_to_turn_off": 0,
|
||||||
"countdown_to_turn_on": 0,
|
"countdown_to_turn_on": 0,
|
||||||
"voltage": 120.5,
|
"voltage": 120.3,
|
||||||
"state": "ON",
|
"state": "ON",
|
||||||
"ac_frequency": 60,
|
"ac_frequency": 60,
|
||||||
"energy": 113.64,
|
"energy": 113.67,
|
||||||
"power": 0.8,
|
"power": 0.8,
|
||||||
"current": 0.05,
|
"current": 0.87,
|
||||||
"power_factor": 0.41,
|
"power_factor": 0.34,
|
||||||
"update": {
|
"update": {
|
||||||
"state": "idle",
|
"state": "idle",
|
||||||
"installed_version": 268513381,
|
"installed_version": 268513381,
|
||||||
@@ -96,12 +96,12 @@
|
|||||||
"countdown_to_turn_off": 0,
|
"countdown_to_turn_off": 0,
|
||||||
"countdown_to_turn_on": 0,
|
"countdown_to_turn_on": 0,
|
||||||
"voltage": 121.3,
|
"voltage": 121.3,
|
||||||
"energy": 50.5,
|
"energy": 50.52,
|
||||||
"state": "ON",
|
"state": "ON",
|
||||||
"power": 44.2,
|
"power": 38.6,
|
||||||
"current": 0.53,
|
"current": 0.42,
|
||||||
"ac_frequency": 60,
|
"ac_frequency": 60,
|
||||||
"power_factor": 0.76,
|
"power_factor": 0.74,
|
||||||
"update": {
|
"update": {
|
||||||
"state": "idle",
|
"state": "idle",
|
||||||
"installed_version": 268513381,
|
"installed_version": 268513381,
|
||||||
@@ -114,11 +114,11 @@
|
|||||||
},
|
},
|
||||||
"0xffffb40e060895b3": {
|
"0xffffb40e060895b3": {
|
||||||
"state": "ON",
|
"state": "ON",
|
||||||
"voltage": 121,
|
"voltage": 121.4,
|
||||||
"ac_frequency": 60,
|
"ac_frequency": 60,
|
||||||
"energy": 7.14,
|
"energy": 7.14,
|
||||||
"current": 0.01,
|
"current": 0.01,
|
||||||
"power": 0.2,
|
"power": 0.1,
|
||||||
"power_factor": 0.11,
|
"power_factor": 0.11,
|
||||||
"linkquality": 123,
|
"linkquality": 123,
|
||||||
"update": {
|
"update": {
|
||||||
@@ -172,7 +172,7 @@
|
|||||||
"0xffffb40e060893d8": {
|
"0xffffb40e060893d8": {
|
||||||
"state": "ON",
|
"state": "ON",
|
||||||
"led_brightness": 100,
|
"led_brightness": 100,
|
||||||
"voltage": 121.2,
|
"voltage": 121.7,
|
||||||
"countdown_to_turn_off": 0,
|
"countdown_to_turn_off": 0,
|
||||||
"countdown_to_turn_on": 0,
|
"countdown_to_turn_on": 0,
|
||||||
"energy": 3.11,
|
"energy": 3.11,
|
||||||
@@ -187,8 +187,8 @@
|
|||||||
"latest_source": "https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/images/ThirdReality/SmartPlug_Zigbee_PROD_OTA_V101_1.01.01.ota",
|
"latest_source": "https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/images/ThirdReality/SmartPlug_Zigbee_PROD_OTA_V101_1.01.01.ota",
|
||||||
"latest_release_notes": null
|
"latest_release_notes": null
|
||||||
},
|
},
|
||||||
"power_factor": 0,
|
"power_factor": 0.1,
|
||||||
"power": 0
|
"power": 0.1
|
||||||
},
|
},
|
||||||
"0xa4c1380d0679ffff": {
|
"0xa4c1380d0679ffff": {
|
||||||
"battery": 100,
|
"battery": 100,
|
||||||
|
|||||||
Reference in New Issue
Block a user