New apps Added

This commit is contained in:
2026-07-08 10:43:39 -04:00
parent 3b1f4bbd75
commit fefc2c8b5c
1114 changed files with 406637 additions and 154 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,90 @@
"""Binary sensor platform for TaskMate integration."""
from __future__ import annotations
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import TaskMateCoordinator
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up TaskMate binary sensors."""
coordinator: TaskMateCoordinator = hass.data[DOMAIN][entry.entry_id]
entities: list[BinarySensorEntity] = [
HasPendingApprovalsBinarySensor(coordinator, entry),
]
async_add_entities(entities)
class TaskMateBaseBinarySensor(CoordinatorEntity, BinarySensorEntity):
"""Base class for TaskMate binary sensors."""
def __init__(
self,
coordinator: TaskMateCoordinator,
entry: ConfigEntry,
) -> None:
"""Initialize the binary sensor."""
super().__init__(coordinator)
self._entry = entry
@property
def device_info(self) -> DeviceInfo:
"""Return device info."""
return DeviceInfo(
identifiers={(DOMAIN, self._entry.entry_id)},
name="TaskMate",
manufacturer="TaskMate",
model="Family Chore Manager",
)
class HasPendingApprovalsBinarySensor(TaskMateBaseBinarySensor):
"""Binary sensor indicating if there are pending approvals."""
def __init__(
self,
coordinator: TaskMateCoordinator,
entry: ConfigEntry,
) -> None:
"""Initialize the binary sensor."""
super().__init__(coordinator, entry)
self._attr_unique_id = f"{entry.entry_id}_has_pending_approvals"
self._attr_name = "Has Pending Approvals"
@property
def is_on(self) -> bool:
"""Return true if there are pending approvals."""
pending_completions = self.coordinator.data.get("pending_completions", [])
pending_rewards = self.coordinator.data.get("pending_reward_claims", [])
return len(pending_completions) > 0 or len(pending_rewards) > 0
@property
def icon(self) -> str:
"""Return the icon."""
if self.is_on:
return "mdi:bell-alert"
return "mdi:bell-check"
@property
def extra_state_attributes(self) -> dict:
"""Return additional attributes."""
pending_completions = self.coordinator.data.get("pending_completions", [])
pending_rewards = self.coordinator.data.get("pending_reward_claims", [])
return {
"pending_chore_completions": len(pending_completions),
"pending_reward_claims": len(pending_rewards),
"total_pending": len(pending_completions) + len(pending_rewards),
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+251
View File
@@ -0,0 +1,251 @@
"""Button platform for TaskMate integration."""
from __future__ import annotations
import logging
from homeassistant.components.button import ButtonEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import TaskMateCoordinator
from .models import Child, Chore, Reward
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up TaskMate buttons."""
coordinator: TaskMateCoordinator = hass.data[DOMAIN][entry.entry_id]
entities: list[ButtonEntity] = []
# Add complete chore buttons for each child/chore combination
children = coordinator.data.get("children", [])
chores = coordinator.data.get("chores", [])
rewards = coordinator.data.get("rewards", [])
for child in children:
# Chore completion buttons
for chore in chores:
if getattr(chore, "assignment_mode", "everyone") == "unassigned":
continue
if not chore.assigned_to or child.id in chore.assigned_to:
entities.append(
CompleteChoreButton(coordinator, entry, child, chore)
)
# Reward claim buttons
for reward in rewards:
entities.append(
ClaimRewardButton(coordinator, entry, child, reward)
)
# Track which entity combos already exist
tracked_combos: set[str] = set()
for child in children:
for chore in chores:
if getattr(chore, "assignment_mode", "everyone") == "unassigned":
continue
if not chore.assigned_to or child.id in chore.assigned_to:
tracked_combos.add(f"{child.id}_{chore.id}_complete")
for reward in rewards:
tracked_combos.add(f"{child.id}_{reward.id}_claim")
async_add_entities(entities)
# Set up listener to add buttons for new children/chores/rewards
@callback
def async_update_entities() -> None:
"""Add button entities for newly created children, chores, or rewards."""
new_entities: list[ButtonEntity] = []
current_children = coordinator.data.get("children", [])
current_chores = coordinator.data.get("chores", [])
current_rewards = coordinator.data.get("rewards", [])
for child in current_children:
for chore in current_chores:
if getattr(chore, "assignment_mode", "everyone") == "unassigned":
continue
if not chore.assigned_to or child.id in chore.assigned_to:
key = f"{child.id}_{chore.id}_complete"
if key not in tracked_combos:
new_entities.append(
CompleteChoreButton(coordinator, entry, child, chore)
)
tracked_combos.add(key)
for reward in current_rewards:
key = f"{child.id}_{reward.id}_claim"
if key not in tracked_combos:
new_entities.append(
ClaimRewardButton(coordinator, entry, child, reward)
)
tracked_combos.add(key)
if new_entities:
async_add_entities(new_entities)
coordinator.async_add_listener(async_update_entities)
class TaskMateBaseButton(CoordinatorEntity, ButtonEntity):
"""Base class for TaskMate buttons."""
def __init__(
self,
coordinator: TaskMateCoordinator,
entry: ConfigEntry,
) -> None:
"""Initialize the button."""
super().__init__(coordinator)
self._entry = entry
@property
def device_info(self) -> DeviceInfo:
"""Return device info."""
return DeviceInfo(
identifiers={(DOMAIN, self._entry.entry_id)},
name="TaskMate",
manufacturer="TaskMate",
model="Family Chore Manager",
)
class CompleteChoreButton(TaskMateBaseButton):
"""Button to mark a chore as completed."""
def __init__(
self,
coordinator: TaskMateCoordinator,
entry: ConfigEntry,
child: Child,
chore: Chore,
) -> None:
"""Initialize the button."""
super().__init__(coordinator, entry)
self.child_id = child.id
self.chore_id = chore.id
self._attr_unique_id = f"{entry.entry_id}_{child.id}_{chore.id}_complete"
self._attr_name = f"{child.name}: Complete {chore.name}"
@property
def icon(self) -> str:
"""Return the icon."""
chore = self.coordinator.get_chore(self.chore_id)
return getattr(chore, 'icon', "mdi:check-circle") if chore else "mdi:check-circle"
@property
def extra_state_attributes(self) -> dict:
"""Return additional attributes."""
child = self.coordinator.get_child(self.child_id)
chore = self.coordinator.get_chore(self.chore_id)
if not child or not chore:
return {}
return {
"child_id": child.id,
"child_name": child.name,
"chore_id": chore.id,
"chore_name": chore.name,
"points": chore.points,
"requires_approval": chore.requires_approval,
}
async def async_press(self) -> None:
"""Handle the button press."""
try:
await self.coordinator.async_complete_chore(self.chore_id, self.child_id)
except ValueError as err:
_LOGGER.error("Failed to complete chore: %s", err)
class ClaimRewardButton(TaskMateBaseButton):
"""Button to claim a reward."""
def __init__(
self,
coordinator: TaskMateCoordinator,
entry: ConfigEntry,
child: Child,
reward: Reward,
) -> None:
"""Initialize the button."""
super().__init__(coordinator, entry)
self.child_id = child.id
self.reward_id = reward.id
self._attr_unique_id = f"{entry.entry_id}_{child.id}_{reward.id}_claim"
self._attr_name = f"{child.name}: Claim {reward.name}"
@property
def icon(self) -> str:
"""Return the icon."""
reward = self.coordinator.get_reward(self.reward_id)
return reward.icon if reward else "mdi:gift"
def _get_committed_points(self, child_id: str) -> int:
"""Calculate points committed by pending reward claims for a child.
Pool-mode pending claims are skipped because their cost was already
deducted from child.points at allocation time.
"""
pending_claims = self.coordinator.data.get("pending_reward_claims", [])
committed = 0
for c in pending_claims:
if c.child_id == child_id and not self.coordinator.is_pool_mode_claim(c):
reward = self.coordinator.get_reward(c.reward_id)
if reward:
committed += reward.cost
return committed
@property
def available(self) -> bool:
"""Return if button is available (accounting for committed points)."""
child = self.coordinator.get_child(self.child_id)
reward = self.coordinator.get_reward(self.reward_id)
if not child or not reward:
return False
available_points = child.points - self._get_committed_points(child.id)
return available_points >= reward.cost
@property
def extra_state_attributes(self) -> dict:
"""Return additional attributes."""
child = self.coordinator.get_child(self.child_id)
reward = self.coordinator.get_reward(self.reward_id)
if not child or not reward:
return {}
committed = self._get_committed_points(child.id)
available_points = child.points - committed
can_afford = available_points >= reward.cost
return {
"child_id": child.id,
"child_name": child.name,
"reward_id": reward.id,
"reward_name": reward.name,
"cost": reward.cost,
"child_points": child.points,
"committed_points": committed,
"available_points": available_points,
"can_afford": can_afford,
"points_needed": max(0, reward.cost - available_points),
}
async def async_press(self) -> None:
"""Handle the button press."""
try:
await self.coordinator.async_claim_reward(self.reward_id, self.child_id)
except ValueError as err:
_LOGGER.error("Failed to claim reward: %s", err)
+232
View File
@@ -0,0 +1,232 @@
"""Calendar platform for TaskMate.
Exposes one read-only ``calendar.taskmate_<child>`` entity per child. Events are
derived live from existing TaskMate data — there is no second store and nothing
to keep in sync:
* chore occurrences come from the recurrence/assignment engine
(``_is_chore_scheduled_for_date`` + ``_compute_active_children``), rendered
as timed events inside the configured time-of-day window or as all-day
events when the chore is "anytime";
* away / unavailable blocks come from ``_is_child_on_vacation`` (#525),
coalesced into multi-day all-day events. On an away day the child's chores
are hidden, mirroring the rest of the integration.
Read-only for now: completing a chore from the calendar is intentionally not
supported.
"""
from __future__ import annotations
import logging
from datetime import date, datetime, timedelta
from homeassistant.components.calendar import CalendarEntity, CalendarEvent
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util
from .const import DOMAIN
from .coordinator import TaskMateCoordinator
from .models import Child, Chore
_LOGGER = logging.getLogger(__name__)
# How far ahead the `event` (next-up) property scans for the soonest event.
_NEXT_EVENT_HORIZON_DAYS = 60
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up one calendar entity per child, adding more as children are created."""
coordinator: TaskMateCoordinator = hass.data[DOMAIN][entry.entry_id]
tracked: set[str] = set()
def _new_entities() -> list[TaskMateCalendar]:
out: list[TaskMateCalendar] = []
for child in coordinator.data.get("children", []):
if child.id in tracked:
continue
tracked.add(child.id)
out.append(TaskMateCalendar(coordinator, entry, child))
return out
async_add_entities(_new_entities())
@callback
def _async_add_new() -> None:
new = _new_entities()
if new:
async_add_entities(new)
coordinator.async_add_listener(_async_add_new)
def _chore_applies_to_child(
coordinator: TaskMateCoordinator, chore: Chore, child_id: str, day: date
) -> bool:
"""True if ``chore`` is scheduled for ``child_id`` on ``day``.
Combines the recurrence schedule with the assignment engine so the calendar
matches who would actually see the chore — without consulting completion
state (the calendar projects the schedule, not today's done/not-done).
"""
if not getattr(chore, "enabled", True):
return False
if getattr(chore, "assignment_mode", "everyone") == "unassigned":
return False
if not coordinator._is_chore_scheduled_for_date(chore, day):
return False
active = coordinator._compute_active_children(chore, day)
if active:
return child_id in active
# Empty active set means an unrestricted "everyone" chore (no assigned_to).
return not chore.assigned_to
def _chore_description(chore: Chore) -> str:
"""Compact one-line description for a chore event."""
parts = ["TaskMate chore"]
pts = getattr(chore, "points", 0) or 0
parts.append(f"{pts} pts")
cat = getattr(chore, "time_category", "anytime") or "anytime"
if cat != "anytime":
parts.append(cat)
return " · ".join(parts)
def _as_local_dt(value: date | datetime) -> datetime:
"""Normalise a CalendarEvent start/end to an aware local datetime for sorting."""
if isinstance(value, datetime):
if value.tzinfo is None:
return value.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE)
return value
return datetime(value.year, value.month, value.day, tzinfo=dt_util.DEFAULT_TIME_ZONE)
class TaskMateCalendar(CoordinatorEntity, CalendarEntity):
"""A read-only calendar of one child's chores and away periods."""
_attr_icon = "mdi:calendar-account"
def __init__(
self,
coordinator: TaskMateCoordinator,
entry: ConfigEntry,
child: Child,
) -> None:
super().__init__(coordinator)
self._entry = entry
self._child_id = child.id
self._attr_unique_id = f"{entry.entry_id}_{child.id}_calendar"
self._attr_name = f"TaskMate {child.name}"
@property
def device_info(self) -> DeviceInfo:
return DeviceInfo(
identifiers={(DOMAIN, self._entry.entry_id)},
name="TaskMate",
manufacturer="TaskMate",
model="Family Chore Manager",
)
@property
def _child(self) -> Child | None:
return self.coordinator.storage.get_child(self._child_id)
@property
def available(self) -> bool:
return self._child is not None and super().available
@property
def event(self) -> CalendarEvent | None:
"""The current or next upcoming event (HA shows this as the entity state)."""
child = self._child
if not child:
return None
now = dt_util.now()
today = now.date()
events = self._build_events(child, today, today + timedelta(days=_NEXT_EVENT_HORIZON_DAYS))
upcoming = [e for e in events if _as_local_dt(e.end) > now]
upcoming.sort(key=lambda e: _as_local_dt(e.start))
return upcoming[0] if upcoming else None
async def async_get_events(
self, hass: HomeAssistant, start_date: datetime, end_date: datetime
) -> list[CalendarEvent]:
"""Return all events overlapping the requested range."""
child = self._child
if not child:
return []
return self._build_events(child, start_date.date(), end_date.date())
def _build_events(
self, child: Child, start_day: date, end_day: date
) -> list[CalendarEvent]:
coord = self.coordinator
events: list[CalendarEvent] = []
# ---- away blocks: coalesce consecutive away days into one all-day event ----
run_start: date | None = None
run_label: str | None = None
day = start_day
while day <= end_day:
if coord._is_child_on_vacation(child, day):
if run_start is None:
period = coord.active_vacation(day)
run_label = (period or {}).get("name") or ""
run_start = day
elif run_start is not None:
events.append(_away_event(run_start, day, run_label))
run_start = None
day += timedelta(days=1)
if run_start is not None:
events.append(_away_event(run_start, end_day + timedelta(days=1), run_label))
# ---- chore occurrences (skipped on away days) ----
chores = coord.storage.get_chores()
tz = dt_util.DEFAULT_TIME_ZONE
day = start_day
while day <= end_day:
if not coord._is_child_on_vacation(child, day):
for chore in chores:
if not _chore_applies_to_child(coord, chore, child.id, day):
continue
window = coord._time_category_window(
getattr(chore, "time_category", "anytime"), day
)
desc = _chore_description(chore)
if window is None:
events.append(CalendarEvent(
start=day,
end=day + timedelta(days=1),
summary=chore.name,
description=desc,
))
else:
start_dt, end_dt = window
events.append(CalendarEvent(
start=start_dt.replace(tzinfo=tz),
end=end_dt.replace(tzinfo=tz),
summary=chore.name,
description=desc,
))
day += timedelta(days=1)
return events
def _away_event(start_day: date, end_day_exclusive: date, label: str | None) -> CalendarEvent:
"""Build a coalesced all-day 'away' event over [start, end)."""
summary = f"Away — {label}" if label else "Away"
return CalendarEvent(
start=start_day,
end=end_day_exclusive,
summary=summary,
description="Unavailable — streak paused and chores hidden.",
)
+58
View File
@@ -0,0 +1,58 @@
"""Config flow for TaskMate integration.
Only the initial setup flow lives here. Day-to-day configuration (children,
chores, rewards, task groups, settings, notifications, badges, templates, …)
is handled entirely by the dedicated TaskMate admin panel at
``/taskmate-admin``. The legacy options/configure flow was removed in v4.0 —
all of its functionality is available in the panel, and all data is stored in
the integration's own ``Store`` rather than in ``config_entry.options``.
"""
from __future__ import annotations
from typing import Any
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import selector
from .const import DEFAULT_POINTS_ICON, DEFAULT_POINTS_NAME, DOMAIN
class TaskMateConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for TaskMate."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
# Check if already configured
await self.async_set_unique_id(DOMAIN)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title="TaskMate",
data={
"points_name": user_input.get("points_name", DEFAULT_POINTS_NAME),
"points_icon": user_input.get("points_icon", DEFAULT_POINTS_ICON),
},
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Optional("points_name", default=DEFAULT_POINTS_NAME): str,
vol.Optional("points_icon", default=DEFAULT_POINTS_ICON): selector.IconSelector(),
}
),
errors=errors,
description_placeholders={
"title": "Welcome to TaskMate!",
},
)
+390
View File
@@ -0,0 +1,390 @@
"""Constants for TaskMate integration."""
from typing import Final
DOMAIN: Final = "taskmate"
# Configuration keys
CONF_CHILDREN: Final = "children"
CONF_CHORES: Final = "chores"
CONF_REWARDS: Final = "rewards"
CONF_POINTS_NAME: Final = "points_name"
CONF_POINTS_ICON: Final = "points_icon"
# Child keys
CONF_CHILD_NAME: Final = "name"
CONF_CHILD_AVATAR: Final = "avatar"
CONF_CHILD_POINTS: Final = "points"
CONF_CHILD_ID: Final = "id"
CONF_CHILD_AVAILABILITY_ENTITY: Final = "availability_entity"
# Chore keys
CONF_CHORE_NAME: Final = "name"
CONF_CHORE_DESCRIPTION: Final = "description"
CONF_CHORE_POINTS: Final = "points"
CONF_CHORE_DUE_DAYS: Final = "due_days"
CONF_CHORE_ASSIGNED_TO: Final = "assigned_to"
CONF_CHORE_ID: Final = "id"
CONF_CHORE_REQUIRES_APPROVAL: Final = "requires_approval"
CONF_CLAIM_ALLOWANCE_MINUTES: Final = "claim_allowance_minutes"
DEFAULT_CLAIM_ALLOWANCE_MINUTES: Final = 0
CONF_CHORE_ASSIGNMENT_MODE: Final = "assignment_mode"
CONF_CHORE_ASSIGNMENT_ROTATION_ANCHOR: Final = "assignment_rotation_anchor"
CONF_CHORE_PUBLISH_CALENDARS: Final = "publish_calendar_entities"
CONF_CHORE_REQUIRE_AVAILABILITY: Final = "require_availability"
CONF_CHORE_MANUAL_START_CHILD: Final = "manual_start_child_id"
# Task groups
CONF_TASK_GROUPS: Final = "task_groups"
CONF_TASK_GROUP_ID: Final = "group_id"
CONF_TASK_GROUP_NAME: Final = "name"
CONF_TASK_GROUP_POLICY: Final = "policy"
CONF_TASK_GROUP_CHORE_IDS: Final = "chore_ids"
TASK_GROUP_POLICIES: Final = ["sticky", "spread"]
DEFAULT_TASK_GROUP_POLICY: Final = "sticky"
# Calendar projection: how many days ahead each chore's assignment is pushed
# into the configured HA calendars. 14 days balances planning visibility with
# calendar noise; raise via the Settings flow for longer planning horizons.
CONF_CALENDAR_PROJECTION_DAYS: Final = "calendar_projection_days"
DEFAULT_CALENDAR_PROJECTION_DAYS: Final = 14
MIN_CALENDAR_PROJECTION_DAYS: Final = 1
MAX_CALENDAR_PROJECTION_DAYS: Final = 90
# Assignment modes
# "everyone" = visible to every child in assigned_to (or all children if empty) -- existing behavior
# "alternating" = round-robin through assigned_to (or all children), one child per calendar day
# "random" = deterministic per-day random pick from the same pool (each chore picks independently)
# "balanced" = split today's balanced-mode chores evenly across the pool so no one child gets swamped
# "first_come" = competitive: shown to the whole pool at once; first child to complete it wins and it
# hides for everyone else (shared quota of 1). A parent rejection reopens it for the pool.
ASSIGNMENT_MODES: Final = ["everyone", "alternating", "random", "balanced", "first_come", "unassigned"]
DEFAULT_ASSIGNMENT_MODE: Final = "everyone"
# Reward keys
CONF_REWARD_NAME: Final = "name"
CONF_REWARD_DESCRIPTION: Final = "description"
CONF_REWARD_COST: Final = "cost"
CONF_REWARD_ICON: Final = "icon"
CONF_REWARD_ID: Final = "id"
CONF_REWARD_QUANTITY: Final = "quantity"
CONF_REWARD_EXPIRES_AT: Final = "expires_at"
# Default values
DEFAULT_POINTS_NAME: Final = "Stars"
DEFAULT_POINTS_ICON: Final = "mdi:star"
# Days of week
DAYS_OF_WEEK: Final = [
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
"sunday",
]
# Recurrence options for chores (Mode B)
RECURRENCE_OPTIONS: Final = [
"every_2_days",
"weekly",
"every_2_weeks",
"monthly",
"every_3_months",
"every_6_months",
]
RECURRENCE_LABELS: Final = {
"every_2_days": "Every 2 days",
"weekly": "Weekly",
"every_2_weeks": "Every 2 weeks",
"monthly": "Monthly",
"every_3_months": "Every 3 months",
"every_6_months": "Every 6 months",
}
# Schedule modes
SCHEDULE_MODES: Final = ["specific_days", "recurring", "one_shot"]
# First occurrence modes
FIRST_OCCURRENCE_MODES: Final = ["available_immediately", "wait_for_first_occurrence"]
# Time categories for chores
TIME_CATEGORIES: Final = [
"morning",
"afternoon",
"evening",
"night",
"anytime",
]
# Time category icons
TIME_CATEGORY_ICONS: Final = {
"morning": "mdi:weather-sunny",
"afternoon": "mdi:white-balance-sunny",
"evening": "mdi:weather-sunset",
"night": "mdi:weather-night",
"anytime": "mdi:clock-outline",
}
# Default user-editable time-of-day periods. "anytime" is implicit (all-day,
# always available) and never appears in this list. An empty label means
# "use the translated built-in name for this id".
DEFAULT_TIME_PERIODS: Final = [
{"id": "morning", "label": "", "start": "06:00", "end": "12:00", "icon": "mdi:weather-sunny"},
{"id": "afternoon", "label": "", "start": "12:00", "end": "17:00", "icon": "mdi:white-balance-sunny"},
{"id": "evening", "label": "", "start": "17:00", "end": "21:00", "icon": "mdi:weather-sunset"},
{"id": "night", "label": "", "start": "21:00", "end": "23:59", "icon": "mdi:weather-night"},
]
MAX_TIME_PERIODS: Final = 24
# Avatar options
AVATAR_OPTIONS: Final = [
# Basic faces
"mdi:account-circle",
"mdi:face-man",
"mdi:face-woman",
"mdi:face-man-outline",
"mdi:face-woman-outline",
# Fun character avatars
"mdi:robot-happy",
"mdi:robot-excited",
"mdi:alien",
"mdi:ninja",
"mdi:pirate",
# Animals kids love
"mdi:cat",
"mdi:dog",
"mdi:unicorn",
"mdi:dragon",
"mdi:owl",
"mdi:penguin",
"mdi:panda",
"mdi:rabbit",
"mdi:koala",
"mdi:fox",
"mdi:dolphin",
"mdi:jellyfish",
"mdi:octopus",
"mdi:butterfly",
# Splatoon Inkling-inspired (colorful, action-themed)
"mdi:palette",
"mdi:spray",
"mdi:water-outline",
"mdi:shimmer",
"mdi:flare",
"mdi:star-face",
"mdi:emoticon-cool",
"mdi:emoticon-excited",
"mdi:emoticon-kiss",
"mdi:emoticon-wink",
"mdi:emoticon-happy",
"mdi:emoticon-poop",
"mdi:face-woman-shimmer",
"mdi:face-man-shimmer",
"mdi:account-star",
"mdi:account-heart",
# Gaming and action themed
"mdi:gamepad-variant",
"mdi:controller",
"mdi:trophy",
"mdi:crown",
"mdi:lightning-bolt",
"mdi:fire",
"mdi:rocket",
"mdi:sword",
"mdi:shield",
# Space and fantasy
"mdi:alien-outline",
"mdi:ufo",
"mdi:space-invaders",
"mdi:ghost",
"mdi:skull-crossbones",
"mdi:wizard-hat",
"mdi:magic-staff",
# Nature and colorful
"mdi:flower-tulip",
"mdi:flower-poppy",
"mdi:clover",
"mdi:mushroom",
"mdi:star-shooting",
"mdi:star-four-points",
"mdi:heart",
"mdi:heart-multiple",
"mdi:diamond-stone",
"mdi:puzzle-heart",
]
# Reward icon options
REWARD_ICON_OPTIONS: Final = [
"mdi:gift",
"mdi:ice-cream",
"mdi:pizza",
"mdi:movie",
"mdi:gamepad-variant",
"mdi:tablet",
"mdi:television",
"mdi:bike",
"mdi:currency-usd",
"mdi:shopping",
"mdi:party-popper",
"mdi:swim",
"mdi:sleep",
"mdi:candy",
]
# Platforms
PLATFORMS: Final = ["sensor", "button", "binary_sensor"]
# Services
SERVICE_COMPLETE_CHORE: Final = "complete_chore"
SERVICE_APPROVE_CHORE: Final = "approve_chore"
SERVICE_APPROVE_ALL_CHORES: Final = "approve_all_chores"
SERVICE_REJECT_CHORE: Final = "reject_chore"
SERVICE_UNDO_CHORE_APPROVAL: Final = "undo_chore_approval"
SERVICE_APPLY_MANDATORY_PENALTY: Final = "apply_mandatory_penalty"
SERVICE_POSTPONE_MANDATORY_CHORE: Final = "postpone_mandatory_chore"
SERVICE_DISMISS_MANDATORY_CHORE: Final = "dismiss_mandatory_chore"
SERVICE_CLAIM_REWARD: Final = "claim_reward"
SERVICE_APPROVE_REWARD: Final = "approve_reward"
SERVICE_REJECT_REWARD: Final = "reject_reward"
SERVICE_ADD_POINTS: Final = "add_points"
SERVICE_REMOVE_POINTS: Final = "remove_points"
SERVICE_UNDO_TRANSACTION: Final = "undo_transaction"
SERVICE_TEST_NOTIFICATION: Final = "test_notification"
SERVICE_GIFT_POINTS: Final = "gift_points"
SERVICE_RECORD_ALLOWANCE_PAYOUT: Final = "record_allowance_payout"
SERVICE_REQUEST_SWAP: Final = "request_swap"
SERVICE_CHOOSE_AVATAR: Final = "choose_avatar"
SERVICE_SET_CHORE_ORDER: Final = "set_chore_order"
SERVICE_PREVIEW_SOUND: Final = "preview_sound"
SERVICE_ADD_PENALTY: Final = "add_penalty"
SERVICE_UPDATE_PENALTY: Final = "update_penalty"
SERVICE_REMOVE_PENALTY: Final = "remove_penalty"
SERVICE_APPLY_PENALTY: Final = "apply_penalty"
SERVICE_ADD_BONUS: Final = "add_bonus"
SERVICE_UPDATE_BONUS: Final = "update_bonus"
SERVICE_REMOVE_BONUS: Final = "remove_bonus"
SERVICE_APPLY_BONUS: Final = "apply_bonus"
SERVICE_ADD_CHORE: Final = "add_chore"
SERVICE_ALLOCATE_POINTS_TO_POOL: Final = "allocate_points_to_pool"
SERVICE_SKIP_CHORE: Final = "skip_chore"
SERVICE_SET_CHORE_MANUAL_START: Final = "set_chore_manual_start"
SERVICE_ADD_TASK_GROUP: Final = "add_task_group"
SERVICE_UPDATE_TASK_GROUP: Final = "update_task_group"
SERVICE_REMOVE_TASK_GROUP: Final = "remove_task_group"
SERVICE_COMPLETE_BONUS_SUBTASK: Final = "complete_bonus_subtask"
SERVICE_START_TIMED_TASK: Final = "start_timed_task"
SERVICE_PAUSE_TIMED_TASK: Final = "pause_timed_task"
SERVICE_STOP_TIMED_TASK: Final = "stop_timed_task"
# Events
EVENT_PREVIEW_SOUND: Final = "taskmate_preview_sound"
# Attributes
ATTR_CHILD_ID: Final = "child_id"
ATTR_CHORE_ID: Final = "chore_id"
ATTR_AS_PARENT: Final = "as_parent"
ATTR_REWARD_ID: Final = "reward_id"
ATTR_POINTS: Final = "points"
ATTR_REASON: Final = "reason"
ATTR_CHORE_ORDER: Final = "chore_order"
ATTR_SOUND: Final = "sound"
ATTR_PENALTY_ID: Final = "penalty_id"
ATTR_PENALTY_NAME: Final = "name"
ATTR_PENALTY_POINTS: Final = "points"
ATTR_PENALTY_DESCRIPTION: Final = "description"
ATTR_PENALTY_ICON: Final = "icon"
ATTR_PENALTY_ASSIGNED_TO: Final = "assigned_to"
ATTR_BONUS_ID: Final = "bonus_id"
ATTR_BONUS_NAME: Final = "name"
ATTR_BONUS_POINTS: Final = "points"
ATTR_BONUS_DESCRIPTION: Final = "description"
ATTR_BONUS_ICON: Final = "icon"
ATTR_BONUS_ASSIGNED_TO: Final = "assigned_to"
ATTR_CHORE_NAME: Final = "name"
ATTR_CHORE_DESCRIPTION: Final = "description"
ATTR_CHORE_POINTS: Final = "points"
ATTR_CHORE_ASSIGNED_TO: Final = "assigned_to"
ATTR_CHORE_TIME_CATEGORY: Final = "time_category"
ATTR_CHORE_ONE_SHOT: Final = "one_shot"
ATTR_CHORE_REQUIRES_APPROVAL: Final = "requires_approval"
ATTR_BONUS_SUBTASK_ID: Final = "bonus_subtask_id"
# Badge attributes
ATTR_BADGE_ID: Final = "badge_id"
ATTR_BADGE_NAME: Final = "name"
ATTR_BADGE_DESCRIPTION: Final = "description"
ATTR_BADGE_ICON: Final = "icon"
ATTR_BADGE_TIER: Final = "tier"
ATTR_BADGE_POINT_BONUS: Final = "point_bonus"
ATTR_BADGE_CRITERIA: Final = "criteria"
ATTR_BADGE_COMBINATOR: Final = "combinator"
ATTR_BADGE_ASSIGNED_TO: Final = "assigned_to"
ATTR_BADGE_NOTIFY_ON_EARN: Final = "notify_on_earn"
ATTR_BADGE_ENABLED: Final = "enabled"
ATTR_AWARDED_BADGE_ID: Final = "awarded_badge_id"
# States
STATE_PENDING: Final = "pending"
STATE_AWAITING_APPROVAL: Final = "awaiting_approval"
STATE_COMPLETED: Final = "completed"
STATE_CLAIMED: Final = "claimed"
# Completion sound options
# Most sounds are synthesized via Web Audio API
# Fart sounds are CC0 audio files from BigSoundBank.com and GfxSounds.com
COMPLETION_SOUND_OPTIONS: Final = [
"none", # No sound
"coin", # Coin collect sound
"levelup", # Level up / success sound
"fanfare", # Celebratory fanfare
"chime", # Simple chime
"powerup", # Power up sound
"undo", # Sad/descending "womp womp" for undo actions
"fart1", # Flatulence 1 (short)
"fart2", # Flatulence 2 (short)
"fart3", # Flatulence 3 (short)
"fart4", # Pony flatulence 2 (~3 sec)
"fart5", # Flatulence 4 - discreet (short)
"fart6", # Prout'cochons 1 - pig game sound (short)
"fart7", # Prout'cochons 2 - pig game sound (short)
"fart8", # Prout'cochons 3 - pig game sound (short)
"fart9", # Pony flatulence 1 (short)
"fart10", # Baby fart (short)
"fart_random", # Random fart - picks a random fart sound each time!
]
# Default completion sound
DEFAULT_COMPLETION_SOUND: Final = "coin"
# Chore keys (sound-related)
CONF_CHORE_COMPLETION_SOUND: Final = "completion_sound"
# --- Chore difficulty tiers ---
# Each chore carries a difficulty tier; the points it awards are the base
# points multiplied by the tier's multiplier. "medium" is the neutral baseline
# (×1.0) so chores that predate this feature (which default to medium) keep
# their exact award value. Multipliers are configurable via the settings
# "difficulty_multiplier_<tier>" keys.
DIFFICULTY_TIERS: Final = ("easy", "medium", "hard")
DEFAULT_DIFFICULTY: Final = "medium"
DEFAULT_DIFFICULTY_MULTIPLIERS: Final = {"easy": 0.5, "medium": 1.0, "hard": 2.0}
# --- Notification type IDs (v3.9.0) ---
NOTIF_TYPE_BEDTIME_REMINDER: Final = "bedtime_reminder"
NOTIF_TYPE_STREAK_AT_RISK: Final = "streak_at_risk"
NOTIF_TYPE_ALL_CHORES_DONE: Final = "all_chores_done"
NOTIF_TYPE_BADGE_EARNED: Final = "badge_earned"
NOTIF_TYPE_PENDING_CHORE_APPROVAL: Final = "pending_chore_approval"
NOTIF_TYPE_PENDING_REWARD_CLAIM: Final = "pending_reward_claim"
NOTIF_TYPE_STREAK_MILESTONE: Final = "streak_milestone"
NOTIF_TYPE_LEVEL_UP: Final = "level_up"
NOTIF_TYPE_WEEKLY_DIGEST: Final = "weekly_digest"
NOTIF_TYPE_CELEBRATION: Final = "celebration"
NOTIF_TYPE_MANDATORY_REMINDER: Final = "mandatory_reminder"
NOTIF_TYPE_MANDATORY_PARENT_ALERT: Final = "mandatory_parent_alert"
NOTIF_TYPE_MONTHLY_REPORT: Final = "monthly_report"
NOTIF_TYPE_SEASON_CHAMPION: Final = "season_champion"
NOTIF_TYPE_FAMILY_GOAL_REACHED: Final = "family_goal_reached"
@@ -0,0 +1,584 @@
"""Assignment operations mixin for TaskMateCoordinator."""
from __future__ import annotations
import asyncio
import hashlib
import logging
from datetime import date
from typing import TYPE_CHECKING, Any
from homeassistant.core import callback
from homeassistant.util import dt as dt_util
from .models import Chore
if TYPE_CHECKING:
pass
_LOGGER = logging.getLogger(__name__)
class AssignmentsMixin:
"""Mixin providing assignment rotation, availability, and visibility logic."""
def _refresh_tracked_availability_entities(self) -> None:
"""Rebuild the cached set of entities that gate child availability.
Children only change via mutations (which trigger a coordinator
refresh), so this is rebuilt on refresh rather than on every bus
state_changed event — which fires for every entity in HA (PERF-5).
"""
tracked: set[str] = set()
for c in self.storage.get_children():
if getattr(c, "availability_entity", ""):
tracked.add(c.availability_entity)
if getattr(c, "unavailability_entity", ""):
tracked.add(c.unavailability_entity)
self._tracked_availability_entities = tracked
@callback
def _availability_state_changed(self, event: Any) -> None:
"""Cheap bus-filter: dispatch a re-eval only when a tracked entity changes.
Decorated @callback so it runs on the event loop (a plain listener runs
in the executor, where async_create_task raises) and reads the cached
tracked-entity set rather than rescanning all children per event.
"""
data = getattr(event, "data", None) or {}
entity_id = data.get("entity_id")
if not entity_id:
return
if entity_id not in getattr(self, "_tracked_availability_entities", set()):
return
self.hass.async_create_task(self._async_reevaluate_availability())
async def _async_reevaluate_availability(self) -> None:
"""Re-run assignment for require_availability chores when availability flips.
Skips chores that already have a completion today so we don't phantom-
reassign a chore a child already ticked off. Only non-`everyone` modes
cache a single current child on the chore; `everyone` mode resolves
availability at read-time via `_compute_active_children`.
"""
today = dt_util.as_local(dt_util.now()).date()
completions_today: set[str] = set()
for comp in self.storage.get_completions():
try:
comp_date = dt_util.as_local(comp.completed_at).date()
except (AttributeError, TypeError, ValueError):
continue
if comp_date == today:
completions_today.add(comp.chore_id)
# Use the group-aware daily map so sticky/spread policies are honored
# when an availability flip causes a shift.
daily = self._compute_daily_assignments(today)
changed = False
for chore in self.storage.get_chores():
if not getattr(chore, "require_availability", False):
continue
if getattr(chore, "assignment_mode", "everyone") == "everyone":
continue
if chore.id in completions_today:
continue
desired = daily.get(chore.id, "")
if getattr(chore, "assignment_current_child_id", "") != desired:
chore.assignment_current_child_id = desired
self.storage.update_chore(chore)
changed = True
if changed:
await self.storage.async_save()
await self.async_refresh()
_AVAILABLE_STATES: frozenset[str] = frozenset({
"on", "home", "available", "present", "true",
})
def _is_visibility_entity_active(
self, visibility_entity: str, visibility_state: str, visibility_operator: str = "equals"
) -> bool:
"""Check if a visibility entity matches the desired state.
Args:
visibility_entity: Entity ID to check (e.g. 'binary_sensor.dishwasher')
visibility_state: State value to compare (e.g. 'on', '123', '10', '>=10', '<20')
visibility_operator: Comparison operator: equals, gte, lte, gt, lt, not_equals
Returns True if entity matches visibility_state with the specified operator.
Defaults to visible if entity doesn't exist.
"""
if not visibility_entity or visibility_operator == "none":
return True
# Default empty operator/state to sensible values
if not visibility_operator:
visibility_operator = "equals"
if not visibility_state:
visibility_state = "on"
# Get entity state from Home Assistant
state_obj = self.hass.states.get(visibility_entity)
if state_obj is None:
# Entity doesn't exist, treat as visible
_LOGGER.debug(
"Visibility entity '%s' not found, defaulting to visible",
visibility_entity,
)
return True
entity_state = state_obj.state
# Parse operator from visibility_state if embedded (e.g. ">=10", "<20")
parsed_operator = visibility_operator
parsed_state = visibility_state
if visibility_state.startswith(">="):
parsed_operator = "gte"
parsed_state = visibility_state[2:]
elif visibility_state.startswith("<="):
parsed_operator = "lte"
parsed_state = visibility_state[2:]
elif visibility_state.startswith(">"):
parsed_operator = "gt"
parsed_state = visibility_state[1:]
elif visibility_state.startswith("<"):
parsed_operator = "lt"
parsed_state = visibility_state[1:]
elif visibility_state.startswith("!="):
parsed_operator = "not_equals"
parsed_state = visibility_state[2:]
# Try numeric comparison if operator is not "equals"
if parsed_operator != "equals":
try:
threshold = float(parsed_state)
entity_value = float(entity_state)
if parsed_operator == "gte":
return entity_value >= threshold
elif parsed_operator == "lte":
return entity_value <= threshold
elif parsed_operator == "gt":
return entity_value > threshold
elif parsed_operator == "lt":
return entity_value < threshold
elif parsed_operator == "not_equals":
return entity_value != threshold
except (ValueError, TypeError):
# If conversion fails, fall through to string matching
pass
# Check state (case-insensitive exact match)
if entity_state.lower() == parsed_state.lower():
return True
# Check attributes for a matching value
if hasattr(state_obj, 'attributes') and state_obj.attributes:
for attr_value in state_obj.attributes.values():
if str(attr_value).lower() == parsed_state.lower():
return True
return False
def _is_child_available(self, child_id: str) -> bool:
"""Return True when the child should receive chores today.
Two-step evaluation:
1. availability_entity — fail-open; if inverted, _AVAILABLE_STATES means BUSY.
Inversion only applies to real states, not broken/missing sensors.
2. unavailability_entity — neutral when missing; _AVAILABLE_STATES means BUSY.
Final = step1 AND NOT step2.
"""
child = self.storage.get_child(child_id)
if not child:
return True
# Step 1: availability sensor (fail-open when broken/missing)
avail_ok = True
entity_id = getattr(child, "availability_entity", "") or ""
if entity_id:
raw = self._read_entity_active(entity_id)
if raw is not None:
avail_ok = raw
if getattr(child, "availability_inverted", False):
avail_ok = not avail_ok
# Step 2: unavailability sensor (neutral when broken/missing)
busy = False
unav_id = getattr(child, "unavailability_entity", "") or ""
if unav_id:
raw = self._read_entity_active(unav_id)
if raw is not None:
busy = raw
return avail_ok and not busy
def _read_entity_active(self, entity_id: str) -> bool | None:
"""Read an entity's state and return True if it's in _AVAILABLE_STATES,
False if it's a real state not in the set, or None if the entity is
missing/broken (unavailable/unknown/None) so callers can apply their
own default.
"""
state_obj = self.hass.states.get(entity_id)
if state_obj is None:
return None
raw = getattr(state_obj, "state", None)
if raw is None:
return None
value = str(raw).strip().lower()
if value in ("unavailable", "unknown", "none", ""):
return None
return value in self._AVAILABLE_STATES
def _chore_assignment_pool(self, chore: Chore) -> list[str]:
"""Resolve the ordered pool of child IDs this chore rotates through.
Prefers the chore's `assigned_to` list. When empty, falls back to every
stored child so "Everyone"-style chores can still alternate/randomize.
"""
pool = [cid for cid in (chore.assigned_to or []) if self.storage.get_child(cid)]
if pool:
return pool
return [child.id for child in self.storage.get_children()]
def _compute_active_children(self, chore: Chore, today: date | None = None) -> list[str]:
"""Return the child IDs the chore is active for today.
- everyone: whatever `assigned_to` already said (empty = all children).
- alternating: one child picked by `(today - anchor).days % len(pool)`.
- random: one child picked by a deterministic per-day+chore-id hash.
- balanced: today's balanced-mode chores sharing this pool are evenly
split across the pool via a round-robin anchored by the date — so 10
chores across 2 children always land 5/5 (11 lands 6/5, etc.).
When `chore.skip_date` matches today, `chore.skip_count` is added to the
computed rotation index so the pointer advances past any children the
parent has skipped. Stale skip state (skip_date != today) is ignored at
read time and cleared during the midnight refresh.
"""
# PERF-1: the active set depends only on the chore for "today" (the
# availability matrix calls this per chore × child). Memoize per chore
# within an availability build scope. Skip when an explicit `today` is
# passed (callers that probe other days must not read today's cache).
cache = getattr(self, "_avail_cache", None)
if cache is not None and today is None:
cached = cache["active"].get(chore.id)
if cached is None:
cached = self._compute_active_children_uncached(chore, None)
cache["active"][chore.id] = cached
return cached
return self._compute_active_children_uncached(chore, today)
def _compute_active_children_uncached(self, chore: Chore, today: date | None = None) -> list[str]:
mode = getattr(chore, "assignment_mode", "everyone")
require_availability = getattr(chore, "require_availability", False)
if mode == "unassigned":
return []
if mode == "first_come":
# Competitive: every child in the resolved pool sees it until the
# first completion fills the shared quota (see _is_rotation_done_today).
pool = self._chore_assignment_pool(chore)
if require_availability:
return [cid for cid in pool if self._is_child_available(cid)]
return pool
if mode not in ("alternating", "random", "balanced"):
assigned = list(chore.assigned_to or [])
if require_availability and assigned:
return [cid for cid in assigned if self._is_child_available(cid)]
return assigned
pool = self._chore_assignment_pool(chore)
if not pool:
return []
if today is None:
today = dt_util.as_local(dt_util.now()).date()
# Skip offset only applies when the skip was recorded today.
skip_offset = 0
if getattr(chore, "skip_date", "") == today.isoformat():
skip_offset = int(getattr(chore, "skip_count", 0) or 0)
if mode == "alternating":
anchor_iso = getattr(chore, "assignment_rotation_anchor", "") or ""
try:
anchor = date.fromisoformat(anchor_iso) if anchor_iso else today
except ValueError:
anchor = today
offset = (today - anchor).days
idx = (offset + skip_offset) % len(pool)
picked = self._skip_unavailable(pool, idx, require_availability)
return [picked] if picked else []
if mode == "random":
# random: stable per (chore.id, date) so the frontend and backend agree
digest = hashlib.sha256(f"{chore.id}:{today.toordinal()}".encode()).digest()
idx = (int.from_bytes(digest[:8], "big") + skip_offset) % len(pool)
picked = self._skip_unavailable(pool, idx, require_availability)
return [picked] if picked else []
# balanced: group today's balanced-mode chores that share this exact pool,
# sort them by id for determinism, then round-robin across the pool. A
# per-day start offset rotates who gets the "first" chore so no child is
# always the one doing chore #1.
pool_key = tuple(sorted(pool))
group_ids = sorted(
c.id
for c in self.storage.get_chores()
if getattr(c, "assignment_mode", "everyone") == "balanced"
and tuple(sorted(self._chore_assignment_pool(c))) == pool_key
)
try:
position = group_ids.index(chore.id)
except ValueError:
position = 0
start_digest = hashlib.sha256(f"balanced:{pool_key}:{today.toordinal()}".encode()).digest()
start = int.from_bytes(start_digest[:4], "big") % len(pool)
idx = (start + position + skip_offset) % len(pool)
picked = self._skip_unavailable(pool, idx, require_availability)
return [picked] if picked else []
def _compute_daily_assignments(self, today: date | None = None) -> dict[str, str]:
"""Compute today's assignment per rotation-mode chore, honoring groups.
Returns a map of chore_id -> child_id. Only chores with a non-everyone
assignment_mode are present. Task group policies (sticky / spread) are
applied on top of the per-chore raw pick.
"""
if today is None:
today = dt_util.as_local(dt_util.now()).date()
chores = self.storage.get_chores()
chore_by_id: dict[str, Chore] = {c.id: c for c in chores}
# Step 1: raw per-chore picks for rotation modes.
result: dict[str, str] = {}
for chore in chores:
mode = getattr(chore, "assignment_mode", "everyone")
if mode in ("everyone", "first_come"):
continue
active = self._compute_active_children(chore, today)
if active:
result[chore.id] = active[0]
# Step 2: apply group policies.
for group in self.storage.get_task_groups():
if not group.chore_ids:
continue
if group.policy == "sticky":
self._apply_sticky_policy(group, chore_by_id, result)
elif group.policy == "spread":
self._apply_spread_policy(group, chore_by_id, result)
return result
def _apply_sticky_policy(
self, group, chore_by_id: dict[str, Chore], result: dict[str, str]
) -> None:
"""Force followers onto the leader chore's assignee (when in pool)."""
leader_id = group.chore_ids[0]
leader_child = result.get(leader_id)
if not leader_child:
return
for follower_id in group.chore_ids[1:]:
follower = chore_by_id.get(follower_id)
if not follower:
continue
if getattr(follower, "assignment_mode", "everyone") in ("everyone", "first_come"):
continue
pool = self._chore_assignment_pool(follower)
if leader_child in pool:
result[follower_id] = leader_child
else:
_LOGGER.debug(
"STICKY fallback: leader %s assigned to %s not in follower %s pool",
leader_id, leader_child, follower_id,
)
def _apply_spread_policy(
self, group, chore_by_id: dict[str, Chore], result: dict[str, str]
) -> None:
"""Assign group members to distinct children; wraps when pool < group size."""
used: set[str] = set()
for chore_id in group.chore_ids:
chore = chore_by_id.get(chore_id)
if not chore:
continue
if getattr(chore, "assignment_mode", "everyone") in ("everyone", "first_come"):
continue
pool = self._chore_assignment_pool(chore)
if not pool:
continue
# Wrap: once every child in this pool has been used, start over.
if len(used) >= len(pool) or all(p in used for p in pool):
used = set()
raw = result.get(chore_id) or pool[0]
if raw not in used:
result[chore_id] = raw
used.add(raw)
continue
# Walk pool from raw pick looking for an unused child.
try:
start_idx = pool.index(raw)
except ValueError:
start_idx = 0
picked = raw
for step in range(len(pool)):
cid = pool[(start_idx + step) % len(pool)]
if cid not in used:
picked = cid
break
result[chore_id] = picked
used.add(picked)
def _skip_unavailable(self, pool: list[str], start_idx: int, enabled: bool) -> str:
"""Walk forward from `start_idx` through `pool` looking for an available
child. If the skip is disabled, return the originally picked child.
If enabled and no child is available, return ``""`` so callers can hide
the chore entirely.
"""
original = pool[start_idx]
if not enabled:
return original
size = len(pool)
# Cache per-call so the same child isn't queried twice in a scan.
cache: dict[str, bool] = {}
def available(cid: str) -> bool:
if cid not in cache:
cache[cid] = self._is_child_available(cid)
return cache[cid]
for step in range(size):
cid = pool[(start_idx + step) % size]
if available(cid):
return cid
_LOGGER.debug(
"Availability skip: no available child in pool %s for chore, "
"hiding chore (all children unavailable)",
pool,
)
return ""
def _is_rotation_done_today(self, chore) -> bool:
"""Return True when a non-everyone-mode chore has been completed
enough times today (across the whole rotation pool) to fill its
daily_limit. Once that quota is met the chore is "done for the
rotation" and should disappear from every pool member's list — not
just the child who happened to complete it. Returns False for
everyone-mode chores since they don't share a single daily quota.
Exception: bonus sub-tasks render inside the parent chore card, so
hiding the chore would also hide any pending bonus sub-tasks. If the
active child still has uncompleted bonus sub-tasks for today, keep
the chore visible (return False) so they remain reachable.
"""
if getattr(chore, 'assignment_mode', 'everyone') == 'everyone':
return False
# PERF-1: result depends only on the chore; memoize per availability build.
cache = getattr(self, "_avail_cache", None)
if cache is not None and chore.id in cache["rotation_done"]:
return cache["rotation_done"][chore.id]
result = self._is_rotation_done_today_uncached(chore)
if cache is not None:
cache["rotation_done"][chore.id] = result
return result
def _is_rotation_done_today_uncached(self, chore) -> bool:
pool = set(self._chore_assignment_pool(chore))
if not pool:
return False
today = dt_util.as_local(dt_util.now()).date()
active_child_id = getattr(chore, 'assignment_current_child_id', '') or ''
completions_today = 0
completed_bonus_ids_today: set[str] = set()
for comp in self._cached_completions():
if comp.chore_id != chore.id:
continue
comp_dt = comp.completed_at
try:
if hasattr(comp_dt, 'astimezone'):
comp_dt = dt_util.as_local(comp_dt)
comp_date = comp_dt.date() if hasattr(comp_dt, 'date') else None
except (AttributeError, TypeError, ValueError):
continue
if comp_date != today:
continue
bonus_id = getattr(comp, 'bonus_subtask_id', None)
if bonus_id:
# Bonus completions don't count toward the parent's daily
# quota; track them only to decide whether the active child
# still has bonus work pending.
if active_child_id and comp.child_id == active_child_id:
completed_bonus_ids_today.add(bonus_id)
continue
# Parent completions count toward the quota. A `__parent__`
# sentinel covers the whole rotation for today.
if comp.child_id in pool or comp.child_id == "__parent__":
completions_today += 1
# first_come is a single-winner race: clamp any mis-configured quota to 1.
if getattr(chore, 'assignment_mode', 'everyone') == 'first_come':
daily_limit = 1
else:
daily_limit = getattr(chore, 'daily_limit', 1) or 1
if completions_today < daily_limit:
return False
bonus_subtasks = getattr(chore, 'bonus_subtasks', None) or []
if bonus_subtasks and active_child_id:
for bst in bonus_subtasks:
bst_id = getattr(bst, 'id', None)
if bst_id and bst_id not in completed_bonus_ids_today:
return False
return True
async def _async_refresh_assignments_and_publish(self) -> None:
"""Recompute today's active child per chore and publish to calendars.
Runs at midnight. All chores are processed concurrently so the runtime
is bounded by the slowest single publish, not the sum across chores.
Also clears stale skip state (skip_date != today) so yesterday's skip
doesn't bleed into the new day.
"""
today = dt_util.as_local(dt_util.now()).date()
today_iso = today.isoformat()
chores = self.storage.get_chores()
if not chores:
return
# Clear stale skip state in-memory (persisted via update_chore below).
for chore in chores:
if getattr(chore, "skip_date", "") and chore.skip_date != today_iso:
chore.skip_date = ""
chore.skip_count = 0
# Group-aware daily assignment map.
daily = self._compute_daily_assignments(today)
async def _process(chore: Chore) -> bool:
dirty = False
mode = getattr(chore, "assignment_mode", "everyone")
desired = daily.get(chore.id, "") if mode != "everyone" else ""
if getattr(chore, "assignment_current_child_id", "") != desired:
chore.assignment_current_child_id = desired
dirty = True
before = list(getattr(chore, "publish_calendar_published_dates", []) or [])
await self._publish_chore_to_calendars(chore, today)
if list(getattr(chore, "publish_calendar_published_dates", []) or []) != before:
dirty = True
# Always persist if skip state was cleared above.
if getattr(chore, "skip_date", "") == "" and getattr(chore, "skip_count", 0) == 0:
stored = self.storage.get_chore(chore.id)
if stored and (stored.skip_date or stored.skip_count):
dirty = True
if dirty:
self.storage.update_chore(chore)
return dirty
results = await asyncio.gather(*(_process(c) for c in chores), return_exceptions=True)
if any(r is True for r in results):
await self.storage.async_save()
await self.async_refresh()
+115
View File
@@ -0,0 +1,115 @@
"""Avatar unlockables mixin for TaskMateCoordinator.
Parents define a catalog of avatars, each gated behind a requirement (a level,
a lifetime-points total, or a best-streak length — or ``free`` for always
available). Children unlock avatars by hitting those milestones and can switch
to any avatar they've unlocked; parents can set any catalogue avatar.
"""
from __future__ import annotations
import logging
_LOGGER = logging.getLogger(__name__)
# Shipped defaults so the feature is useful out of the box. Parents can replace
# the whole list from the panel.
DEFAULT_AVATAR_CATALOG: list[dict] = [
{"id": "starter", "label": "Starter", "icon": "mdi:account-circle", "unlock_type": "free", "unlock_value": 0},
{"id": "rocket", "label": "Rocket", "icon": "mdi:rocket-launch", "unlock_type": "level", "unlock_value": 3},
{"id": "robot", "label": "Robot", "icon": "mdi:robot-happy", "unlock_type": "level", "unlock_value": 5},
{"id": "ninja", "label": "Ninja", "icon": "mdi:ninja", "unlock_type": "level", "unlock_value": 10},
{"id": "crown", "label": "Royalty", "icon": "mdi:crown", "unlock_type": "points", "unlock_value": 500},
{"id": "trophy", "label": "Champion", "icon": "mdi:trophy", "unlock_type": "points", "unlock_value": 1000},
{"id": "fire", "label": "On Fire", "icon": "mdi:fire", "unlock_type": "streak", "unlock_value": 7},
{"id": "diamond", "label": "Diamond", "icon": "mdi:diamond-stone", "unlock_type": "streak", "unlock_value": 30},
]
class AvatarsMixin:
"""Mixin providing the avatar catalogue and unlock/selection logic."""
def avatar_catalog(self) -> list[dict]:
"""The configured avatar catalogue (falls back to the shipped default)."""
raw = self.storage.get_setting("avatar_catalog", None)
if isinstance(raw, list) and raw:
return [a for a in raw if isinstance(a, dict) and a.get("icon")]
return list(DEFAULT_AVATAR_CATALOG)
def _avatar_unlocked(self, entry: dict, child) -> bool:
kind = entry.get("unlock_type", "free")
try:
value = int(entry.get("unlock_value", 0) or 0)
except (ValueError, TypeError):
value = 0
if kind == "free":
return True
if kind == "level":
return int(self.level_info(child)["level"]) >= value
if kind == "points":
return int(getattr(child, "total_points_earned", 0) or 0) >= value
if kind == "streak":
return int(getattr(child, "best_streak", 0) or 0) >= value
return False
def avatar_options_for_child(self, child) -> list[dict]:
"""Catalogue annotated with per-child unlock state (for sensors/cards)."""
out: list[dict] = []
for entry in self.avatar_catalog():
kind = entry.get("unlock_type", "free")
value = entry.get("unlock_value", 0)
if kind == "free":
req = ""
elif kind == "level":
req = f"Level {value}"
elif kind == "points":
req = f"{value} earned"
elif kind == "streak":
req = f"{value}-day streak"
else:
req = ""
out.append({
"id": entry.get("id", entry.get("icon")),
"label": entry.get("label", ""),
"icon": entry.get("icon"),
"unlocked": self._avatar_unlocked(entry, child),
"requirement": req,
})
return out
async def async_update_avatar_catalog(self, catalog: list[dict]) -> None:
"""Replace the avatar catalogue (admin)."""
cleaned: list[dict] = []
for a in catalog:
icon = (a.get("icon") or "").strip()
if not icon:
continue
cleaned.append({
"id": (a.get("id") or icon).strip(),
"label": (a.get("label") or "").strip(),
"icon": icon,
"unlock_type": a.get("unlock_type", "free"),
"unlock_value": int(a.get("unlock_value", 0) or 0),
})
self.storage.set_setting("avatar_catalog", cleaned)
await self.storage.async_save()
await self.async_refresh()
async def async_set_avatar(self, child_id: str, icon: str, enforce_unlock: bool = True) -> None:
"""Set a child's avatar.
When ``enforce_unlock`` (child self-service) the icon must be an
unlocked catalogue avatar; parents may set any catalogue avatar.
"""
child = self.get_child(child_id)
if not child:
raise ValueError(f"Child {child_id} not found")
catalog = self.avatar_catalog()
entry = next((a for a in catalog if a.get("icon") == icon or a.get("id") == icon), None)
if not entry:
raise ValueError("That avatar is not in the catalogue")
if enforce_unlock and not self._avatar_unlocked(entry, child):
raise ValueError("That avatar is not unlocked yet")
child.avatar = entry.get("icon")
self.storage.update_child(child)
await self.storage.async_save()
await self.async_refresh()
+323
View File
@@ -0,0 +1,323 @@
"""Badge evaluation engine and built-in catalogue."""
from __future__ import annotations
import logging
from .models import Badge, BadgeCriterion, Child
_LOGGER = logging.getLogger(__name__)
def _b(id_suffix: str, name: str, description: str, icon: str, tier: str,
point_bonus: int, metric: str, value: int) -> Badge:
"""Helper to build a built-in badge."""
criteria = [BadgeCriterion(metric=metric, operator=">=", value=value)] if metric else []
badge = Badge(
name=name,
description=description,
icon=icon,
tier=tier,
point_bonus=point_bonus,
criteria=criteria,
builtin=True,
enabled=True,
notify_on_earn=True,
)
badge.id = f"builtin.{id_suffix}"
return badge
BUILTIN_CATALOGUE: list[Badge] = [
# Bronze
_b("first_chore", "First Chore", "Complete your very first chore",
"mdi:check-circle", "bronze", 0, "first_chore", 1),
_b("first_reward", "First Reward", "Claim your first reward",
"mdi:gift", "bronze", 0, "first_reward", 1),
_b("100_points", "100 Points", "Earn 100 lifetime points",
"mdi:star", "bronze", 0, "total_points", 100),
_b("10_chores", "10 Chores Completed", "Complete 10 chores",
"mdi:checkbox-marked-circle", "bronze", 0, "total_chores", 10),
# Silver
_b("500_points", "500 Points", "Earn 500 lifetime points",
"mdi:star-circle", "silver", 25, "total_points", 500),
_b("50_chores", "50 Chores Completed", "Complete 50 chores",
"mdi:checkbox-multiple-marked-circle", "silver", 25, "total_chores", 50),
_b("3_day_streak", "3-Day Streak", "Complete chores 3 days in a row",
"mdi:fire", "silver", 25, "current_streak", 3),
_b("first_perfect_week", "First Perfect Week", "Complete a perfect week",
"mdi:calendar-star", "silver", 50, "perfect_weeks", 1),
# Gold
_b("1000_points", "1000 Points", "Earn 1000 lifetime points",
"mdi:trophy", "gold", 100, "total_points", 1000),
_b("100_chores", "100 Chores Completed", "Complete 100 chores",
"mdi:trophy-variant", "gold", 100, "total_chores", 100),
_b("7_day_streak", "7-Day Streak", "Complete chores 7 days in a row",
"mdi:lightning-bolt", "gold", 50, "current_streak", 7),
_b("5_perfect_weeks", "5 Perfect Weeks", "Achieve 5 perfect weeks",
"mdi:calendar-multiple-check", "gold", 100, "perfect_weeks", 5),
# Platinum
_b("5000_points", "5000 Points", "Earn 5000 lifetime points",
"mdi:diamond-stone", "platinum", 250, "total_points", 5000),
_b("30_day_streak", "30-Day Streak", "Complete chores 30 days in a row",
"mdi:crown", "platinum", 250, "current_streak", 30),
_b("10_perfect_weeks", "10 Perfect Weeks", "Achieve 10 perfect weeks",
"mdi:rainbow", "platinum", 250, "perfect_weeks", 10),
]
def resolve_metric(metric: str, child: Child, storage) -> int:
"""Resolve a metric value for a child. Returns int (0 for unknown metrics)."""
if metric == "total_points":
return int(child.total_points_earned or 0)
if metric == "total_chores":
return int(child.total_chores_completed or 0)
if metric == "current_streak":
return int(child.current_streak or 0)
if metric == "best_streak":
return int(child.best_streak or 0)
if metric == "perfect_weeks":
return len(child.awarded_perfect_weeks or [])
if metric == "first_chore":
return 1 if (child.total_chores_completed or 0) >= 1 else 0
if metric in ("total_rewards", "first_reward"):
approved_count = sum(
1 for c in storage.get_reward_claims()
if c.child_id == child.id and c.approved
)
if metric == "first_reward":
return 1 if approved_count >= 1 else 0
return approved_count
return 0
TRIGGER_METRICS: dict[str, set[str]] = {
"chore_completed": {"total_chores", "first_chore"},
"points_changed": {"total_points"},
"reward_redeemed": {"total_rewards", "first_reward"},
"streak_updated": {"current_streak", "best_streak"},
"perfect_week": {"perfect_weeks"},
}
def criterion_met(value: int, operator: str, threshold: int) -> bool:
"""Evaluate a single badge criterion. Unknown operators default to >=."""
op = operator or ">="
if op == ">=":
return value >= threshold
if op == "==":
return value == threshold
if op == "<=":
return value <= threshold
if op == ">":
return value > threshold
if op == "<":
return value < threshold
if op == "!=":
return value != threshold
return value >= threshold
def badge_relevant_to_trigger(badge: Badge, trigger: str) -> bool:
"""Return True if any of the badge's criteria reference a metric in the trigger's set.
'manual' trigger always returns True (re-evaluates everything).
Badges with no criteria are manual-award-only and never auto-fire.
"""
if trigger == "manual":
return True
if not badge.criteria:
return False
relevant = TRIGGER_METRICS.get(trigger, set())
return any(c.metric in relevant for c in badge.criteria)
class BadgeCoordinator:
"""Evaluates badge criteria and emits awards."""
def __init__(self, hass, storage, points_coord, notifications=None) -> None:
self.hass = hass
self.storage = storage
self.points_coord = points_coord
self.notifications = notifications
async def evaluate_for_child(
self,
child_id: str,
trigger: str,
*,
silent: bool = False,
) -> list:
"""Evaluate all enabled badges for this child given a trigger event.
Returns the list of newly-created AwardedBadge instances.
Set silent=True for retroactive backfill (suppresses notifications,
events, and point bonuses).
"""
from .models import AwardedBadge
child = self.storage.get_child(child_id)
if child is None:
return []
new_awards: list[AwardedBadge] = []
for badge in self.storage.get_badges():
if not badge.enabled:
continue
if badge.assigned_to and child_id not in badge.assigned_to:
continue
if not badge_relevant_to_trigger(badge, trigger):
continue
if self.storage.has_awarded(child_id, badge.id):
continue
if not badge.criteria:
continue
results = [
criterion_met(resolve_metric(c.metric, child, self.storage), c.operator, c.value)
for c in badge.criteria
]
combinator = (getattr(badge, "combinator", "AND") or "AND").upper()
passed = any(results) if combinator == "OR" else all(results)
if not passed:
continue
bonus = 0 if silent else badge.point_bonus
award = AwardedBadge(
child_id=child_id,
badge_id=badge.id,
silent=silent,
bonus_credited=bonus,
)
self.storage.add_awarded_badge(award)
new_awards.append(award)
if not silent:
if bonus > 0:
await self.points_coord.async_add_points(
child_id,
bonus,
reason=f"Badge: {badge.name}",
)
self.hass.bus.async_fire(
"taskmate_badge_earned",
{
"child_id": child_id,
"badge_id": badge.id,
"awarded_id": award.id,
"name": badge.name,
"icon": badge.icon,
"tier": badge.tier,
"point_bonus": bonus,
},
)
if new_awards:
await self.storage.async_save()
if not silent:
await self._dispatch_notifications(new_awards)
return new_awards
async def award_manually(self, child_id: str, badge_id: str):
"""Manually award a badge to a child via parent action."""
from .models import AwardedBadge
child = self.storage.get_child(child_id)
badge = self.storage.get_badge(badge_id)
if child is None or badge is None:
return None
if self.storage.has_awarded(child_id, badge_id):
return None
bonus = badge.point_bonus
award = AwardedBadge(
child_id=child_id,
badge_id=badge_id,
manually_awarded=True,
silent=False,
bonus_credited=bonus,
)
self.storage.add_awarded_badge(award)
if bonus > 0:
await self.points_coord.async_add_points(
child_id, bonus, reason=f"Badge: {badge.name}",
)
self.hass.bus.async_fire(
"taskmate_badge_earned",
{
"child_id": child_id,
"badge_id": badge_id,
"awarded_id": award.id,
"name": badge.name,
"icon": badge.icon,
"tier": badge.tier,
"point_bonus": bonus,
},
)
await self._dispatch_notifications([award])
await self.storage.async_save()
return award
async def revoke(self, awarded_id: str) -> bool:
"""Revoke an awarded badge; reverse bonus_credited if > 0."""
matching = [
a for a in self.storage.get_awarded_badges() if a.id == awarded_id
]
if not matching:
return False
award = matching[0]
badge = self.storage.get_badge(award.badge_id)
badge_name = badge.name if badge else "(removed)"
self.storage.remove_awarded_badge(awarded_id)
if award.bonus_credited > 0:
await self.points_coord.async_remove_points(
award.child_id,
award.bonus_credited,
reason=f"Badge revoked: {badge_name}",
)
await self.storage.async_save()
return True
async def rebuild_all(self) -> int:
"""Re-evaluate all enabled badges across all children, silently.
Used by rebuild_badges service and one-time backfill.
Returns total count of new silent awards.
"""
total = 0
for child in self.storage.get_children():
new_awards = await self.evaluate_for_child(
child.id, "manual", silent=True,
)
total += len(new_awards)
return total
async def _dispatch_notifications(self, awards: list) -> None:
"""Combine awards into one or per-badge ping(s) via the central dispatcher.
Per-badge `notify_on_earn` still applies — badges with that flag off are
excluded from the message but their event is still emitted by the badge
coordinator's regular bus.fire.
"""
per_child: dict[str, list] = {}
for award in awards:
badge = self.storage.get_badge(award.badge_id)
if not badge or not badge.notify_on_earn:
continue
per_child.setdefault(award.child_id, []).append(badge)
for child_id, badges in per_child.items():
child = self.storage.get_child(child_id)
if child is None or self.notifications is None:
continue
badge_name = badges[0].name if len(badges) == 1 else ", ".join(b.name for b in badges)
await self.notifications.fire(
"badge_earned",
{
"child_name": child.name,
"badge_name": badge_name,
"badge_id": ",".join(b.id for b in badges),
"tier": badges[0].tier,
},
)
@@ -0,0 +1,385 @@
"""Calendar operations mixin for TaskMateCoordinator."""
from __future__ import annotations
import asyncio
import logging
from calendar import monthrange
from datetime import date, datetime, time, timedelta
from typing import TYPE_CHECKING
from homeassistant.util import dt as dt_util
from .const import (
DEFAULT_CALENDAR_PROJECTION_DAYS,
DEFAULT_TIME_PERIODS,
MAX_CALENDAR_PROJECTION_DAYS,
MIN_CALENDAR_PROJECTION_DAYS,
TIME_CATEGORY_ICONS,
)
from .models import Chore
if TYPE_CHECKING:
pass
_LOGGER = logging.getLogger(__name__)
class CalendarMixin:
"""Mixin providing calendar projection and event publishing logic."""
_SCHEDULE_DOW = ("monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday")
@staticmethod
def _parse_hhmm(value: str) -> time | None:
"""Parse an HH:MM string, returning None when invalid."""
try:
h, m = (int(x) for x in str(value).split(":"))
return time(h, m)
except (ValueError, TypeError):
return None
def get_time_periods(self) -> list[dict]:
"""Resolve the user-defined time-of-day periods, ordered by start.
Fallback chain: `time_periods` setting → legacy `time_<cat>_start/end`
keys → DEFAULT_TIME_PERIODS. "anytime" is implicit and never listed.
"""
raw = self.storage.get_setting("time_periods", None)
if isinstance(raw, list) and raw:
periods = []
for entry in raw:
if not isinstance(entry, dict):
continue
start = self._parse_hhmm(entry.get("start"))
end = self._parse_hhmm(entry.get("end"))
pid = str(entry.get("id") or "").strip()
if not pid or pid == "anytime" or start is None or end is None:
continue
periods.append({
"id": pid,
"label": str(entry.get("label") or "").strip(),
"start": start.strftime("%H:%M"),
"end": end.strftime("%H:%M"),
"icon": str(entry.get("icon") or "") or TIME_CATEGORY_ICONS.get(pid, "mdi:clock-outline"),
})
if periods:
return sorted(periods, key=lambda p: p["start"])
# Legacy flat keys (pre-time_periods installs); these already default
# to the built-in boundaries, so this also covers fresh installs.
periods = []
for default in DEFAULT_TIME_PERIODS:
pid = default["id"]
start_str = self.storage.get_setting(f"time_{pid}_start", default["start"])
end_str = self.storage.get_setting(f"time_{pid}_end", default["end"])
start = self._parse_hhmm(start_str) or self._parse_hhmm(default["start"])
end = self._parse_hhmm(end_str) or self._parse_hhmm(default["end"])
periods.append({
"id": pid,
"label": "",
"start": start.strftime("%H:%M"),
"end": end.strftime("%H:%M"),
"icon": default["icon"],
})
return sorted(periods, key=lambda p: p["start"])
def _get_time_boundaries(self) -> dict[str, tuple[time, time] | None]:
"""Build time boundaries from the resolved periods."""
result: dict[str, tuple[time, time] | None] = {"anytime": None}
for period in self.get_time_periods():
result[period["id"]] = (
self._parse_hhmm(period["start"]),
self._parse_hhmm(period["end"]),
)
return result
def _time_category_window(self, category: str, today: date) -> tuple[datetime, datetime] | None:
"""Return (start, end) datetimes for a time_category, or None for all-day."""
boundaries = self._get_time_boundaries()
window = boundaries.get(category or "anytime")
if window is None:
return None
start_t, end_t = window
return datetime.combine(today, start_t), datetime.combine(today, end_t)
def _chore_event_marker(self, chore: Chore) -> str:
"""Marker stitched into the event description so we can find our own events."""
return f"taskmate:chore:{chore.id}"
def _calendar_projection_days(self) -> int:
"""Return the configured projection horizon, clamped to the allowed range."""
try:
raw = int(float(self.storage.get_setting(
"calendar_projection_days", str(DEFAULT_CALENDAR_PROJECTION_DAYS)
)))
except (TypeError, ValueError):
raw = DEFAULT_CALENDAR_PROJECTION_DAYS
return max(MIN_CALENDAR_PROJECTION_DAYS, min(MAX_CALENDAR_PROJECTION_DAYS, raw))
def _is_chore_scheduled_for_date(self, chore: Chore, day: date) -> bool:
"""Return True if the chore's schedule places it on `day`.
Mirrors the client-side `_isChoreScheduledOn` in taskmate-calendar-card.js
so the HA calendar projection matches what the card shows. Does not
consult completion state — this is purely the recurrence/schedule math.
"""
if not getattr(chore, "enabled", True):
return False
schedule_mode = getattr(chore, "schedule_mode", "specific_days")
created_iso = getattr(chore, "created_date", "") or ""
if schedule_mode == "one_shot":
return bool(created_iso) and created_iso == day.isoformat()
if created_iso:
try:
if day < date.fromisoformat(created_iso):
return False
except ValueError:
pass
if schedule_mode == "specific_days":
due_days = list(getattr(chore, "due_days", []) or [])
if not due_days:
return True
return self._SCHEDULE_DOW[day.weekday()] in due_days
if schedule_mode != "recurring":
return False
recurrence = getattr(chore, "recurrence", "weekly")
recurrence_day = (getattr(chore, "recurrence_day", "") or "").lower()
recurrence_start = getattr(chore, "recurrence_start", "") or ""
# For interval recurrences, fall back to created_date as the cadence
# anchor when no explicit recurrence_start is set — otherwise these
# chores never project onto the calendar at all (ERR-2).
anchor_iso = recurrence_start or created_iso
day_dow = self._SCHEDULE_DOW[day.weekday()]
if recurrence_day and recurrence in ("weekly", "every_2_weeks"):
if recurrence_day != day_dow:
return False
if recurrence == "every_2_weeks" and recurrence_start:
try:
anchor = date.fromisoformat(recurrence_start)
diff = (day - anchor).days
if diff < 0 or (diff // 7) % 2 != 0:
return False
except ValueError:
pass
return True
if recurrence == "every_2_days" and anchor_iso:
try:
anchor = date.fromisoformat(anchor_iso)
diff = (day - anchor).days
return diff >= 0 and diff % 2 == 0
except ValueError:
return False
month_steps = {"monthly": 1, "every_3_months": 3, "every_6_months": 6}.get(recurrence)
if month_steps and anchor_iso:
try:
anchor = date.fromisoformat(anchor_iso)
if day < anchor:
return False
months_diff = (day.year - anchor.year) * 12 + (day.month - anchor.month)
if months_diff % month_steps != 0:
return False
# Clamp the anchor day for shorter months (e.g. 31st → Feb 28th)
target_day = min(anchor.day, monthrange(day.year, day.month)[1])
return day.day == target_day
except ValueError:
return False
# Weekly/every_2_weeks without an explicit day: same weekday as today,
# matching the card's fallback for loosely-scheduled recurrences.
if recurrence in ("weekly", "every_2_weeks"):
today = dt_util.as_local(dt_util.now()).date()
return day.weekday() == today.weekday()
return False
def _build_event_payload(self, chore: Chore, day: date, summary: str) -> dict:
"""Build the calendar.create_event payload for one (chore, day)."""
description = self._chore_event_marker(chore)
window = self._time_category_window(
getattr(chore, "time_category", "anytime"), day
)
if window is None:
return {
"summary": summary,
"description": description,
"start_date": day.isoformat(),
"end_date": (day + timedelta(days=1)).isoformat(),
}
start_dt, end_dt = window
return {
"summary": summary,
"description": description,
"start_date_time": start_dt.isoformat(),
"end_date_time": end_dt.isoformat(),
}
def _child_name_for_day(self, chore: Chore, day: date) -> str:
"""Return the display name for the chore's assignee on `day`."""
active = self._compute_active_children(chore, day)
if not active:
return "Everyone"
child = self.storage.get_child(active[0])
return child.name if child else "Everyone"
async def _publish_chore_to_calendars(self, chore: Chore, today: date | None = None) -> None:
"""Publish assignments for `chore` across the configured projection horizon.
For every date in [today, today + horizon) that the schedule covers and
hasn't already been published, computes that date's active child and
writes an event to each configured calendar. Past entries are pruned
from `publish_calendar_published_dates` so the list doesn't grow
unbounded. Fan-out is via asyncio.gather so N entities x M missing
days scale with the slowest single service call.
"""
entities = list(getattr(chore, "publish_calendar_entities", []) or [])
if not entities:
return
if today is None:
today = dt_util.as_local(dt_util.now()).date()
horizon = self._calendar_projection_days()
published = set(getattr(chore, "publish_calendar_published_dates", []) or [])
# Prune stale entries (strictly before today) so the list stays small.
published = {iso for iso in published if iso >= today.isoformat()}
pending: list[tuple[date, dict]] = []
for offset in range(horizon):
day = today + timedelta(days=offset)
day_iso = day.isoformat()
if day_iso in published:
continue
if not self._is_chore_scheduled_for_date(chore, day):
continue
summary = f"{chore.name}{self._child_name_for_day(chore, day)}"
pending.append((day, self._build_event_payload(chore, day, summary)))
if not pending and not published.symmetric_difference(
getattr(chore, "publish_calendar_published_dates", []) or []
):
# Nothing to publish and nothing pruned — leave the chore untouched
# so the caller doesn't write the record out for no reason.
return
async def _call(entity_id: str, payload: dict) -> None:
try:
await self.hass.services.async_call(
"calendar",
"create_event",
{"entity_id": entity_id, **payload},
blocking=True,
)
except Exception as err: # noqa: BLE001 - HA service errors vary
_LOGGER.warning(
"TaskMate: failed to publish chore %s to calendar %s: %s",
chore.name,
entity_id,
err,
)
tasks = [_call(e, payload) for day, payload in pending for e in entities]
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
for day, _ in pending:
published.add(day.isoformat())
chore.publish_calendar_published_dates = sorted(published)
async def _cleanup_chore_from_calendars(
self,
chore: Chore,
entities: list[str] | None = None,
today: date | None = None,
summary_prefixes: list[str] | None = None,
) -> None:
"""Best-effort delete of the chore's own events from each configured calendar.
Uses a description marker (`taskmate:chore:<id>`) stitched in at create
time to re-identify events. Covers today through today+horizon+7 so the
full projection range is cleaned up on edit/delete. Failure modes
(unavailable calendar, integration without delete_event support,
response service disabled) are caught and logged; cleanup never blocks
the caller.
"""
ents = list(entities if entities is not None else getattr(chore, "publish_calendar_entities", []) or [])
if not ents:
return
if today is None:
today = dt_util.as_local(dt_util.now()).date()
window_days = max(30, self._calendar_projection_days() + 7)
window_start = datetime.combine(today, time(0, 0)).isoformat()
window_end = datetime.combine(today + timedelta(days=window_days), time(0, 0)).isoformat()
marker = self._chore_event_marker(chore)
# Fallback summary prefixes: covers integrations whose get_events
# response omits or strips the description field. Default to the
# current chore's name so deletes still work for unedited chores.
prefixes = list(summary_prefixes or [f"{chore.name}"])
def _matches(event: dict) -> bool:
if marker in (event.get("description") or ""):
return True
summary = event.get("summary") or ""
return any(summary.startswith(p) for p in prefixes if p)
async def _purge(entity_id: str) -> None:
try:
response = await self.hass.services.async_call(
"calendar",
"get_events",
{
"entity_id": entity_id,
"start_date_time": window_start,
"end_date_time": window_end,
},
blocking=True,
return_response=True,
)
except Exception as err: # noqa: BLE001
_LOGGER.debug(
"TaskMate: could not list events on %s for cleanup: %s",
entity_id,
err,
)
return
events = []
if isinstance(response, dict):
bucket = response.get(entity_id, response)
if isinstance(bucket, dict):
events = bucket.get("events", []) or []
elif isinstance(bucket, list):
events = bucket
for event in events:
if not _matches(event):
continue
uid = event.get("uid") or event.get("recurrence_id") or event.get("id")
if not uid:
continue
try:
await self.hass.services.async_call(
"calendar",
"delete_event",
{"entity_id": entity_id, "uid": uid},
blocking=True,
)
except Exception as err: # noqa: BLE001
_LOGGER.warning(
"TaskMate: failed to delete event %s from %s: %s",
uid,
entity_id,
err,
)
await asyncio.gather(*(_purge(e) for e in ents), return_exceptions=True)
chore.publish_calendar_published_dates = []
@@ -0,0 +1,165 @@
"""Daily / weekly challenges mixin for TaskMateCoordinator.
A challenge sets a per-period target — complete N chores today, or earn N
points this week — and awards a one-off bonus when a child hits it. Progress
and the award reset automatically when the period rolls over (a new day or a
new Monday-anchored week).
"""
from __future__ import annotations
import logging
from datetime import timedelta
from homeassistant.util import dt as dt_util
from .models import Challenge, PointsTransaction
_LOGGER = logging.getLogger(__name__)
class ChallengesMixin:
"""Mixin providing challenge CRUD and per-period evaluation."""
# ── CRUD ─────────────────────────────────────────────────────────────
async def async_create_challenge(self, **fields) -> str:
challenge = Challenge.from_dict(fields)
if not challenge.name.strip():
raise ValueError("Challenge name is required")
if challenge.target <= 0:
raise ValueError("Target must be at least 1")
self.storage.add_challenge(challenge)
await self.storage.async_save()
await self.async_refresh()
return challenge.id
async def async_update_challenge(self, challenge_id: str, **fields) -> None:
existing = self.storage.get_challenge(challenge_id)
if not existing:
raise ValueError(f"Challenge {challenge_id} not found")
data = existing.to_dict()
data.update(fields)
data["id"] = challenge_id
updated = Challenge.from_dict(data)
if updated.target <= 0:
raise ValueError("Target must be at least 1")
self.storage.update_challenge(updated)
await self.storage.async_save()
await self.async_refresh()
async def async_delete_challenge(self, challenge_id: str) -> None:
if not self.storage.get_challenge(challenge_id):
raise ValueError(f"Challenge {challenge_id} not found")
self.storage.remove_challenge(challenge_id)
await self.storage.async_save()
await self.async_refresh()
# ── Period helpers ───────────────────────────────────────────────────
def _challenge_applies_to(self, challenge: Challenge, child_id: str) -> bool:
return not challenge.assigned_to or child_id in challenge.assigned_to
def _period_start_key(self, scope: str):
"""Return (period_start_date, period_key) for the current period."""
today = dt_util.now().date()
if scope == "weekly":
start = today - timedelta(days=today.weekday())
return start, f"w:{start.isoformat()}"
return today, f"d:{today.isoformat()}"
def _metric_value(self, child_id: str, scope: str, metric: str) -> int:
"""Child's current metric value within the active period."""
start, _ = self._period_start_key(scope)
count = 0
points = 0
for comp in self.storage.get_completions():
if comp.child_id != child_id or not comp.approved or comp.bonus_subtask_id:
continue
if dt_util.as_local(comp.completed_at).date() < start:
continue
count += 1
points += comp.points_awarded or 0
return points if metric == "points" else count
def challenge_progress_for_child(self, child_id: str) -> list[dict]:
"""Snapshot of every active challenge assigned to ``child_id``."""
out: list[dict] = []
for ch in self.storage.get_challenges():
if not ch.active or not self._challenge_applies_to(ch, child_id):
continue
value = self._metric_value(child_id, ch.scope, ch.metric)
_, period_key = self._period_start_key(ch.scope)
prog = self.storage.get_challenge_child_progress(ch.id, child_id)
awarded = bool(prog.get("awarded")) and prog.get("period") == period_key
out.append({
"challenge_id": ch.id,
"name": ch.name,
"icon": ch.icon,
"scope": ch.scope,
"metric": ch.metric,
"target": ch.target,
"progress": min(value, ch.target),
"value": value,
"bonus_points": ch.bonus_points,
"complete": value >= ch.target,
"awarded": awarded,
})
return out
# ── Evaluation ───────────────────────────────────────────────────────
async def _async_evaluate_challenges(self, child_id: str) -> None:
"""Award any challenge the child has now met for the current period."""
child = self.get_child(child_id)
if not child:
return
changed = False
for ch in self.storage.get_challenges():
if not ch.active or ch.target <= 0:
continue
if not self._challenge_applies_to(ch, child_id):
continue
_, period_key = self._period_start_key(ch.scope)
prog = dict(self.storage.get_challenge_child_progress(ch.id, child_id))
# New period → clear any stale award flag.
if prog.get("period") != period_key:
prog = {"period": period_key, "awarded": False}
if prog.get("awarded"):
continue
value = self._metric_value(child_id, ch.scope, ch.metric)
if value < ch.target:
self.storage.set_challenge_child_progress(ch.id, child_id, prog)
continue
prog["awarded"] = True
self.storage.set_challenge_child_progress(ch.id, child_id, prog)
await self._award_challenge(ch, child)
changed = True
if changed:
await self.storage.async_save()
await self.async_refresh()
async def _award_challenge(self, challenge: Challenge, child) -> None:
bonus = int(challenge.bonus_points or 0)
if bonus > 0:
child.points += bonus
child.total_points_earned += bonus
child.career_score = child.total_points_earned - child.total_penalties_received
self.storage.add_points_transaction(PointsTransaction(
child_id=child.id, points=bonus,
reason=f"Challenge complete: {challenge.name}", created_at=dt_util.now(),
))
if hasattr(self, "_maybe_level_up"):
await self._maybe_level_up(child)
self.storage.update_child(child)
self.hass.bus.async_fire("taskmate_challenge_completed", {
"child_id": child.id, "child_name": child.name,
"challenge_id": challenge.id, "challenge_name": challenge.name,
"scope": challenge.scope, "bonus": bonus,
"timestamp": dt_util.now().isoformat(),
})
if hasattr(self, "_celebrate"):
await self._celebrate(
child, "challenge_completed",
f"{child.name} completed the challenge '{challenge.name}'!",
tier=2, extra={"challenge_id": challenge.id, "bonus": bonus},
)
_LOGGER.info("Challenge '%s' completed by %s (+%d)", challenge.name, child.name, bonus)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,344 @@
"""Mandatory-chore detection, scheduling, and resolution (#532)."""
from __future__ import annotations
import logging
from datetime import date, datetime, timedelta
from datetime import time as dt_time
from homeassistant.core import callback
from homeassistant.helpers.event import (
async_track_time_change,
async_track_time_interval,
)
from homeassistant.util import dt as dt_util
from .const import (
NOTIF_TYPE_MANDATORY_PARENT_ALERT,
NOTIF_TYPE_MANDATORY_REMINDER,
)
from .models import MandatoryMiss, parse_datetime
# How often the escalation ladder is re-evaluated (FEAT-6).
_ESCALATION_INTERVAL = timedelta(minutes=5)
_LOGGER = logging.getLogger(__name__)
# Assignment modes where every assigned child owes the chore. Rotation modes
# point to a single current child via assignment_current_child_id.
_SHARED_MODES = {"everyone", "first_come", "unassigned"}
class MandatoryMixin:
"""Detection + scheduling + resolution for mandatory chores."""
def _ensure_mandatory_state(self) -> None:
if not hasattr(self, "mandatory_postpone"):
self.mandatory_postpone = {}
def _effective_period_for(self, chore, child_id: str, day: date) -> str:
"""The chore's period for this child/day, honoring any postpone override."""
self._ensure_mandatory_state()
key = f"{chore.id}:{child_id}:{day.isoformat()}"
return self.mandatory_postpone.get(key) or (chore.time_category or "anytime")
def _mandatory_owers(self, chore) -> list[str]:
"""Child IDs responsible for this chore right now."""
mode = getattr(chore, "assignment_mode", "everyone")
assigned = list(getattr(chore, "assigned_to", []) or [])
if mode in _SHARED_MODES:
if assigned:
return assigned
return [c.id for c in self.storage.get_children()]
cur = getattr(chore, "assignment_current_child_id", "") or ""
return [cur] if cur else []
def _child_completed_today(self, chore_id: str, child_id: str, day: date) -> bool:
for comp in self.storage.get_completions():
if comp.chore_id != chore_id or comp.child_id != child_id:
continue
if comp.bonus_subtask_id:
continue
if dt_util.as_local(comp.completed_at).date() == day:
return True
return False
async def async_detect_mandatory_misses(self, period_id: str, day: date) -> int:
"""Create misses for due+incomplete mandatory chores in `period_id`."""
existing = {
(m.chore_id, m.child_id, m.due_date)
for m in self.storage.get_mandatory_misses()
}
created = 0
for chore in self.storage.get_chores():
if not getattr(chore, "mandatory", False):
continue
if not getattr(chore, "enabled", True):
continue
if not self._is_chore_scheduled_for_date(chore, day):
continue
for child_id in self._mandatory_owers(chore):
if self._effective_period_for(chore, child_id, day) != period_id:
continue
if child_id in (getattr(chore, "disabled_for", []) or []):
continue
if self._child_completed_today(chore.id, child_id, day):
continue
if (chore.id, child_id, day.isoformat()) in existing:
continue
miss = MandatoryMiss(
chore_id=chore.id,
child_id=child_id,
due_date=day.isoformat(),
period_id=period_id,
penalty_points=int(getattr(chore, "mandatory_penalty_points", 0) or 0),
)
self.storage.add_mandatory_miss(miss)
created += 1
self.hass.bus.async_fire("taskmate_mandatory_missed", {
"miss_id": miss.id, "chore_id": chore.id, "child_id": child_id,
"period_id": period_id, "penalty_points": miss.penalty_points,
"timestamp": dt_util.now().isoformat(),
})
if created:
await self.storage.async_save()
await self.async_refresh()
return created
# ---- resolution actions ------------------------------------------------
def _get_miss(self, miss_id: str) -> MandatoryMiss | None:
for m in self.storage.get_mandatory_misses():
if m.id == miss_id:
return m
return None
def _next_period_after(self, period_id: str, now: datetime) -> str | None:
"""Next period whose end is still in the future today, by start order."""
cur_now = now.time()
for p in self.get_time_periods():
try:
eh, em = [int(x) for x in p["end"].split(":")]
except (ValueError, KeyError):
continue
if dt_time(eh, em) > cur_now and p["id"] != period_id:
return p["id"]
return None
async def async_apply_mandatory_penalty(self, miss_id: str) -> None:
miss = self._get_miss(miss_id)
if miss is None:
return
chore = next((c for c in self.storage.get_chores() if c.id == miss.chore_id), None)
name = getattr(chore, "name", "chore")
if miss.penalty_points > 0:
await self.async_remove_points(
miss.child_id, miss.penalty_points,
reason=f"Penalty: {name} (missed mandatory)",
)
self.storage.remove_mandatory_miss(miss_id)
await self.storage.async_save()
self.hass.bus.async_fire("taskmate_mandatory_penalty_applied", {
"miss_id": miss_id, "chore_id": miss.chore_id, "child_id": miss.child_id,
"points": miss.penalty_points, "timestamp": dt_util.now().isoformat(),
})
await self.async_refresh()
async def async_postpone_mandatory_chore(self, miss_id: str) -> None:
miss = self._get_miss(miss_id)
if miss is None:
return
self._ensure_mandatory_state()
now = dt_util.now()
nxt = self._next_period_after(miss.period_id, now)
if nxt:
key = f"{miss.chore_id}:{miss.child_id}:{miss.due_date}"
self.mandatory_postpone[key] = nxt
# else: no window left today -> let normal scheduling resurface tomorrow
self.storage.remove_mandatory_miss(miss_id)
await self.storage.async_save()
self.hass.bus.async_fire("taskmate_mandatory_postponed", {
"miss_id": miss_id, "chore_id": miss.chore_id, "child_id": miss.child_id,
"next_period": nxt or "", "timestamp": dt_util.now().isoformat(),
})
await self.async_refresh()
async def async_dismiss_mandatory_chore(self, miss_id: str) -> None:
miss = self._get_miss(miss_id)
if miss is None:
return
self.storage.remove_mandatory_miss(miss_id)
await self.storage.async_save()
self.hass.bus.async_fire("taskmate_mandatory_dismissed", {
"miss_id": miss_id, "chore_id": miss.chore_id, "child_id": miss.child_id,
"timestamp": dt_util.now().isoformat(),
})
await self.async_refresh()
# ---- escalation (FEAT-6) ----------------------------------------------
async def async_escalate_mandatory_misses(self, now: datetime | None = None) -> int:
"""Walk today's open mandatory misses and advance each escalation stage.
Ladder driven by minutes elapsed since the miss was created:
stage 1 (nudge) immediately -> mandatory_reminder (child)
stage 2 (reminder) after reminder_minutes -> mandatory_reminder
stage 3 (parent alert) after parent_minutes -> mandatory_parent_alert
A miss whose chore has since been completed by that child is skipped (it
will be resolved by the parent / next-day prune). Each notification is
still gated by its own master switch + routes inside ``fire()``; stages
advance regardless so a disabled type is not retro-fired when re-enabled.
Returns the number of misses whose stage advanced.
"""
now = now or dt_util.now()
today_iso = now.date().isoformat()
reminder_minutes = self.storage.get_escalation_reminder_minutes()
parent_minutes = self.storage.get_escalation_parent_minutes()
chores = {c.id: c for c in self.storage.get_chores()}
children = {c.id: c for c in self.storage.get_children()}
advanced = 0
for miss in self.storage.get_mandatory_misses():
if miss.due_date != today_iso:
continue # only escalate same-day misses (skips midnight backfill)
if self._child_completed_today(miss.chore_id, miss.child_id, now.date()):
continue
created = parse_datetime(miss.created_at)
if created is None:
continue
elapsed_min = (now - created).total_seconds() / 60.0
target = 1
if elapsed_min >= reminder_minutes:
target = 2
if elapsed_min >= parent_minutes:
target = 3
if target <= miss.escalation_stage:
continue
chore = chores.get(miss.chore_id)
child = children.get(miss.child_id)
ctx = {
"child_id": miss.child_id,
"child_name": getattr(child, "name", ""),
"chore_name": getattr(chore, "name", "chore"),
}
for stage in range(miss.escalation_stage + 1, target + 1):
if stage in (1, 2):
await self.notifications.fire(
NOTIF_TYPE_MANDATORY_REMINDER, ctx,
only_recipients={f"child:{miss.child_id}"},
)
elif stage == 3:
await self.notifications.fire(
NOTIF_TYPE_MANDATORY_PARENT_ALERT, ctx,
)
miss.escalation_stage = target
self.storage.update_mandatory_miss(miss)
advanced += 1
if advanced:
await self.storage.async_save()
return advanced
# ---- scheduling --------------------------------------------------------
def _mandatory_period_end_times(self) -> list[tuple[int, int, str]]:
"""Distinct (hour, minute, period_id) from each period's end time."""
out: list[tuple[int, int, str]] = []
for p in self.get_time_periods():
try:
h, m = [int(x) for x in p["end"].split(":")]
except (ValueError, KeyError):
continue
out.append((h, m, p["id"]))
return out
def arm_mandatory_schedules(self) -> None:
"""Register a callback at each period's end time + the escalation tick."""
self._ensure_mandatory_state()
self._unsub_mandatory = getattr(self, "_unsub_mandatory", [])
for hour, minute, period_id in self._mandatory_period_end_times():
unsub = async_track_time_change(
self.hass,
self._make_mandatory_period_cb(period_id),
hour=hour, minute=minute, second=10,
)
self._unsub_mandatory.append(unsub)
# Reminder escalation ladder (FEAT-6) — re-evaluate open misses on a tick.
self._unsub_mandatory.append(
async_track_time_interval(
self.hass, self._escalation_tick, _ESCALATION_INTERVAL,
)
)
@callback
def _escalation_tick(self, now: datetime) -> None:
self.hass.async_create_task(self.async_escalate_mandatory_misses(now))
def _make_mandatory_period_cb(self, period_id: str):
@callback
def _cb(now: datetime) -> None:
self.hass.async_create_task(
self.async_detect_mandatory_misses(period_id, dt_util.now().date())
)
return _cb
def disarm_mandatory_schedules(self) -> None:
for unsub in getattr(self, "_unsub_mandatory", []):
unsub()
self._unsub_mandatory = []
async def async_rearm_mandatory_schedules(self) -> None:
"""Re-arm after the time_periods setting changes."""
self.disarm_mandatory_schedules()
self.arm_mandatory_schedules()
async def async_catchup_mandatory_misses(self) -> None:
"""On startup, detect misses for any period that already ended today.
The period-end callbacks only fire going forward, so a restart after a
boundary would otherwise miss that period for the rest of the day. The
detector's existing-item guard keeps this idempotent.
"""
now = dt_util.now()
today = now.date()
cur = now.time()
for hour, minute, period_id in self._mandatory_period_end_times():
if dt_time(hour, minute) <= cur:
await self.async_detect_mandatory_misses(period_id, today)
async def async_detect_anytime_mandatory_misses(self) -> None:
"""Midnight: raise misses for incomplete mandatory 'anytime' chores for the
day that just ended, then prune the postpone override map (new day starts clean)."""
self._ensure_mandatory_state()
yesterday = (dt_util.now() - timedelta(days=1)).date()
await self.async_detect_mandatory_misses("anytime", yesterday)
self.mandatory_postpone = {}
# ---- maintenance -------------------------------------------------------
async def async_prune_orphan_misses(self) -> int:
"""Drop misses whose chore is gone, disabled, or no longer mandatory."""
chores = {c.id: c for c in self.storage.get_chores()}
removed = 0
for m in self.storage.get_mandatory_misses():
chore = chores.get(m.chore_id)
if chore is None or not getattr(chore, "mandatory", False) or not getattr(chore, "enabled", True):
self.storage.remove_mandatory_miss(m.id)
removed += 1
if removed:
await self.storage.async_save()
await self.async_refresh()
return removed
def mandatory_misses_state(self) -> list[dict]:
"""Misses enriched with chore/child display names for the UI."""
chores = {c.id: c for c in self.storage.get_chores()}
children = {c.id: c for c in self.storage.get_children()}
out = []
for m in self.storage.get_mandatory_misses():
d = m.to_dict()
d["chore_name"] = getattr(chores.get(m.chore_id), "name", "")
d["child_name"] = getattr(children.get(m.child_id), "name", "")
out.append(d)
return out
@@ -0,0 +1,625 @@
"""Central notification dispatcher and scheduler.
All TaskMate notifications flow through this module. It owns:
* The static NOTIFICATION_TYPES registry (built-in metadata)
* `fire(type_id, context)` — the public dispatch entry point
* Scheduled callbacks for time-gated types (bedtime, streak-at-risk, custom)
* The mobile_app_notification_action listener for tap-to-approve
Other coordinators MUST NOT call notify.* / persistent_notification directly
once this module is in place. They call self.notifications.fire(...).
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any
from homeassistant.core import HomeAssistant
from homeassistant.helpers.event import async_track_time_change
from .const import (
NOTIF_TYPE_ALL_CHORES_DONE,
NOTIF_TYPE_BADGE_EARNED,
NOTIF_TYPE_BEDTIME_REMINDER,
NOTIF_TYPE_CELEBRATION,
NOTIF_TYPE_FAMILY_GOAL_REACHED,
NOTIF_TYPE_LEVEL_UP,
NOTIF_TYPE_MANDATORY_PARENT_ALERT,
NOTIF_TYPE_MANDATORY_REMINDER,
NOTIF_TYPE_MONTHLY_REPORT,
NOTIF_TYPE_PENDING_CHORE_APPROVAL,
NOTIF_TYPE_PENDING_REWARD_CLAIM,
NOTIF_TYPE_SEASON_CHAMPION,
NOTIF_TYPE_STREAK_AT_RISK,
NOTIF_TYPE_STREAK_MILESTONE,
NOTIF_TYPE_WEEKLY_DIGEST,
)
from .models import NotificationRoute
_LOGGER = logging.getLogger(__name__)
# Appended to actionable notifications sent to non-mobile_app backends, which
# silently ignore tap actions. Gives those recipients a way to act.
_APPROVE_IN_PANEL_HINT = "Open the TaskMate panel to approve or reject."
def _approval_tag(entry_id: str) -> str:
"""Stable HA companion-app notification tag for an approval (chore/reward).
Set on the outgoing mobile push so that, once the item is approved or
rejected, the same tag can be passed to ``clear_notification`` to dismiss
the alert from the phone. Keyed on the completion/claim id, so each pending
item owns exactly one push.
"""
return f"taskmate_approval_{entry_id}"
@dataclass(frozen=True)
class NotificationTypeMeta:
id: str
audience: str # "child" | "parent" | "both"
time_gated: bool # has its own scheduled callback
per_recipient_time: bool # if True, route.time controls the schedule per recipient
actionable: bool # carries Approve/Reject mobile actions
default_enabled: bool # default master_enabled state at install
NOTIFICATION_TYPES: list[NotificationTypeMeta] = [
NotificationTypeMeta(NOTIF_TYPE_BEDTIME_REMINDER, "child", True, True, False, False),
NotificationTypeMeta(NOTIF_TYPE_STREAK_AT_RISK, "child", True, False, False, False),
NotificationTypeMeta(NOTIF_TYPE_ALL_CHORES_DONE, "both", False, False, False, False),
NotificationTypeMeta(NOTIF_TYPE_BADGE_EARNED, "both", False, False, False, True),
NotificationTypeMeta(NOTIF_TYPE_PENDING_CHORE_APPROVAL, "parent", False, False, True, True),
NotificationTypeMeta(NOTIF_TYPE_PENDING_REWARD_CLAIM, "parent", False, False, True, True),
NotificationTypeMeta(NOTIF_TYPE_STREAK_MILESTONE, "both", False, False, False, False),
NotificationTypeMeta(NOTIF_TYPE_LEVEL_UP, "both", False, False, False, False),
NotificationTypeMeta(NOTIF_TYPE_WEEKLY_DIGEST, "parent", False, False, False, False),
NotificationTypeMeta(NOTIF_TYPE_CELEBRATION, "both", False, False, False, False),
NotificationTypeMeta(NOTIF_TYPE_MANDATORY_REMINDER, "child", False, False, False, False),
NotificationTypeMeta(NOTIF_TYPE_MANDATORY_PARENT_ALERT, "parent", False, False, False, False),
NotificationTypeMeta(NOTIF_TYPE_MONTHLY_REPORT, "parent", False, False, False, False),
NotificationTypeMeta(NOTIF_TYPE_SEASON_CHAMPION, "both", False, False, False, False),
NotificationTypeMeta(NOTIF_TYPE_FAMILY_GOAL_REACHED, "both", False, False, False, False),
]
NOTIFICATION_TYPES_BY_ID: dict[str, NotificationTypeMeta] = {
t.id: t for t in NOTIFICATION_TYPES
}
def _parse_hhmm(value: str) -> tuple[int, int] | None:
"""Parse "HH:MM" into (hour, minute); None if blank/malformed."""
if not value:
return None
try:
hour, minute = map(int, value.split(":", 1))
except (ValueError, AttributeError):
return None
if 0 <= hour <= 23 and 0 <= minute <= 59:
return hour, minute
return None
def _is_within_quiet_hours(start: str, end: str, now) -> bool:
"""True if ``now`` (a datetime) falls inside the [start, end) HH:MM window.
Both bounds must be set, else quiet hours are disabled. A start later than
the end denotes an overnight window (e.g. 20:00-07:00). The end bound is
exclusive so a window of 07:00-07:00 (equal bounds) is treated as disabled.
"""
s = _parse_hhmm(start)
e = _parse_hhmm(end)
if s is None or e is None or s == e:
return False
cur = now.hour * 60 + now.minute
start_m = s[0] * 60 + s[1]
end_m = e[0] * 60 + e[1]
if start_m < end_m:
return start_m <= cur < end_m
# Overnight window: active from start through midnight to end.
return cur >= start_m or cur < end_m
class _SafeDict(dict):
"""str.format_map dict that leaves missing keys as `{key}` literal."""
def __missing__(self, key: str) -> str:
return "{" + key + "}"
class NotificationCoordinator:
"""Single dispatcher for all TaskMate notifications."""
def __init__(self, hass: HomeAssistant, storage) -> None:
self.hass = hass
self.storage = storage
self._scheduled_unsubs: list = [] # cancellation handles for time triggers
self.coordinator: Any = None
async def fire(
self, type_id: str, context: dict[str, Any],
only_recipients: set[str] | None = None,
) -> None:
"""Dispatch a notification of the given type with the given context.
``only_recipients`` — if given, restrict delivery to those recipient ids
(e.g. a single ``child:<id>``). Used for per-child escalation so a
reminder about one child doesn't fan out to every routed recipient.
"""
meta = NOTIFICATION_TYPES_BY_ID.get(type_id)
if meta is None:
_LOGGER.warning("Unknown notification type %s", type_id)
return
cfg = self.storage.get_notification_config(type_id)
if not cfg.master_enabled:
self._fire_bus_event(type_id, context, recipients=[])
return
recipients_fired: list[str] = []
message = self._render_template(meta, context)
for recipient_id, route in cfg.routes.items():
if not route.enabled:
continue
if only_recipients is not None and recipient_id not in only_recipients:
continue
if self._child_in_quiet_hours(recipient_id):
# Do-not-disturb: suppress this child's notifications during
# their configured quiet-hours window. Parent routes (and the
# bus event below) are unaffected.
continue
notify_service = self._resolve_notify_service(recipient_id)
if not notify_service:
continue
await self._send_to(notify_service, message, meta, context)
recipients_fired.append(recipient_id)
# NOTE: deliberately no unconditional persistent_notification here.
# persistent_notification is instance-wide (visible to every HA user,
# including a child on a kiosk), so firing it for every notification
# leaked parent-audience messages ("… awaiting approval") to the child
# who just completed the chore. A persistent notification now happens
# only when a recipient is explicitly routed to notify.persistent_
# notification, flowing through _send_to like any other channel.
self._fire_bus_event(type_id, context, recipients_fired)
async def send_test(self, type_id: str) -> list[str]:
"""Send a sample notification of ``type_id`` to its enabled routes.
Ignores ``master_enabled`` (so a route can be verified before going live),
prefixes the message with "[TEST] ", and does not emit a bus event.
Returns the recipient ids that were sent to.
"""
meta = NOTIFICATION_TYPES_BY_ID.get(type_id)
if meta is None:
raise ValueError(f"Unknown notification type {type_id}")
ctx = {
"child_name": "Alex",
"chore_name": "Tidy room",
"reward_name": "Movie night",
"badge_name": "Star Helper",
"points": 10,
"cost": 50,
"streak": 7,
"days": 7,
"level": 5,
"tier": 3,
"message": "Alex reached level 5!",
"summary": "• Alex: 5 chores, 50 Stars earned",
"month": "January 2026",
"goal_name": "Movie night fund",
"goal_reward": "a family movie night",
"points_name": self.storage.get_points_name(),
}
message = "[TEST] " + self._render_template(meta, ctx)
cfg = self.storage.get_notification_config(type_id)
sent: list[str] = []
for recipient_id, route in cfg.routes.items():
if not route.enabled:
continue
notify_service = self._resolve_notify_service(recipient_id)
if not notify_service:
continue
await self._send_to(notify_service, message, meta, ctx)
sent.append(recipient_id)
await self._fire_persistent_notification(type_id, message)
return sent
def _child_in_quiet_hours(self, recipient_id: str) -> bool:
"""True if ``recipient_id`` is a child currently inside their quiet-hours
(do-not-disturb) window. Non-child recipients are never suppressed."""
if not recipient_id.startswith("child:"):
return False
child = self.storage.get_child(recipient_id.split(":", 1)[1])
if child is None:
return False
from homeassistant.util import dt as dt_util
return _is_within_quiet_hours(
child.quiet_hours_start, child.quiet_hours_end, dt_util.now()
)
def _resolve_notify_service(self, recipient_id: str) -> str:
if recipient_id.startswith("child:"):
child_id = recipient_id.split(":", 1)[1]
child = self.storage.get_child(child_id)
return child.notify_service or "" if child else ""
if recipient_id.startswith("parent:"):
for p in self.storage.get_parent_recipients():
if p.id == recipient_id and p.enabled:
return p.notify_service
return ""
def _render_template(self, meta: "NotificationTypeMeta", context: dict[str, Any]) -> str:
# Built-in types use a baked-in default; will be replaced by translations
# in a later task. For now use a safe English fallback so dispatch works.
templates = {
NOTIF_TYPE_BEDTIME_REMINDER: "{child_name}, you still have chores to do before bedtime.",
NOTIF_TYPE_STREAK_AT_RISK: "{child_name}, complete a chore today to keep your {streak}-day streak!",
NOTIF_TYPE_ALL_CHORES_DONE: "{child_name} finished every chore today!",
NOTIF_TYPE_BADGE_EARNED: "{child_name} earned the {badge_name} badge!",
NOTIF_TYPE_PENDING_CHORE_APPROVAL: "{child_name} completed '{chore_name}' (+{points} {points_name}) — awaiting approval.",
NOTIF_TYPE_PENDING_REWARD_CLAIM: "{child_name} claimed '{reward_name}' ({cost} {points_name}) — awaiting approval.",
NOTIF_TYPE_STREAK_MILESTONE: "{child_name} hit a {days}-day streak — +{points} {points_name}!",
NOTIF_TYPE_LEVEL_UP: "{child_name} reached level {level}! 🎉",
NOTIF_TYPE_WEEKLY_DIGEST: "TaskMate weekly digest:\n{summary}",
NOTIF_TYPE_CELEBRATION: "🎉 {message}",
NOTIF_TYPE_MANDATORY_REMINDER: "{child_name}, you still need to do '{chore_name}'.",
NOTIF_TYPE_MANDATORY_PARENT_ALERT: "{child_name} still hasn't done the mandatory chore '{chore_name}'.",
NOTIF_TYPE_MONTHLY_REPORT: "TaskMate {month} report:\n{summary}",
NOTIF_TYPE_SEASON_CHAMPION: "🏆 {child_name} won the {month} leaderboard with {points} {points_name}!",
NOTIF_TYPE_FAMILY_GOAL_REACHED: "🎉 Family goal reached: {goal_name}! Time for {goal_reward}.",
}
tpl = context.get("message_template") or templates.get(meta.id, "")
try:
return tpl.format_map(_SafeDict(context))
except (ValueError, IndexError, KeyError):
# Malformed user template (e.g. stray '{') — fall back to raw text
_LOGGER.warning("Malformed notification template %r, sending raw", tpl)
return tpl
async def _send_to(
self, notify_service: str, message: str,
meta: "NotificationTypeMeta", context: dict[str, Any],
) -> None:
domain, service = (
notify_service.split(".", 1) if "." in notify_service
else ("notify", notify_service)
)
if domain != "notify":
_LOGGER.warning("notify_service must be notify.*, got %s", notify_service)
return
data: dict[str, Any] = {"title": "TaskMate", "message": message}
if meta.actionable:
entry_id = context.get("entry_id")
# Tap actions only render on the HA mobile app. Other backends
# (Telegram, email, SMS, persistent, …) ignore them, so instead of
# sending dead buttons we append a hint pointing to the panel.
if service.startswith("mobile_app"):
if entry_id:
# `tag` lets us dismiss this push later (clear_approval) once
# the item is reviewed — see _approval_tag.
data["data"] = {
"tag": _approval_tag(entry_id),
"actions": [
{"action": f"TASKMATE_APPROVE_{entry_id}", "title": "Approve"},
{"action": f"TASKMATE_REJECT_{entry_id}", "title": "Reject"},
]
}
else:
data["message"] = f"{message} {_APPROVE_IN_PANEL_HINT}"
try:
await self.hass.services.async_call(domain, service, data, blocking=False)
except Exception as err: # noqa: BLE001
_LOGGER.warning("notify call failed for %s: %s", notify_service, err)
async def clear_approval(self, type_id: str, entry_id: str) -> None:
"""Dismiss the mobile push for a reviewed approval (chore or reward).
The pending-approval push carries a stable ``tag`` (:func:`_approval_tag`).
Once the item is approved or rejected we send the HA companion app's
``clear_notification`` with the same tag so the alert disappears from the
phone — the fix for the "approve all leaves a pile of notifications"
problem.
Gated to ``mobile_app.*`` services only: other backends (Telegram, email,
persistent) would render the literal "clear_notification" string as a
message, so they are skipped. Clearing a tag that isn't present is a
harmless no-op on the app, so no per-recipient bookkeeping is needed.
"""
if not entry_id:
return
cfg = self.storage.get_notification_config(type_id)
if not cfg.master_enabled:
return
tag = _approval_tag(entry_id)
cleared_services: set[str] = set()
for recipient_id, route in cfg.routes.items():
if not route.enabled:
continue
notify_service = self._resolve_notify_service(recipient_id)
if not notify_service:
continue
domain, service = (
notify_service.split(".", 1) if "." in notify_service
else ("notify", notify_service)
)
if domain != "notify" or not service.startswith("mobile_app"):
continue
if service in cleared_services:
continue
cleared_services.add(service)
try:
await self.hass.services.async_call(
"notify", service,
{"message": "clear_notification", "data": {"tag": tag}},
blocking=False,
)
except Exception as err: # noqa: BLE001
_LOGGER.warning("clear_notification failed for %s: %s", notify_service, err)
async def _fire_persistent_notification(self, type_id: str, message: str) -> None:
await self.hass.services.async_call(
"persistent_notification", "create",
{
"title": "TaskMate",
"message": message,
"notification_id": f"taskmate_{type_id}",
},
blocking=False,
)
def _fire_bus_event(self, type_id: str, context: dict[str, Any], recipients: list[str]) -> None:
payload = dict(context)
payload["recipients"] = recipients
self.hass.bus.async_fire(f"taskmate_{type_id}", payload)
async def handle_mobile_action(self, event) -> None:
"""Route TASKMATE_APPROVE_<id> / TASKMATE_REJECT_<id> mobile actions."""
action = (event.data or {}).get("action", "")
if not action.startswith("TASKMATE_"):
return
coordinator = getattr(self, "coordinator", None)
if coordinator is None:
return
if action.startswith("TASKMATE_APPROVE_"):
entry_id = action[len("TASKMATE_APPROVE_"):]
try:
await coordinator.async_approve_chore(entry_id)
return
except (ValueError, KeyError):
pass
try:
await coordinator.async_approve_reward(entry_id)
except (ValueError, KeyError):
_LOGGER.info("Mobile action %s — entry not found", action)
elif action.startswith("TASKMATE_REJECT_"):
entry_id = action[len("TASKMATE_REJECT_"):]
try:
await coordinator.async_reject_chore(entry_id)
return
except (ValueError, KeyError):
pass
try:
await coordinator.async_reject_reward(entry_id)
except (ValueError, KeyError):
_LOGGER.info("Mobile action %s — entry not found", action)
# ------------------------------------------------------------------
# Scheduler — time-gated callbacks
# ------------------------------------------------------------------
async def async_setup_schedules(self) -> None:
"""Cancel any existing time callbacks and register fresh ones from current config.
Call this on startup AND after any config change that affects schedules
(e.g. bedtime time edited, custom notification time edited, master toggled).
"""
for unsub in self._scheduled_unsubs:
try:
unsub()
except Exception: # noqa: BLE001
pass
self._scheduled_unsubs = []
# Bedtime — per-child time
cfg = self.storage.get_notification_config("bedtime_reminder")
if cfg.master_enabled:
for recipient_id, route in cfg.routes.items():
if not route.enabled or not route.time:
continue
if not recipient_id.startswith("child:"):
continue
child_id = recipient_id.split(":", 1)[1]
self._register_at(
route.time,
self._make_bedtime_callback(child_id),
)
# Streak at risk — global cutoff time, fire once per child
cfg = self.storage.get_notification_config("streak_at_risk")
if cfg.master_enabled:
cutoff = self.storage.get_streak_at_risk_cutoff()
self._register_at(cutoff, self._streak_at_risk_callback)
# Custom — per-row time
for custom in self.storage.get_custom_notifications():
if not custom.enabled:
continue
self._register_at(
custom.time,
self._make_custom_callback(custom.id),
)
def _register_at(self, hhmm: str, callback) -> None:
try:
hour, minute = map(int, hhmm.split(":", 1))
except (ValueError, AttributeError):
_LOGGER.warning("Invalid time %r — skipping schedule", hhmm)
return
unsub = async_track_time_change(
self.hass, callback, hour=hour, minute=minute, second=0,
)
self._scheduled_unsubs.append(unsub)
def _make_bedtime_callback(self, child_id: str):
async def _cb(now):
child = self.storage.get_child(child_id)
if child is None:
return
if not self._has_outstanding_chores_today(child_id):
return
await self.fire(
"bedtime_reminder",
{"child_name": child.name, "child_id": child_id},
)
return _cb
async def _streak_at_risk_callback(self, now) -> None:
from homeassistant.util import dt as dt_util
today = dt_util.now().date().isoformat()
for child in self.storage.get_children():
if (child.current_streak or 0) < 2:
continue
if child.last_completion_date == today:
continue
await self.fire(
"streak_at_risk",
{
"child_name": child.name,
"child_id": child.id,
"streak": child.current_streak,
},
)
def _make_custom_callback(self, custom_id: str):
async def _cb(now):
from homeassistant.util import dt as dt_util
n = next(
(c for c in self.storage.get_custom_notifications() if c.id == custom_id),
None,
)
if n is None or not n.enabled:
return
today_bit = 1 << dt_util.now().date().weekday() # Mon=0
if not (n.day_mask & today_bit):
return
for recipient_id in n.recipient_ids:
notify_service = self._resolve_notify_service(recipient_id)
if not notify_service:
continue
child_name = ""
if recipient_id.startswith("child:"):
child = self.storage.get_child(recipient_id.split(":", 1)[1])
child_name = child.name if child else ""
try:
message = n.message_template.format_map(
_SafeDict({"child_name": child_name, "time": n.time}),
)
except (ValueError, IndexError, KeyError):
# Malformed user template — send the raw text rather
# than silently dropping the notification
_LOGGER.warning(
"Malformed custom notification template %r, sending raw",
n.message_template,
)
message = n.message_template
service_name = notify_service.split(".", 1)[1] if "." in notify_service else notify_service
await self.hass.services.async_call(
"notify", service_name,
{"title": "TaskMate", "message": message},
blocking=False,
)
self.hass.bus.async_fire(
"taskmate_custom_notification",
{"id": n.id, "name": n.name, "recipients": n.recipient_ids},
)
return _cb
# ------------------------------------------------------------------
# CRUD wrappers — persist + reload schedules as needed
# ------------------------------------------------------------------
async def upsert_custom(self, n) -> None:
self.storage.upsert_custom_notification(n)
await self.storage.async_save()
await self.async_setup_schedules()
async def delete_custom(self, custom_id: str) -> None:
self.storage.delete_custom_notification(custom_id)
await self.storage.async_save()
await self.async_setup_schedules()
def ensure_parent_default_routes(self) -> bool:
"""Subscribe enabled parents to default-on parent-audience types.
A parent-audience type (e.g. pending_chore_approval) is useless without
a parent route — and adding a parent recipient previously left those
routes empty, so no one was notified. Fills ONLY types whose routes are
still empty, so it never overrides routing the user set or cleared.
Returns True if anything changed (caller is responsible for saving).
"""
parents = [p for p in self.storage.get_parent_recipients() if p.enabled]
if not parents:
return False
changed = False
for meta in NOTIFICATION_TYPES:
if not meta.default_enabled or meta.audience not in ("parent", "both"):
continue
if self.storage.get_notification_config(meta.id).routes:
continue # already configured — leave it alone
for p in parents:
self.storage.set_notification_route(
meta.id, p.id, NotificationRoute(enabled=True)
)
changed = True
return changed
async def upsert_parent(self, p) -> None:
self.storage.upsert_parent_recipient(p)
self.ensure_parent_default_routes()
await self.storage.async_save()
async def delete_parent(self, parent_id: str) -> None:
self.storage.delete_parent_recipient(parent_id)
await self.storage.async_save()
await self.async_setup_schedules() # in case routes referenced this id
async def set_route(self, type_id: str, recipient_id: str, route) -> None:
self.storage.set_notification_route(type_id, recipient_id, route)
await self.storage.async_save()
if NOTIFICATION_TYPES_BY_ID.get(type_id) and NOTIFICATION_TYPES_BY_ID[type_id].time_gated:
await self.async_setup_schedules()
async def set_master_enabled(self, type_id: str, enabled: bool) -> None:
self.storage.set_notification_master(type_id, enabled)
await self.storage.async_save()
if NOTIFICATION_TYPES_BY_ID.get(type_id) and NOTIFICATION_TYPES_BY_ID[type_id].time_gated:
await self.async_setup_schedules()
async def set_streak_cutoff(self, hhmm: str) -> None:
self.storage.set_streak_at_risk_cutoff(hhmm)
await self.storage.async_save()
await self.async_setup_schedules()
def _has_outstanding_chores_today(self, child_id: str) -> bool:
"""Returns True if the child has at least one chore assigned today
that has no approved/pending completion yet."""
from homeassistant.util import dt as dt_util
today = dt_util.now().date()
chores = self.storage.get_chores()
completions = self.storage.get_completions()
completed_today = {
c.chore_id for c in completions
if c.child_id == child_id
and dt_util.as_local(c.completed_at).date() == today
}
for chore in chores:
if not chore.assigned_to or child_id not in chore.assigned_to:
continue
if chore.id in completed_today:
continue
return True
return False
File diff suppressed because it is too large Load Diff
+156
View File
@@ -0,0 +1,156 @@
"""Quests (chore chains) mixin for TaskMateCoordinator.
A quest is an ordered list of chores. Each child works through the chain one
step at a time: completing (and getting approval for) the chore at their
current step advances them. Finishing the final step awards the quest's bonus
points, fires a ``taskmate_quest_completed`` event + celebration, and either
resets progress (repeatable quests) or marks the quest complete for that child.
"""
from __future__ import annotations
import logging
from homeassistant.util import dt as dt_util
from .models import PointsTransaction, Quest
_LOGGER = logging.getLogger(__name__)
class QuestsMixin:
"""Mixin providing quest CRUD and chore-chain progression."""
# ── CRUD ─────────────────────────────────────────────────────────────
async def async_create_quest(self, **fields) -> str:
quest = Quest.from_dict(fields)
if not quest.name.strip():
raise ValueError("Quest name is required")
if not quest.steps:
raise ValueError("A quest needs at least one step")
self.storage.add_quest(quest)
await self.storage.async_save()
await self.async_refresh()
return quest.id
async def async_update_quest(self, quest_id: str, **fields) -> None:
existing = self.storage.get_quest(quest_id)
if not existing:
raise ValueError(f"Quest {quest_id} not found")
data = existing.to_dict()
data.update(fields)
data["id"] = quest_id
updated = Quest.from_dict(data)
if not updated.steps:
raise ValueError("A quest needs at least one step")
self.storage.update_quest(updated)
await self.storage.async_save()
await self.async_refresh()
async def async_delete_quest(self, quest_id: str) -> None:
if not self.storage.get_quest(quest_id):
raise ValueError(f"Quest {quest_id} not found")
self.storage.remove_quest(quest_id)
await self.storage.async_save()
await self.async_refresh()
# ── Progress helpers ─────────────────────────────────────────────────
def _quest_applies_to(self, quest: Quest, child_id: str) -> bool:
return not quest.assigned_to or child_id in quest.assigned_to
def quest_progress_for_child(self, child_id: str) -> list[dict]:
"""Snapshot of every active quest assigned to ``child_id``.
Returned shape (for sensors / cards):
``{quest_id, name, icon, total_steps, step, done, times_completed,
bonus_points, next_chore_id}``.
"""
out: list[dict] = []
for quest in self.storage.get_quests():
if not quest.active or not self._quest_applies_to(quest, child_id):
continue
prog = self.storage.get_quest_child_progress(quest.id, child_id)
step = int(prog.get("step", 0))
total = len(quest.steps)
done = step >= total
out.append({
"quest_id": quest.id,
"name": quest.name,
"icon": quest.icon,
"total_steps": total,
"step": min(step, total),
"done": done,
"times_completed": int(prog.get("completed_count", 0)),
"bonus_points": quest.bonus_points,
"next_chore_id": quest.steps[step] if not done and step < total else "",
})
return out
# ── Progression ──────────────────────────────────────────────────────
async def _async_advance_quests(self, child_id: str, chore_id: str) -> None:
"""Advance any active quest whose current step is ``chore_id``.
Called after a (non-bonus) chore completion is approved. Persists and
refreshes only if a quest actually advanced.
"""
child = self.get_child(child_id)
if not child:
return
changed = False
for quest in self.storage.get_quests():
if not quest.active or not quest.steps:
continue
if not self._quest_applies_to(quest, child_id):
continue
prog = dict(self.storage.get_quest_child_progress(quest.id, child_id))
step = int(prog.get("step", 0))
if step >= len(quest.steps):
continue # already finished (non-repeatable)
if quest.steps[step] != chore_id:
continue # this completion isn't the next step
step += 1
prog["step"] = step
self.storage.set_quest_child_progress(quest.id, child_id, prog)
changed = True
if step >= len(quest.steps):
await self._complete_quest(quest, child, prog)
if changed:
await self.storage.async_save()
await self.async_refresh()
async def _complete_quest(self, quest: Quest, child, prog: dict) -> None:
"""Award the quest bonus and reset/finalise progress."""
prog["completed_count"] = int(prog.get("completed_count", 0)) + 1
prog["last_completed"] = dt_util.now().isoformat()
bonus = int(quest.bonus_points or 0)
if bonus > 0:
child.points += bonus
child.total_points_earned += bonus
child.career_score = child.total_points_earned - child.total_penalties_received
self.storage.add_points_transaction(PointsTransaction(
child_id=child.id, points=bonus,
reason=f"Quest complete: {quest.name}", created_at=dt_util.now(),
))
if hasattr(self, "_maybe_level_up"):
await self._maybe_level_up(child)
self.storage.update_child(child)
self.hass.bus.async_fire("taskmate_quest_completed", {
"child_id": child.id, "child_name": child.name,
"quest_id": quest.id, "quest_name": quest.name,
"bonus": bonus, "timestamp": dt_util.now().isoformat(),
})
if hasattr(self, "_celebrate"):
await self._celebrate(
child, "quest_completed",
f"{child.name} completed the quest '{quest.name}'!",
tier=3, extra={"quest_id": quest.id, "bonus": bonus},
)
# Repeatable quests start over; one-shot quests stay complete.
if quest.repeatable:
prog["step"] = 0
self.storage.set_quest_child_progress(quest.id, child.id, prog)
_LOGGER.info("Quest '%s' completed by %s (+%d)", quest.name, child.name, bonus)
+589
View File
@@ -0,0 +1,589 @@
"""Reward operations mixin for TaskMateCoordinator."""
from __future__ import annotations
import logging
from datetime import date
from typing import TYPE_CHECKING
from homeassistant.util import dt as dt_util
from .models import PointsTransaction, PoolAllocation, Reward, RewardClaim
if TYPE_CHECKING:
pass
_LOGGER = logging.getLogger(__name__)
class RewardsMixin:
"""Mixin providing reward CRUD, claiming, and pool allocation logic."""
def get_reward(self, reward_id: str) -> Reward | None:
"""Get a reward by ID."""
return self.storage.get_reward(reward_id)
def is_pool_mode_claim(self, claim: RewardClaim) -> bool:
"""True if the claim is covered by pool allocations (points already deducted).
Pool-mode claims must NOT be counted against a child's spendable balance,
because their cost was already removed from child.points at allocation time.
"""
reward = self.get_reward(claim.reward_id)
if not reward:
return False
if getattr(reward, "is_jackpot", False):
return self.storage.get_total_allocated_for_reward(claim.reward_id) >= reward.cost
alloc = self.storage.get_pool_allocation(claim.child_id, claim.reward_id)
return bool(alloc and alloc.allocated_points >= reward.cost)
async def async_add_reward(
self,
name: str,
cost: int = 50,
description: str = "",
icon: str = "mdi:gift",
assigned_to: list[str] | None = None,
is_jackpot: bool = False,
pool_enabled: bool = False,
quantity: int | None = None,
expires_at: str | None = None,
) -> Reward:
"""Add a new reward."""
reward = Reward(
name=name,
cost=cost,
description=description,
icon=icon,
assigned_to=assigned_to or [],
is_jackpot=is_jackpot,
pool_enabled=pool_enabled, # Reward.__post_init__ forces this on for jackpots (#552)
quantity=quantity,
expires_at=expires_at,
)
self.storage.add_reward(reward)
await self.storage.async_save()
await self.async_refresh()
return reward
async def async_update_reward(self, reward: Reward) -> None:
"""Update a reward.
If the cost is reduced below any existing pool allocation, the excess is
refunded to the contributing children's wallets so over-allocated pools
can't appear as e.g. 11/10. If the edit makes the reward unavailable
(quantity set to 0, or expires_at moved into the past) any pool
allocations on that reward are refunded in full.
"""
old = self.get_reward(reward.id)
# Jackpots are always pool-mode (#552); keep stored data consistent.
if reward.is_jackpot:
reward.pool_enabled = True
self.storage.update_reward(reward)
if old and reward.cost < old.cost:
self._refund_pool_excess(reward, "Pool refund (reward cost reduced)")
became_unavailable = (
self._reward_is_unavailable(reward)
and old is not None
and not self._reward_is_unavailable(old)
)
if became_unavailable:
reason = (
"Pool refund (reward expired)"
if self._reward_is_expired(reward)
else "Pool refund (reward sold out)"
)
self._refund_all_pool_allocations(reward, reason)
await self.storage.async_save()
await self.async_refresh()
async def async_remove_reward(self, reward_id: str) -> None:
"""Remove a reward and clean up any pending claims and pool allocations referencing it."""
self.storage.remove_reward_claims_for_reward(reward_id)
# Refund any earmarked pool points back to their contributors before the
# allocations are dropped — otherwise the points deducted at allocation
# time would be silently lost (#564). Mirrors the expiry/sold-out paths.
reward = self.get_reward(reward_id)
if reward:
self._refund_all_pool_allocations(reward, "Pool refund (reward deleted)")
self.storage.remove_pool_allocations_for_reward(reward_id)
self.storage.remove_reward(reward_id)
await self.storage.async_save()
await self.async_refresh()
@staticmethod
def _reward_is_sold_out(reward: Reward) -> bool:
"""True if the reward has a stock count and it's been exhausted."""
return reward.quantity is not None and reward.quantity <= 0
@staticmethod
def _reward_is_expired(reward: Reward) -> bool:
"""True if the reward has an expiry date and it's on/before today."""
if not reward.expires_at:
return False
try:
deadline = date.fromisoformat(reward.expires_at)
except (TypeError, ValueError):
return False
return deadline <= dt_util.now().date()
@classmethod
def _reward_is_unavailable(cls, reward: Reward) -> bool:
"""True if the reward cannot currently be claimed or allocated to."""
return cls._reward_is_sold_out(reward) or cls._reward_is_expired(reward)
def _refund_all_pool_allocations(self, reward: Reward, reason: str) -> None:
"""Refund every pool allocation on `reward` back to its contributor.
Used when a reward becomes unavailable (sold out or expired) while
children still have points earmarked for it. Reuses the existing
per-allocation refund helper so the PointsTransaction audit trail
stays consistent with cost-reduction refunds.
"""
allocations = [
a for a in self.storage.get_pool_allocations()
if a.reward_id == reward.id and a.allocated_points > 0
]
for alloc in allocations:
self._apply_pool_refund(alloc, alloc.allocated_points, reward, reason)
def _refund_pool_excess(self, reward: Reward, reason: str) -> None:
"""Trim any pool allocations on `reward` that exceed its cost.
Non-jackpot: each allocation is capped at the reward's cost individually.
Jackpot: allocations are trimmed starting from the newest contributor
until the combined total matches the cost.
"""
allocations = [
a for a in self.storage.get_pool_allocations()
if a.reward_id == reward.id and a.allocated_points > 0
]
if not allocations:
return
if reward.is_jackpot:
overshoot = sum(a.allocated_points for a in allocations) - reward.cost
if overshoot <= 0:
return
for alloc in sorted(allocations, key=lambda a: a.id, reverse=True):
if overshoot <= 0:
break
refund = min(alloc.allocated_points, overshoot)
self._apply_pool_refund(alloc, refund, reward, reason)
overshoot -= refund
else:
for alloc in allocations:
if alloc.allocated_points > reward.cost:
self._apply_pool_refund(
alloc, alloc.allocated_points - reward.cost, reward, reason
)
def _apply_pool_refund(
self, allocation: PoolAllocation, refund: int, reward: Reward, reason: str
) -> None:
"""Refund `refund` points from `allocation` back to the child's wallet.
Updates or removes the allocation record and writes an audit transaction.
"""
if refund <= 0:
return
child = self.get_child(allocation.child_id)
if not child:
return
child.points += refund
self.storage.update_child(child)
remaining = allocation.allocated_points - refund
if remaining <= 0:
self.storage.remove_pool_allocation(allocation.child_id, allocation.reward_id)
else:
self.storage.upsert_pool_allocation(PoolAllocation(
child_id=allocation.child_id,
reward_id=allocation.reward_id,
allocated_points=remaining,
id=allocation.id,
))
self.storage.add_points_transaction(PointsTransaction(
child_id=allocation.child_id,
points=refund,
reason=f"{reason}: {reward.name}",
created_at=dt_util.now(),
))
async def async_claim_reward(self, reward_id: str, child_id: str) -> RewardClaim:
"""Child claims a reward — creates a pending claim awaiting parent approval.
Two modes are supported:
* Wallet mode (default): requires child.points (minus committed) to cover cost
* Pool mode: if pool allocations exist for this (child, reward) and they fill the
reward's cost, the claim is a "redeem" — no wallet check needed. For jackpot
rewards the pool total across all contributing children must reach the cost.
"""
reward = self.get_reward(reward_id)
if not reward:
raise ValueError(f"Reward {reward_id} not found")
child = self.get_child(child_id)
if not child:
raise ValueError(f"Child {child_id} not found")
if self._reward_is_sold_out(reward):
raise ValueError(f"Reward '{reward.name}' is sold out")
if self._reward_is_expired(reward):
raise ValueError(f"Reward '{reward.name}' has expired")
# Cost is always static
effective_cost = reward.cost
# Detect pool mode: a filled pool allocation for this (child, reward) is sufficient,
# or for jackpots the summed pool across all children reaches cost.
pool_filled = False
if reward.is_jackpot:
pool_total = self.storage.get_total_allocated_for_reward(reward_id)
if pool_total >= effective_cost:
pool_filled = True
else:
allocation = self.storage.get_pool_allocation(child_id, reward_id)
if allocation and allocation.allocated_points >= effective_cost:
pool_filled = True
if not pool_filled:
# Wallet mode: verify child has enough uncommitted points.
# Pool-mode pending claims already had their cost deducted at allocation time,
# so they are skipped here to avoid double-counting against the wallet.
pending_claims = self.storage.get_pending_reward_claims()
committed = 0
for c in pending_claims:
if c.child_id == child_id and not self.is_pool_mode_claim(c):
pending_reward = self.get_reward(c.reward_id)
if pending_reward:
committed += pending_reward.cost
available_points = child.points - committed
if available_points < effective_cost:
raise ValueError(
f"Not enough points. Need {effective_cost}, have {available_points} available"
)
claim = RewardClaim(
reward_id=reward_id,
child_id=child_id,
claimed_at=dt_util.now(),
)
self.storage.add_reward_claim(claim)
self.hass.bus.async_fire(
"taskmate_reward_claimed",
{
"child_id": child.id,
"reward_id": reward.id,
"claim_id": claim.id,
"cost": reward.cost,
"timestamp": dt_util.now().isoformat(),
},
)
await self.storage.async_save()
await self.async_refresh()
await self._async_notify_pending_reward_claim(
child.name, reward.name, reward.cost, claim_id=claim.id,
)
return claim
def _spend_period_start(self) -> date:
today = dt_util.now().date()
period = self.storage.get_setting("spend_cap_period", "weekly")
if period == "monthly":
return today.replace(day=1)
from datetime import timedelta
return today - timedelta(days=today.weekday()) # Monday of this week
def _spent_in_period(self, child_id: str) -> int:
"""Total reward cost a child has had approved in the current cap period."""
start = self._spend_period_start()
reward_cost = {r.id: r.cost for r in self.storage.get_rewards()}
total = 0
for claim in self.storage.get_reward_claims():
if claim.child_id != child_id or not claim.approved:
continue
when = claim.approved_at or claim.claimed_at
if when and dt_util.as_local(when).date() >= start:
total += reward_cost.get(claim.reward_id, 0)
return total
def _enforce_spend_cap(self, child_id: str, cost: int) -> None:
"""Raise if approving a `cost` spend would exceed the per-period cap."""
enabled = self.storage.get_setting("spend_cap_enabled", False)
if not (enabled is True or str(enabled).lower() == "true"):
return
try:
cap = int(float(self.storage.get_setting("spend_cap_amount", "0")))
except (ValueError, TypeError):
cap = 0
if cap <= 0:
return
if self._spent_in_period(child_id) + cost > cap:
raise ValueError(
f"Spending cap reached: {cap} per period already used"
)
async def async_approve_reward(self, claim_id: str) -> None:
"""Approve a reward claim and deduct points from the child.
If a pool allocation exists for this (child, reward) pair with enough points,
the deduction consumes the pool allocation first (pool mode). Otherwise the
wallet-mode path deducts directly from child.points.
"""
claims = self.storage.get_reward_claims()
for claim in claims:
if claim.id == claim_id:
if claim.approved:
_LOGGER.warning("Reward claim %s already approved, ignoring", claim_id)
return
reward = self.get_reward(claim.reward_id)
child = self.get_child(claim.child_id)
if not reward or not child:
raise ValueError(f"Reward or child not found for claim {claim_id}")
# Cost is always static
effective_cost = reward.cost
# Spending cap: block approval if it would exceed the per-period budget.
self._enforce_spend_cap(claim.child_id, effective_cost)
# Detect pool mode: either a direct allocation, or a filled jackpot pool.
pool_alloc = self.storage.get_pool_allocation(claim.child_id, claim.reward_id)
is_pool_mode = False
if reward.is_jackpot:
pool_total = self.storage.get_total_allocated_for_reward(claim.reward_id)
if pool_total >= effective_cost:
is_pool_mode = True
elif pool_alloc and pool_alloc.allocated_points >= effective_cost:
is_pool_mode = True
if is_pool_mode:
# Pool mode: points were already deducted from child.points at allocation
# time — approving the redeem just clears the allocation record(s).
# Refund any over-allocation first (e.g. left over from a prior cost reduction)
# so the child doesn't lose points beyond the reward's actual cost.
self._refund_pool_excess(reward, "Pool refund on redeem")
if reward.is_jackpot:
jackpot_allocs = [
a for a in self.storage.get_pool_allocations()
if a.reward_id == claim.reward_id and a.allocated_points > 0
]
for alloc in jackpot_allocs:
self.storage.remove_pool_allocation(alloc.child_id, alloc.reward_id)
else:
self.storage.remove_pool_allocation(claim.child_id, claim.reward_id)
else:
# Wallet mode: deduct directly from child.points
if child.points < effective_cost:
raise ValueError(
f"Not enough points to approve. Need {effective_cost}, have {child.points}"
)
child.points -= effective_cost
self.storage.update_child(child)
if reward.quantity is not None:
reward.quantity = max(0, reward.quantity - 1)
self.storage.update_reward(reward)
if reward.quantity == 0:
# Last unit claimed — refund any points other children
# still have earmarked for this reward's pool.
self._refund_all_pool_allocations(
reward, "Pool refund (reward sold out)"
)
claim.approved = True
claim.approved_at = dt_util.now()
self.storage.update_reward_claim(claim)
await self.storage.async_save()
await self.async_refresh()
# Dismiss the mobile approval push now this claim is reviewed.
if getattr(self, "notifications", None):
await self.notifications.clear_approval(
"pending_reward_claim", claim_id
)
self.hass.bus.async_fire("taskmate_reward_approved", {
"child_id": child.id, "child_name": child.name,
"reward_id": reward.id, "reward_name": reward.name,
"cost": effective_cost,
"timestamp": dt_util.now().isoformat(),
})
if getattr(self, "badges", None):
await self.badges.evaluate_for_child(claim.child_id, "reward_redeemed")
return
_LOGGER.warning("Reward claim %s not found for approval", claim_id)
async def async_reject_reward(self, claim_id: str) -> None:
"""Reject a reward claim — no refund needed as points were never deducted."""
claim = next((c for c in self.storage.get_reward_claims() if c.id == claim_id), None)
self.storage.remove_reward_claim(claim_id)
await self.storage.async_save()
await self.async_refresh()
if claim:
reward = self.get_reward(claim.reward_id)
child = self.get_child(claim.child_id)
self.hass.bus.async_fire("taskmate_reward_rejected", {
"child_id": claim.child_id,
"child_name": getattr(child, "name", ""),
"reward_id": claim.reward_id,
"reward_name": getattr(reward, "name", ""),
"timestamp": dt_util.now().isoformat(),
})
# Dismiss the mobile approval push for this reviewed claim.
if getattr(self, "notifications", None):
await self.notifications.clear_approval(
"pending_reward_claim", claim_id
)
async def async_allocate_points_to_pool(
self, child_id: str, reward_id: str, points: int
) -> PoolAllocation:
"""Move `points` from a child's spendable balance into a reward pool.
Deducts immediately from child.points so the visible balance reflects the
commitment. The matching PoolAllocation record tracks the earmarked total
for each (child, reward) pair. Requested points are capped silently at the
pool's remaining capacity and the child's spendable balance.
Allocations are locked — there is no matching "withdraw" operation.
"""
child = self.get_child(child_id)
if not child:
raise ValueError(f"Child {child_id} not found")
reward = self.get_reward(reward_id)
if not reward:
raise ValueError(f"Reward {reward_id} not found")
if self._reward_is_sold_out(reward):
raise ValueError(f"Reward '{reward.name}' is sold out")
if self._reward_is_expired(reward):
raise ValueError(f"Reward '{reward.name}' has expired")
if points < 1:
raise ValueError("Points to allocate must be at least 1")
# Spendable balance = child.points points committed to other pending claims.
# (Already-allocated points are no longer part of child.points, so we do NOT
# subtract total_allocated here — they've been deducted at allocation time.)
# Pool-mode pending claims are also skipped — their cost was already removed
# from child.points at allocation time, so counting it again would block the
# child from allocating to any other pool reward while one awaits approval.
pending_claims = self.storage.get_pending_reward_claims()
committed = 0
for c in pending_claims:
if c.child_id == child_id and not self.is_pool_mode_claim(c):
pending_reward = self.get_reward(c.reward_id)
if pending_reward:
committed += pending_reward.cost
spendable = child.points - committed
if spendable < 1:
raise ValueError(f"No spendable points available for {child.name}")
# Compute remaining pool capacity
existing = self.storage.get_pool_allocation(child_id, reward_id)
current_child_allocation = existing.allocated_points if existing else 0
if reward.is_jackpot:
room_left = reward.cost - self.storage.get_total_allocated_for_reward(reward_id)
else:
room_left = reward.cost - current_child_allocation
if room_left <= 0:
raise ValueError(f"Pool for reward '{reward.name}' is already full")
capped_points = min(points, spendable, room_left)
# Deduct from the visible balance; the allocation record holds the earmarked points.
child.points -= capped_points
self.storage.update_child(child)
allocation = PoolAllocation(
child_id=child_id,
reward_id=reward_id,
allocated_points=current_child_allocation + capped_points,
id=existing.id if existing else PoolAllocation(child_id, reward_id).id,
)
self.storage.upsert_pool_allocation(allocation)
# Audit trail: negative transaction showing the deduction
transaction = PointsTransaction(
child_id=child_id,
points=-capped_points,
reason=f"Allocated to pool: {reward.name}",
created_at=dt_util.now(),
)
self.storage.add_points_transaction(transaction)
await self.storage.async_save()
await self.async_refresh()
return allocation
async def _async_restock_rewards(self) -> None:
"""Refill `quantity` to restock_amount on the period boundary.
daily → every day; weekly → Mondays; monthly → the 1st. A
``restock_last`` stamp guards against restocking twice in a day.
"""
from homeassistant.util import dt as dt_util
today = dt_util.now().date()
today_iso = today.isoformat()
changed = False
for reward in self.storage.get_rewards():
if not getattr(reward, "restock_enabled", False):
continue
if int(getattr(reward, "restock_amount", 0) or 0) <= 0:
continue
if getattr(reward, "restock_last", "") == today_iso:
continue
period = getattr(reward, "restock_period", "weekly")
due = (
period == "daily"
or (period == "weekly" and today.weekday() == 0)
or (period == "monthly" and today.day == 1)
)
if not due:
continue
reward.quantity = int(reward.restock_amount)
reward.restock_last = today_iso
self.storage.update_reward(reward)
changed = True
_LOGGER.info("Restocked reward '%s' to %d", reward.name, reward.quantity)
if changed:
await self.storage.async_save()
await self.async_refresh()
async def _async_expire_rewards(self) -> None:
"""Refund pool allocations on any reward whose expires_at is past.
The reward row itself is kept in storage so the sensor can surface the
"Expired" state and existing claim history stays intact.
"""
changed = False
for reward in self.storage.get_rewards():
if not self._reward_is_expired(reward):
continue
allocations_before = [
a for a in self.storage.get_pool_allocations()
if a.reward_id == reward.id and a.allocated_points > 0
]
if not allocations_before:
continue
self._refund_all_pool_allocations(reward, "Pool refund (reward expired)")
changed = True
_LOGGER.info(
"Reward '%s' expired on %s — refunded %d pool allocation(s)",
reward.name, reward.expires_at, len(allocations_before),
)
if changed:
await self.storage.async_save()
await self.async_refresh()
@@ -0,0 +1,134 @@
"""Template operations mixin for TaskMateCoordinator."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from .models import Chore, generate_id
from .templates import BUILT_IN_IDS, BUILT_IN_TEMPLATES, TEMPLATE_CHORE_FIELDS
if TYPE_CHECKING:
pass
_LOGGER = logging.getLogger(__name__)
class TemplatesMixin:
"""Mixin providing template CRUD and application logic."""
def get_all_templates(self) -> list[dict]:
"""Return built-in + custom templates."""
custom = self.storage.get_custom_templates()
return list(BUILT_IN_TEMPLATES) + custom
def get_template(self, template_id: str) -> dict | None:
"""Return a single template by ID (built-in or custom)."""
for tpl in BUILT_IN_TEMPLATES:
if tpl["id"] == template_id:
return dict(tpl)
return self.storage.get_custom_template(template_id)
async def async_apply_template(self, chores: list[dict]) -> list[str]:
"""Create chores from a template's chore definitions. Returns created IDs."""
if not chores:
raise ValueError("Cannot apply template with no chores")
created_ids = []
for chore_def in chores:
chore = Chore(
name=chore_def.get("name", "Unnamed"),
points=chore_def.get("points", 10),
description=chore_def.get("description", ""),
assigned_to=list(chore_def.get("assigned_to", [])),
requires_approval=chore_def.get("requires_approval", False),
time_category=chore_def.get("time_category", "anytime"),
daily_limit=chore_def.get("daily_limit", 1),
completion_sound=chore_def.get("completion_sound", "coin"),
schedule_mode=chore_def.get("schedule_mode", "specific_days"),
due_days=list(chore_def.get("due_days", [])),
recurrence=chore_def.get("recurrence", "weekly"),
recurrence_day=chore_def.get("recurrence_day", ""),
recurrence_start=chore_def.get("recurrence_start", ""),
first_occurrence_mode=chore_def.get("first_occurrence_mode", "available_immediately"),
assignment_mode=chore_def.get("assignment_mode", "everyone"),
require_availability=chore_def.get("require_availability", False),
visibility_entity=chore_def.get("visibility_entity", ""),
visibility_state=chore_def.get("visibility_state", "on"),
visibility_operator=chore_def.get("visibility_operator", "equals"),
task_type=chore_def.get("task_type", "standard"),
timed_rate_points=chore_def.get("timed_rate_points", 10),
timed_rate_minutes=chore_def.get("timed_rate_minutes", 5),
timed_max_daily_minutes=chore_def.get("timed_max_daily_minutes", 0),
)
self.storage.add_chore(chore)
created_ids.append(chore.id)
await self.storage.async_save()
await self.async_refresh()
return created_ids
async def async_save_template_from_chores(
self, chore_ids: list[str], name: str, icon: str
) -> str:
"""Save existing chores as a custom template pack."""
if not chore_ids:
raise ValueError("At least one chore must be selected")
chores_data = []
for cid in chore_ids:
chore = self.storage.get_chore(cid)
if chore is None:
continue
chore_dict = {k: v for k, v in chore.to_dict().items() if k in TEMPLATE_CHORE_FIELDS}
chores_data.append(chore_dict)
if not chores_data:
raise ValueError("No valid chores found for selected IDs")
tpl_id = generate_id()
template = {
"id": tpl_id,
"name": name.strip(),
"icon": icon or "mdi:clipboard-list",
"builtin": False,
"chores": chores_data,
}
self.storage.add_custom_template(template)
await self.storage.async_save()
return tpl_id
async def async_create_template(
self, name: str, icon: str, chores: list[dict]
) -> str:
"""Create a new custom template from scratch."""
if not chores:
raise ValueError("Template must have at least one chore")
tpl_id = generate_id()
template = {
"id": tpl_id,
"name": name.strip(),
"icon": icon or "mdi:clipboard-list",
"builtin": False,
"chores": chores,
}
self.storage.add_custom_template(template)
await self.storage.async_save()
return tpl_id
async def async_update_template(
self, template_id: str, *, name: str | None = None, icon: str | None = None, chores: list[dict] | None = None
) -> None:
"""Update a custom template. Raises for built-in templates."""
if template_id in BUILT_IN_IDS:
raise ValueError(f"Cannot modify built-in template '{template_id}'")
updates = {}
if name is not None:
updates["name"] = name.strip()
if icon is not None:
updates["icon"] = icon
if chores is not None:
updates["chores"] = chores
self.storage.update_custom_template(template_id, updates)
await self.storage.async_save()
async def async_delete_template(self, template_id: str) -> None:
"""Delete a custom template. Raises for built-in templates."""
if template_id in BUILT_IN_IDS:
raise ValueError(f"Cannot delete built-in template '{template_id}'")
self.storage.remove_custom_template(template_id)
await self.storage.async_save()
+224
View File
@@ -0,0 +1,224 @@
"""Timed task operations mixin for TaskMateCoordinator."""
from __future__ import annotations
import logging
from datetime import datetime
from typing import TYPE_CHECKING
from homeassistant.util import dt as dt_util
from .models import ChoreCompletion, TimedSession
if TYPE_CHECKING:
pass
_LOGGER = logging.getLogger(__name__)
class TimedMixin:
"""Mixin providing timed task start/pause/stop and session management."""
async def async_start_timed_task(self, chore_id: str, child_id: str) -> None:
"""Start or resume a timed task session."""
chore = self.get_chore(chore_id)
if not chore:
raise ValueError(f"Chore {chore_id} not found")
if chore.task_type != "timed":
raise ValueError(f"Chore '{chore.name}' is not a timed task")
child = self.get_child(child_id)
if not child:
raise ValueError(f"Child {child_id} not found")
now = dt_util.now()
today = dt_util.as_local(now).date().isoformat()
existing = self.storage.get_active_timed_session(chore_id, child_id)
if existing and existing.state == "running":
raise ValueError("Timer is already running")
if existing and existing.state == "paused":
# Check daily cap before resuming
if chore.timed_max_daily_minutes > 0:
if existing.total_seconds_today >= chore.timed_max_daily_minutes * 60:
raise ValueError(
f"Daily cap reached ({chore.timed_max_daily_minutes} min)"
)
existing.state = "running"
existing.segments.append({"start": now.isoformat(), "end": None})
self.storage.save_timed_session(existing)
else:
# Check daily cap before starting fresh
if chore.timed_max_daily_minutes > 0:
old_session = self.storage.get_timed_session(chore_id, child_id, today)
if old_session and old_session.total_seconds_today >= chore.timed_max_daily_minutes * 60:
raise ValueError(
f"Daily cap reached ({chore.timed_max_daily_minutes} min)"
)
session = TimedSession(
chore_id=chore_id,
child_id=child_id,
state="running",
segments=[{"start": now.isoformat(), "end": None}],
total_seconds_today=0,
session_date=today,
)
self.storage.save_timed_session(session)
await self.storage.async_save()
await self.async_refresh()
async def async_pause_timed_task(self, chore_id: str, child_id: str) -> None:
"""Pause a running timed task session."""
session = self.storage.get_active_timed_session(chore_id, child_id)
if not session or session.state != "running":
raise ValueError("No running timer to pause")
now = dt_util.now()
if session.segments and session.segments[-1].get("end") is None:
session.segments[-1]["end"] = now.isoformat()
session.total_seconds_today = self._calc_session_seconds(session)
session.state = "paused"
self.storage.save_timed_session(session)
await self.storage.async_save()
await self.async_refresh()
async def async_stop_timed_task(self, chore_id: str, child_id: str) -> None:
"""Stop a timed task session and create a completion."""
session = self.storage.get_active_timed_session(chore_id, child_id)
if not session:
raise ValueError("No active timer to stop")
chore = self.get_chore(chore_id)
if not chore:
raise ValueError(f"Chore {chore_id} not found")
child = self.get_child(child_id)
if not child:
raise ValueError(f"Child {child_id} not found")
now = dt_util.now()
# Close running segment
if session.state == "running" and session.segments and session.segments[-1].get("end") is None:
session.segments[-1]["end"] = now.isoformat()
total_seconds = self._calc_session_seconds(session)
# Clamp to daily cap
if chore.timed_max_daily_minutes > 0:
max_seconds = chore.timed_max_daily_minutes * 60
total_seconds = min(total_seconds, max_seconds)
# Calculate points. Guard against a mis-configured zero rate, which
# would otherwise raise ZeroDivisionError and wedge the session.
rate_seconds = chore.timed_rate_minutes * 60
pts = (total_seconds // rate_seconds) * chore.timed_rate_points if rate_seconds > 0 else 0
completion = ChoreCompletion(
chore_id=chore_id,
child_id=child_id,
completed_at=now,
approved=not chore.requires_approval,
points_awarded=pts if not chore.requires_approval else 0,
timed_duration_seconds=total_seconds,
)
if not chore.requires_approval:
total_awarded = await self._award_points(child, pts)
completion.approved = True
completion.approved_at = dt_util.now()
completion.points_awarded = total_awarded
self.storage.add_completion(completion)
self.storage.set_last_completed(chore_id, child_id, now.isoformat())
self.storage.remove_timed_session(session.id)
await self.storage.async_save()
if chore.requires_approval:
await self._async_notify_pending_approval(
child.name, chore.name, pts, completion_id=completion.id,
)
await self.async_refresh()
def _calc_session_seconds(self, session: TimedSession) -> int:
"""Calculate total elapsed seconds from session segments."""
total = 0
for seg in session.segments:
start_str = seg.get("start")
end_str = seg.get("end")
if not start_str:
continue
try:
start_dt = datetime.fromisoformat(start_str)
end_dt = datetime.fromisoformat(end_str) if end_str else dt_util.now()
diff = (end_dt - start_dt).total_seconds()
if diff > 0:
total += int(diff)
except (ValueError, TypeError):
continue
return total
async def _async_stop_stale_timed_sessions(self) -> None:
"""Auto-stop any timed sessions from a previous day (midnight cleanup)."""
today = dt_util.as_local(dt_util.now()).date().isoformat()
sessions = self.storage.get_timed_sessions()
stale = [s for s in sessions if s.session_date != today and s.state in ("running", "paused")]
for session in stale:
chore = self.get_chore(session.chore_id)
child = self.get_child(session.child_id)
if not chore or not child:
self.storage.remove_timed_session(session.id)
continue
# Close any open segment at midnight (tz-aware so it can be
# subtracted from the tz-aware segment start)
if session.segments and session.segments[-1].get("end") is None:
midnight = dt_util.start_of_local_day()
session.segments[-1]["end"] = midnight.isoformat()
total_seconds = self._calc_session_seconds(session)
if chore.timed_max_daily_minutes > 0:
total_seconds = min(total_seconds, chore.timed_max_daily_minutes * 60)
rate_seconds = chore.timed_rate_minutes * 60
pts = (total_seconds // rate_seconds) * chore.timed_rate_points if rate_seconds > 0 else 0
if total_seconds > 0:
completion = ChoreCompletion(
chore_id=session.chore_id,
child_id=session.child_id,
completed_at=dt_util.now(),
approved=not chore.requires_approval,
points_awarded=pts if not chore.requires_approval else 0,
timed_duration_seconds=total_seconds,
)
if not chore.requires_approval:
total_awarded = await self._award_points(child, pts)
completion.approved = True
completion.approved_at = dt_util.now()
completion.points_awarded = total_awarded
self.storage.add_completion(completion)
self.storage.remove_timed_session(session.id)
if stale:
await self.storage.async_save()
await self.async_refresh()
async def _async_auto_stop_capped_sessions(self) -> None:
"""Check running sessions against daily cap and auto-stop if exceeded."""
sessions = self.storage.get_timed_sessions()
for session in sessions:
if session.state != "running":
continue
chore = self.get_chore(session.chore_id)
if not chore or chore.timed_max_daily_minutes <= 0:
continue
elapsed = self._calc_session_seconds(session)
if elapsed >= chore.timed_max_daily_minutes * 60:
await self.async_stop_timed_task(session.chore_id, session.child_id)
+796
View File
@@ -0,0 +1,796 @@
"""Data coordinator for TaskMate integration."""
from __future__ import annotations
import logging
import random
from collections.abc import Callable
from contextlib import contextmanager
from datetime import date, datetime, timedelta
from typing import Any
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.event import async_track_time_change
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.util import dt as dt_util
from . import photos
from .const import DOMAIN
from .coord_assignments import AssignmentsMixin
from .coord_avatars import AvatarsMixin
from .coord_badges import BadgeCoordinator
from .coord_calendar import CalendarMixin
from .coord_challenges import ChallengesMixin
from .coord_chores import ChoresMixin
from .coord_mandatory import MandatoryMixin
from .coord_notifications import NotificationCoordinator
from .coord_points import PointsMixin
from .coord_quests import QuestsMixin
from .coord_rewards import RewardsMixin
from .coord_templates import TemplatesMixin
from .coord_timed import TimedMixin
from .models import Child
from .storage import TaskMateStorage
_LOGGER = logging.getLogger(__name__)
class TaskMateCoordinator(
ChoresMixin,
MandatoryMixin,
AssignmentsMixin,
RewardsMixin,
PointsMixin,
QuestsMixin,
AvatarsMixin,
ChallengesMixin,
TimedMixin,
CalendarMixin,
TemplatesMixin,
DataUpdateCoordinator,
):
"""Coordinator to manage TaskMate data."""
def __init__(self, hass: HomeAssistant, entry_id: str) -> None:
"""Initialize coordinator."""
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=timedelta(seconds=30),
)
self.storage = TaskMateStorage(hass, entry_id)
self.notifications = NotificationCoordinator(hass, self.storage)
self.badges = BadgeCoordinator(hass, self.storage, self, self.notifications)
self.entry_id = entry_id
self._unsub_midnight: Callable[[], None] | None = None
self._unsub_prune: Callable[[], None] | None = None
self._unsub_availability: Callable[[], None] | None = None
self._tracked_availability_entities: set[str] = set()
self._unsub_surprise: Callable[[], None] | None = None
self._unsub_weekly: Callable[[], None] | None = None
self._unsub_mandatory: list[Callable[[], None]] = []
# Per-build memo for the availability matrix (PERF-1). Non-None only
# inside `availability_build_scope()`; see coord_chores/coord_assignments.
self._avail_cache: dict | None = None
# (data_version, snapshot) cache for _async_update_data (PERF-2).
self._data_snapshot_cache: tuple[int, dict[str, Any]] | None = None
def difficulty_multiplier(self, tier: str) -> float:
"""Return the points multiplier for a difficulty tier.
Unknown tiers fall back to the neutral "medium" baseline (×1.0).
Per-tier multipliers are configurable via the
``difficulty_multiplier_<tier>`` settings keys.
"""
from .const import DEFAULT_DIFFICULTY, DEFAULT_DIFFICULTY_MULTIPLIERS
resolved = tier if tier in DEFAULT_DIFFICULTY_MULTIPLIERS else DEFAULT_DIFFICULTY
default = DEFAULT_DIFFICULTY_MULTIPLIERS[resolved]
try:
return float(
self.storage.get_setting(
f"difficulty_multiplier_{resolved}", str(default)
)
)
except (ValueError, TypeError):
return default
def effective_chore_points(self, chore) -> int:
"""Base chore points scaled by its difficulty multiplier (never negative)."""
from .const import DEFAULT_DIFFICULTY
base = int(getattr(chore, "points", 0) or 0)
tier = getattr(chore, "difficulty", DEFAULT_DIFFICULTY) or DEFAULT_DIFFICULTY
return max(0, round(base * self.difficulty_multiplier(tier)))
# ── Vacation / pause mode ────────────────────────────────────────────
# A vacation period is a date range during which chores are hidden/paused
# and streaks are frozen (missed days inside a vacation never break a
# streak). Stored as the "vacation_periods" setting: a list of
# {"id", "name", "start", "end"} with inclusive ISO "YYYY-MM-DD" bounds.
def get_vacation_periods(self) -> list[dict]:
"""Return the configured vacation periods (validated, sorted by start)."""
raw = self.storage.get_setting("vacation_periods", None)
if not isinstance(raw, list):
return []
periods = []
for entry in raw:
if not isinstance(entry, dict):
continue
try:
start = date.fromisoformat(str(entry.get("start")))
end = date.fromisoformat(str(entry.get("end")))
except (TypeError, ValueError):
continue
if end < start:
start, end = end, start
periods.append({
"id": str(entry.get("id") or "").strip() or start.isoformat(),
"name": str(entry.get("name") or "").strip(),
"start": start.isoformat(),
"end": end.isoformat(),
})
return sorted(periods, key=lambda p: p["start"])
def active_vacation(self, on: date | None = None) -> dict | None:
"""Return the vacation period covering ``on`` (default today), or None."""
day = on or dt_util.now().date()
for p in self.get_vacation_periods():
try:
if date.fromisoformat(p["start"]) <= day <= date.fromisoformat(p["end"]):
return p
except (TypeError, ValueError):
continue
return None
def is_vacation_day(self, on: date | None = None) -> bool:
"""True if ``on`` (default today) falls within any vacation period."""
return self.active_vacation(on) is not None
def _vacation_calendar_active(self) -> bool:
"""True if the global family vacation calendar entity currently has an
active event. The ``vacation_calendar`` setting holds a ``calendar.*``
(or any on/off) entity id; empty = disabled. Fail-open: a missing or
broken entity does not freeze anyone.
"""
entity_id = (self.storage.get_setting("vacation_calendar", "") or "").strip()
if not entity_id:
return False
return self._read_entity_active(entity_id) is True
@contextmanager
def availability_build_scope(self):
"""Memoize chore-only computations for one availability build (PERF-1).
The availability matrix calls ``is_chore_available_for_child`` for every
chore × child. Several inner computations depend only on the chore (the
rotation active-set, the rotation-done check) or are re-fetched from
storage repeatedly (the completions list, which ``get_completions``
rebuilds from dicts on each call). Inside this scope those are computed
once and cached. The build is fully synchronous (no awaits), so storage
cannot mutate while the cache is live. Re-entrant scopes reuse the cache.
"""
if getattr(self, "_avail_cache", None) is not None:
yield # already inside a scope — reuse the existing cache
return
self._avail_cache = {
"completions": self.storage.get_completions(),
"active": {},
"rotation_done": {},
}
try:
yield
finally:
self._avail_cache = None
def _cached_completions(self) -> list:
"""Completions list, served from the availability cache when in scope."""
cache = getattr(self, "_avail_cache", None)
if cache is not None:
return cache["completions"]
return self.storage.get_completions()
def _is_child_on_vacation(self, child, on: date | None = None) -> bool:
"""True when ``child`` should be treated as away (streak frozen, chores
hidden) on ``on`` (default today). Three stacking sources, OR'd:
1. a global static ``vacation_periods`` range covers the day;
2. the global ``vacation_calendar`` entity has an active event now;
3. this child opted in (``pause_streak_when_unavailable``) and their
own availability/unavailability sensor — which may be a
``calendar.*`` entity — currently reports them away.
Sources 2 and 3 are evaluated live (current entity state); source 1 is
historical date math. ``child`` may be ``None`` (callers that only have
a global context), in which case only sources 1 and 2 apply.
"""
if self.is_vacation_day(on):
return True
if self._vacation_calendar_active():
return True
if child is not None and getattr(child, "pause_streak_when_unavailable", False):
if not self._is_child_available(child.id):
return True
return False
# ── Backup / restore ─────────────────────────────────────────────────
EXPORT_VERSION = 1
def export_config(self) -> dict:
"""Return a portable backup of all TaskMate data."""
return {
"taskmate_export_version": self.EXPORT_VERSION,
"data": self.storage.export_data(),
}
async def async_import_config(self, payload: dict) -> None:
"""Restore TaskMate data from an export payload (full replace)."""
if not isinstance(payload, dict) or not isinstance(payload.get("data"), dict):
raise ValueError("Invalid TaskMate export payload")
self.storage.import_data(payload["data"])
await self.storage.async_save()
await self.async_refresh()
# ── Admin audit log ──────────────────────────────────────────────────
async def async_record_audit(
self, user_id: str, user_name: str, action: str, target: str = ""
) -> None:
"""Record an admin config action in the audit log and persist it."""
from .models import generate_id
self.storage.add_audit_entry({
"id": generate_id(),
"ts": dt_util.now().isoformat(),
"user_id": user_id or "",
"user_name": user_name or "",
"action": action,
"target": target or "",
})
await self.storage.async_save()
async def async_initialize(self) -> None:
"""Initialize the coordinator."""
await self.storage.async_load()
self.notifications.coordinator = self
# Wire existing parent recipients to default-on parent-audience types
# that have no routes yet (e.g. a parent added after the one-time seed),
# so pending-approval notifications actually reach a parent.
if self.notifications.ensure_parent_default_routes():
await self.storage.async_save()
# Achievement badges: silent retroactive backfill on first install
if self.storage.is_badges_backfill_pending():
await self.badges.rebuild_all()
self.storage.clear_badges_backfill_pending()
await self.storage.async_save()
await self._async_backfill_career_history()
await self._async_stop_stale_timed_sessions()
await self.async_refresh()
# Schedule midnight streak check at 00:00:05
self._unsub_midnight = async_track_time_change(
self.hass, self._async_midnight_streak_check, hour=0, minute=0, second=5
)
# Schedule daily history pruning at 00:01:00
self._unsub_prune = async_track_time_change(
self.hass, self._async_scheduled_prune, hour=0, minute=1, second=0
)
# Re-evaluate availability-aware chore assignments when any HA entity
# state changes. The callback filters cheaply on entity id so only
# relevant flips trigger a recompute.
self._refresh_tracked_availability_entities()
self._unsub_availability = self.hass.bus.async_listen(
"state_changed", self._availability_state_changed
)
# Surprise-bonus daily roll at 16:00 (opt-in; no-op unless enabled)
self._unsub_surprise = async_track_time_change(
self.hass, self._async_surprise_bonus_check, hour=16, minute=0, second=0
)
# Weekly digest — fired Sundays at 18:00 (opt-in)
self._unsub_weekly = async_track_time_change(
self.hass, self._async_weekly_digest_check, hour=18, minute=0, second=0
)
await self.notifications.async_setup_schedules()
# Mandatory-chore period-end detection (#532)
self.arm_mandatory_schedules()
await self.async_catchup_mandatory_misses()
@callback
def _async_weekly_digest_check(self, now: datetime) -> None:
"""Daily 18:00 callback; fires the weekly digest on Sundays and the
monthly report on the 1st of the month (FEAT-14)."""
if now.weekday() == 6: # Sunday
self.hass.async_create_task(self._async_send_weekly_digest())
if now.day == 1:
self.hass.async_create_task(self._async_send_monthly_report())
self.hass.async_create_task(self._async_finalize_season())
async def _async_send_weekly_digest(self) -> None:
"""Build and send the weekly digest to parents (opt-in)."""
summary = self._build_weekly_digest()
if not summary:
return
await self.notifications.fire("weekly_digest", {"summary": summary})
def _build_weekly_digest(self) -> str:
"""One line per child: chores done + points earned this week."""
today = dt_util.now().date()
week_start = today - timedelta(days=today.weekday())
children = self.storage.get_children()
if not children:
return ""
done: dict[str, int] = {}
earned: dict[str, int] = {}
for comp in self.storage.get_completions():
if not comp.approved or comp.bonus_subtask_id:
continue
if dt_util.as_local(comp.completed_at).date() < week_start:
continue
done[comp.child_id] = done.get(comp.child_id, 0) + 1
earned[comp.child_id] = earned.get(comp.child_id, 0) + (comp.points_awarded or 0)
pts = self.storage.get_points_name()
lines = [
f"{c.name}: {done.get(c.id, 0)} chores, {earned.get(c.id, 0)} {pts} earned"
for c in children
]
return "\n".join(lines)
async def _async_send_monthly_report(self) -> None:
"""Build and send the previous calendar month's per-child recap (FEAT-14)."""
today = dt_util.now().date()
month_end = today.replace(day=1) - timedelta(days=1)
month_start = month_end.replace(day=1)
summary = self._build_monthly_report(month_start, month_end)
if not summary:
return
await self.notifications.fire("monthly_report", {
"summary": summary,
"month": month_start.strftime("%B %Y"),
})
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."""
children = self.storage.get_children()
if not children:
return ""
done: dict[str, int] = {}
earned: dict[str, int] = {}
for comp in self.storage.get_completions():
if not comp.approved or comp.bonus_subtask_id:
continue
d = dt_util.as_local(comp.completed_at).date()
if not (month_start <= d <= month_end):
continue
done[comp.child_id] = done.get(comp.child_id, 0) + 1
earned[comp.child_id] = earned.get(comp.child_id, 0) + (comp.points_awarded or 0)
pts = self.storage.get_points_name()
lines = [
f"{c.name}: {done.get(c.id, 0)} chores, {earned.get(c.id, 0)} {pts}, "
f"level {getattr(c, 'level', 1)}, best streak {getattr(c, 'best_streak', 0)}"
for c in children
]
return "\n".join(lines)
# ── Leaderboard seasons (FEAT-2) ─────────────────────────────────────
def get_season_standings(self, ym: str | None = None) -> list[dict]:
"""Per-child points earned in calendar month ``ym`` (default current),
ranked high→low (ties broken by name)."""
if ym is None:
ym = dt_util.now().strftime("%Y-%m")
pts = self.storage.get_season_points(ym)
rows = [
{"child_id": c.id, "name": c.name, "points": int(pts.get(c.id, 0))}
for c in self.storage.get_children()
]
rows.sort(key=lambda r: (-r["points"], r["name"].lower()))
for i, r in enumerate(rows):
r["rank"] = i + 1
return rows
# ── Family co-op goal (FEAT-4) ───────────────────────────────────────
def family_goal_progress(self) -> int:
"""Combined current points across all children (the shared goal metric)."""
return sum(c.points for c in self.storage.get_children())
async def _async_check_family_goal(self) -> None:
"""Fire the family-goal notification once the combined points reach the
target. Idempotent via the family_goal_achieved flag (reset when the
target/enabled setting changes)."""
if not self._setting_enabled("family_goal_enabled"):
return
if self.storage.get_setting("family_goal_achieved", False) in (True, "true"):
return
try:
target = int(self.storage.get_setting("family_goal_target", 0))
except (TypeError, ValueError):
target = 0
if target < 1 or self.family_goal_progress() < target:
return
self.storage.set_setting("family_goal_achieved", True)
await self.storage.async_save()
name = str(self.storage.get_setting("family_goal_name", "") or "Family goal")
reward = str(self.storage.get_setting("family_goal_reward", "") or "a treat")
self.hass.bus.async_fire("taskmate_family_goal_reached", {
"goal_name": name, "goal_reward": reward, "target": target,
"timestamp": dt_util.now().isoformat(),
})
await self.notifications.fire("family_goal_reached", {
"goal_name": name, "goal_reward": reward,
})
# ── Allowance payout ledger (FEAT-3) ─────────────────────────────────
async def async_record_allowance_payout(self, child_id: str, points: int) -> dict:
"""Record a parent-confirmed allowance payout: deduct ``points`` and log
the cash equivalent (fixed conversion rate — no dynamic pricing).
Returns the ledger entry. Raises ValueError on a bad child/points or when
allowance is disabled.
"""
child = self.get_child(child_id)
if child is None:
raise ValueError(f"Child {child_id} not found")
points = int(points)
if points < 1:
raise ValueError("points must be >= 1")
if not self._setting_enabled("allowance_enabled"):
raise ValueError("Allowance is not enabled")
try:
rate = int(self.storage.get_setting("allowance_rate", 10))
except (TypeError, ValueError):
rate = 10
rate = max(1, rate)
currency = str(self.storage.get_setting("allowance_currency", "") or "")
amount = round(points / rate, 2)
await self.async_remove_points(child_id, points, reason="Allowance payout")
from .models import generate_id
entry = {
"id": generate_id(),
"child_id": child_id,
"child_name": child.name,
"points": points,
"amount": amount,
"currency": currency,
"date": dt_util.now().isoformat(),
}
self.storage.add_allowance_payout(entry)
await self.storage.async_save()
self.hass.bus.async_fire("taskmate_allowance_paid", {**entry, "timestamp": entry["date"]})
await self.async_refresh()
return entry
# ── ICS calendar feed token (FEAT-10) ────────────────────────────────
async def async_get_or_create_ics_token(self) -> str:
"""Return the ICS feed token, generating + persisting one on first use."""
token = self.storage.get_setting("ics_token", "")
if not token:
import secrets
token = secrets.token_urlsafe(24)
self.storage.set_setting("ics_token", token)
await self.storage.async_save()
return token
async def async_regenerate_ics_token(self) -> str:
"""Rotate the ICS feed token (invalidates existing subscriptions)."""
import secrets
token = secrets.token_urlsafe(24)
self.storage.set_setting("ics_token", token)
await self.storage.async_save()
return token
async def _async_finalize_season(self) -> None:
"""At month start, record the previous month's champion (top earner)."""
now = dt_util.now()
prev_end = now.date().replace(day=1) - timedelta(days=1)
ym = prev_end.strftime("%Y-%m")
if any(c.get("month") == ym for c in self.storage.get_season_champions()):
return # already finalised
winners = [r for r in self.get_season_standings(ym) if r["points"] > 0]
if not winners:
return
top = winners[0]
self.storage.add_season_champion({
"month": ym,
"child_id": top["child_id"],
"child_name": top["name"],
"points": top["points"],
})
await self.storage.async_save()
self.hass.bus.async_fire("taskmate_season_champion", {
"month": ym, "child_id": top["child_id"], "child_name": top["name"],
"points": top["points"], "timestamp": now.isoformat(),
})
await self.notifications.fire("season_champion", {
"child_name": top["name"], "points": top["points"],
"month": prev_end.strftime("%B %Y"),
"points_name": self.storage.get_points_name(),
})
@callback
def _async_surprise_bonus_check(self, now: datetime) -> None:
"""Scheduled callback — roll the daily surprise bonus."""
self.hass.async_create_task(self._async_run_surprise_bonus())
async def _async_run_surprise_bonus(self) -> None:
"""Each enabled day, give each child a random chance at a surprise bonus.
Opt-in via the ``surprise_bonus_enabled`` setting. Per child, rolls
``surprise_bonus_chance`` percent; on a hit awards a random amount in
[min, max], logged as a normal points transaction, and fires a
``taskmate_surprise_bonus`` event for automations.
"""
enabled = self.storage.get_setting("surprise_bonus_enabled", False)
if not (enabled is True or str(enabled).lower() == "true"):
return
try:
chance = float(self.storage.get_setting("surprise_bonus_chance", "15"))
except (ValueError, TypeError):
chance = 15.0
try:
lo = int(float(self.storage.get_setting("surprise_bonus_min", "5")))
hi = int(float(self.storage.get_setting("surprise_bonus_max", "20")))
except (ValueError, TypeError):
lo, hi = 5, 20
if hi < lo:
lo, hi = hi, lo
for child in self.storage.get_children():
if random.random() * 100.0 >= chance:
continue
pts = random.randint(lo, hi)
if pts <= 0:
continue
await self.async_add_points(child.id, pts, reason="Surprise bonus 🎉")
self.hass.bus.async_fire("taskmate_surprise_bonus", {
"child_id": child.id, "child_name": child.name,
"points": pts, "timestamp": dt_util.now().isoformat(),
})
async def _async_backfill_career_history(self) -> None:
"""Backfill career_score_history from completions and transactions.
Runs once on startup for children whose history is sparse (fewer than
7 entries). Uses all stored completions and transactions — not the
capped sensor attributes — so coverage matches the 90-day retention.
"""
children = self.storage.get_children()
if not children:
return
needs_save = False
completions = self.storage.get_completions()
transactions = self.storage.get_points_transactions()
chore_lookup = {ch.id: ch for ch in self.storage.get_chores()}
for child in children:
existing = self.storage.get_career_score_history(child.id)
if len(existing) >= 7:
continue
daily_net: dict[str, int] = {}
for comp in completions:
if comp.child_id != child.id or not comp.approved:
continue
day = comp.completed_at.date().isoformat()
pts = comp.points_awarded
if not pts:
chore = chore_lookup.get(comp.chore_id)
pts = chore.points if chore else 0
daily_net[day] = daily_net.get(day, 0) + pts
for txn in transactions:
if txn.child_id != child.id:
continue
day = txn.created_at.date().isoformat()
daily_net[day] = daily_net.get(day, 0) + txn.points
if not daily_net:
continue
sorted_days = sorted(daily_net.keys())
total_net = sum(daily_net.values())
start_score = (child.career_score or 0) - total_net
running = start_score
for day in sorted_days:
running += daily_net[day]
self.storage.append_career_score_snapshot(
child.id, day, running
)
needs_save = True
_LOGGER.info(
"Backfilled %d career history entries for %s",
len(sorted_days), child.name,
)
if needs_save:
await self.storage.async_save()
async def async_shutdown(self) -> None:
"""Shutdown the coordinator and clean up listeners."""
if self._unsub_midnight:
self._unsub_midnight()
self._unsub_midnight = None
if self._unsub_prune:
self._unsub_prune()
self._unsub_prune = None
if self._unsub_availability:
self._unsub_availability()
self._unsub_availability = None
if self._unsub_surprise:
self._unsub_surprise()
self._unsub_surprise = None
if self._unsub_weekly:
self._unsub_weekly()
self._unsub_weekly = None
self.disarm_mandatory_schedules()
# Flush any pending debounced save so an entry unload/reload can't drop
# the last mutation (PERF-3).
await self.storage.async_save_now()
@callback
def _async_midnight_streak_check(self, now: datetime) -> None:
"""Scheduled callback at midnight to run all daily maintenance."""
self.hass.async_create_task(self._async_run_midnight_maintenance(now))
async def _async_run_midnight_maintenance(self, now: datetime) -> None:
"""Run the midnight maintenance steps sequentially.
A single task (rather than one task per step) so the read-modify-write
steps on shared storage can't interleave and overwrite each other's
saves. One step failing must not stop the rest.
"""
steps = [
self._async_check_streaks,
self._async_expire_one_shot_chores,
self._async_expire_dated_chores,
self._async_restock_rewards,
self._async_expire_rewards,
self._async_decay_points,
self._async_apply_interest,
self._async_stop_stale_timed_sessions,
# Rotate assignment_current_child_id and publish today's events
# to every configured calendar
self._async_refresh_assignments_and_publish,
# Mandatory 'anytime' chores + postpone-map reset (#532)
self.async_detect_anytime_mandatory_misses,
self.async_prune_orphan_misses,
self._async_sweep_orphan_photos,
]
# Check for perfect week bonus every Monday at midnight
if now.weekday() == 0:
steps.append(self._async_check_perfect_week)
for step in steps:
try:
await step()
except Exception: # noqa: BLE001
_LOGGER.exception("Midnight maintenance step %s failed", step.__name__)
# Prune all-chores-done daily flags older than today
self.storage.prune_all_done_flags(dt_util.now().date().isoformat())
await self.storage.async_save()
async def _async_sweep_orphan_photos(self) -> None:
"""Delete evidence photos not referenced by any completion (SEC-2)."""
referenced = [
getattr(c, "photo_url", "")
for c in self.storage.get_completions()
if getattr(c, "photo_url", "")
]
removed = await photos.async_sweep_orphan_photos(self.hass, referenced)
if removed:
_LOGGER.info("Swept %d orphan evidence photo(s)", removed)
@callback
def _async_scheduled_prune(self, now: datetime) -> None:
"""Scheduled callback to prune old completion history."""
days = int(self.storage.get_setting("history_days", "90"))
self.hass.async_create_task(self.async_prune_history(days))
async def _async_update_data(self) -> dict[str, Any]:
"""Fetch data from storage.
PERF-2: rebuilding every child/chore/completion from its stored dict on
each 30 s poll is wasteful when nothing changed. Cache the built dict
keyed by ``storage.data_version`` (bumped on every save) and reuse it
until the next mutation. Availability is recomputed in the sensor layer
from live entity state, not from this dict, so a stale-version reuse
never staleness-bugs availability — listeners still re-read each tick.
"""
# May stop sessions (mutates + saves -> bumps the version), so run first.
await self._async_auto_stop_capped_sessions()
await self._async_check_family_goal()
self._refresh_tracked_availability_entities()
version = self.storage.data_version
cached = getattr(self, "_data_snapshot_cache", None)
if cached is not None and cached[0] == version:
return cached[1]
snapshot = self._build_data_snapshot()
self._data_snapshot_cache = (version, snapshot)
return snapshot
def _build_data_snapshot(self) -> dict[str, Any]:
return {
"children": self.storage.get_children(),
"chores": self.storage.get_chores(),
"rewards": self.storage.get_rewards(),
"completions": self.storage.get_completions(),
"pending_completions": self.storage.get_pending_completions(),
"reward_claims": self.storage.get_reward_claims(),
"pending_reward_claims": self.storage.get_pending_reward_claims(),
"points_transactions": self.storage.get_points_transactions(),
"points_name": self.storage.get_points_name(),
"points_icon": self.storage.get_points_icon(),
"settings": self.storage.get_settings(),
"penalties": self.storage.get_penalties(),
"bonuses": self.storage.get_bonuses(),
"pool_allocations": self.storage.get_pool_allocations(),
"timed_sessions": self.storage.get_timed_sessions(),
}
# Child operations
async def async_add_child(
self,
name: str,
avatar: str = "mdi:account-circle",
availability_entity: str = "",
availability_inverted: bool = False,
unavailability_entity: str = "",
pause_streak_when_unavailable: bool = False,
linked_user_id: str = "",
) -> Child:
"""Add a new child."""
child = Child(
name=name,
avatar=avatar,
availability_entity=availability_entity,
availability_inverted=availability_inverted,
unavailability_entity=unavailability_entity,
pause_streak_when_unavailable=pause_streak_when_unavailable,
linked_user_id=linked_user_id,
)
self.storage.add_child(child)
await self.storage.async_save()
await self.async_refresh()
return child
async def async_update_child(self, child: Child) -> None:
"""Update a child."""
self.storage.update_child(child)
await self.storage.async_save()
await self.async_refresh()
async def async_remove_child(self, child_id: str) -> None:
"""Remove a child and all associated data."""
self.storage.remove_child(child_id)
self.storage.remove_completions_for_child(child_id)
self.storage.remove_reward_claims_for_child(child_id)
self.storage.remove_transactions_for_child(child_id)
self.storage.remove_last_completed_for_child(child_id)
self.storage.remove_pool_allocations_for_child(child_id)
self.storage.remove_career_score_history_for_child(child_id)
self.storage.remove_quest_progress_for_child(child_id)
self.storage.remove_challenge_progress_for_child(child_id)
# Remove child from chore assigned_to lists
for chore in self.storage.get_chores():
if child_id in chore.assigned_to:
chore.assigned_to.remove(child_id)
self.storage.update_chore(chore)
await self.storage.async_save()
await self.async_refresh()
def get_child(self, child_id: str) -> Child | None:
"""Get a child by ID."""
return self.storage.get_child(child_id)
async def async_set_setting(self, key: str, value: str) -> None:
"""Update a generic setting."""
self.storage.set_setting(key, value)
await self.storage.async_save()
await self.async_refresh()
# Settings
async def async_set_points_settings(self, name: str, icon: str) -> None:
"""Update points settings."""
self.storage.set_points_name(name)
self.storage.set_points_icon(icon)
await self.storage.async_save()
await self.async_refresh()
+245
View File
@@ -0,0 +1,245 @@
"""Frontend registration for TaskMate custom cards."""
from __future__ import annotations
import json
import logging
from pathlib import Path
from typing import Final
from homeassistant.components.frontend import add_extra_js_url
from homeassistant.components.http import StaticPathConfig
from homeassistant.core import HomeAssistant
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
# URL base path for serving static files
URL_BASE: Final = "/taskmate"
# Lovelace resources. Listed in load order: shared utilities first so that
# window.__taskmate_attrs / __taskmate_localize are reliably defined by the
# time card modules execute. Without this, a cold-cache load could render
# cards before the utility globals existed, producing empty chore lists and
# raw localisation keys for non-admin users.
CARDS: Final = [
"taskmate-attr-resolver.js",
"taskmate-localize.js",
"taskmate-design.js",
"taskmate-badges-card.js",
"taskmate-child-card.js",
"taskmate-rewards-card.js",
"taskmate-approvals-card.js",
"taskmate-points-card.js",
"taskmate-reorder-card.js",
"taskmate-overview-card.js",
"taskmate-activity-card.js",
"taskmate-streak-card.js",
"taskmate-weekly-card.js",
"taskmate-graph-card.js",
"taskmate-reward-progress-card.js",
"taskmate-leaderboard-card.js",
"taskmate-parent-dashboard-card.js",
"taskmate-penalties-card.js",
"taskmate-bonuses-card.js",
"taskmate-points-display-card.js",
"taskmate-calendar-card.js",
"taskmate-photo-gallery-card.js",
"taskmate-family-goal-card.js",
]
# Cards that USED to ship but were removed. Their files no longer exist, so any
# Lovelace resource still registered for them 404s on every dashboard load for
# users upgrading from a version that had them. We deregister these by EXACT
# URL only (never a heuristic diff — that once wiped every resource, which is
# why blanket stale-cleanup was removed from async_register_cards).
RETIRED_CARDS: Final = [
"taskmate-task-groups-card.js", # removed #452
"taskmate-templates-card.js", # removed #448
"taskmate-reminders-card.js", # removed #450
]
# JS modules loaded on every HA frontend page (config flow sound preview).
# taskmate-localize.js is ALSO listed in CARDS (Lovelace resources) so it loads
# early on dashboards; it must be here too because the admin panel (panel.py) is
# a panel_custom page, not a Lovelace dashboard, and never loads Lovelace
# resources — without this the panel renders raw i18n keys. Double-loading is a
# no-op: the module is keyed by URL (loaded once) and only assigns idempotent
# window.__taskmate_localize globals.
GLOBAL_MODULES: Final = [
"taskmate-config-sounds.js",
"taskmate-localize.js",
# Like localize.js above, the admin panel (a panel_custom page, not a
# Lovelace dashboard) never loads Lovelace resources, so the shared design
# layer must be loaded globally for window.__taskmate_design to exist when
# the panel resolves/stamps its design. Double-loading is a no-op (keyed by
# URL, assigns idempotent globals).
"taskmate-design.js",
]
# Track if frontend is registered
FRONTEND_REGISTERED: Final = "frontend_registered"
async def _async_get_version(hass: HomeAssistant) -> str:
"""Get version from manifest.json for cache busting (async-safe)."""
manifest_path = Path(__file__).parent / "manifest.json"
try:
content = await hass.async_add_executor_job(
manifest_path.read_text, "utf-8"
)
return json.loads(content).get("version", "1.0.0")
except (OSError, json.JSONDecodeError, AttributeError):
return "1.0.0"
async def async_register_frontend(hass: HomeAssistant) -> None:
"""Register static paths for serving card JavaScript files."""
# Only register once
if hass.data.get(DOMAIN, {}).get(FRONTEND_REGISTERED):
_LOGGER.debug("Frontend already registered, skipping")
return
www_path = Path(__file__).parent / "www"
if not www_path.exists():
_LOGGER.warning("www directory not found at %s", www_path)
return
# Register the www folder as a static path
await hass.http.async_register_static_paths(
[StaticPathConfig(URL_BASE, str(www_path), False)]
)
_LOGGER.debug("Registered static path: %s -> %s", URL_BASE, www_path)
# Authenticated upload/serve endpoints for chore evidence photos.
from .http_photos import async_register_photo_views
async_register_photo_views(hass)
# Token-gated ICS calendar feed (FEAT-10).
from .http_calendar import async_register_calendar_view
async_register_calendar_view(hass)
# Register global JS modules (loaded on all pages, including config flow)
version = await _async_get_version(hass)
for module in GLOBAL_MODULES:
module_url = f"{URL_BASE}/{module}?v={version}"
add_extra_js_url(hass, module_url)
_LOGGER.info("Registered global frontend module: %s", module_url)
# Mark as registered
hass.data.setdefault(DOMAIN, {})[FRONTEND_REGISTERED] = True
async def async_register_cards(hass: HomeAssistant) -> None:
"""Register and version-update TaskMate Lovelace resources on every startup.
Safety rules — this function will ONLY ever:
1. Add missing TaskMate cards (create)
2. Update the ?v= query string on existing TaskMate cards (update)
It will NEVER delete any resource. Stale cleanup is removed entirely
because URL mismatches caused accidental deletion of all resources.
Only URLs that begin with /taskmate/ are ever touched.
"""
version = await _async_get_version(hass)
_LOGGER.info("TaskMate resource manager: version=%s", version)
lovelace_data = hass.data.get("lovelace")
if lovelace_data is None:
_LOGGER.warning("TaskMate: Lovelace not available — skipping resource registration.")
return
mode = getattr(lovelace_data, "mode", "storage")
if mode == "yaml":
_LOGGER.info("TaskMate: Lovelace YAML mode — add resources manually:")
for card in CARDS:
_LOGGER.info(" - url: %s/%s?v=%s (type: module)", URL_BASE, card, version)
return
try:
resources = lovelace_data.resources
if resources is None:
_LOGGER.warning("TaskMate: Lovelace resources object not available.")
return
# Force load storage from disk BEFORE reading items.
# Without this, async_items() returns empty if storage hasn't been
# read yet — causing us to create duplicate entries which then get
# wiped when lovelace subsequently loads its own storage file.
#
# Browser Mod uses: resources.async_load() + resources.loaded flag
# WebRTC uses: resources.async_get_info()
# We use both as a belt-and-braces approach.
if hasattr(resources, "async_load"):
await resources.async_load()
if hasattr(resources, "async_get_info"):
await resources.async_get_info()
# Build a map of base_url (without ?v=...) -> full resource item
# ONLY for resources whose URL starts with /taskmate/
# Everything else is completely ignored
existing: dict[str, dict] = {}
all_items = list(resources.async_items())
_LOGGER.debug("TaskMate: total Lovelace resources = %d", len(all_items))
for item in all_items:
url = item.get("url", "")
base_url = url.split("?")[0]
if base_url.startswith(URL_BASE + "/"):
existing[base_url] = item
_LOGGER.debug("TaskMate: found existing resource: %s", url)
_LOGGER.info("TaskMate: found %d existing TaskMate resources", len(existing))
# Add missing cards or update version on existing ones
# NEVER delete anything
for card in CARDS:
card_url = f"{URL_BASE}/{card}"
versioned_url = f"{card_url}?v={version}"
if card_url not in existing:
# Card not registered yet — add it
await resources.async_create_item(
{"url": versioned_url, "res_type": "module"}
)
_LOGGER.info("TaskMate: added resource: %s", versioned_url)
else:
item = existing[card_url]
current_url = item.get("url", "")
if current_url != versioned_url:
# Version string changed — update it
await resources.async_update_item(
item["id"],
{"url": versioned_url, "res_type": "module"},
)
_LOGGER.info(
"TaskMate: updated resource: %s -> %s",
current_url, versioned_url,
)
else:
_LOGGER.debug("TaskMate: resource up to date: %s", versioned_url)
# Deregister retired cards by EXACT URL match only. This is the one
# delete we allow: each target is a specific /taskmate/<file>.js that no
# longer exists, looked up in the existing map we already built. No
# diffing of "unexpected" URLs, so it cannot cascade into deleting live
# resources the way the old blanket cleanup did.
if hasattr(resources, "async_delete_item"):
for retired in RETIRED_CARDS:
item = existing.get(f"{URL_BASE}/{retired}")
if item is None:
continue
try:
await resources.async_delete_item(item["id"])
_LOGGER.info(
"TaskMate: removed retired resource: %s", item.get("url")
)
except (AttributeError, KeyError, TypeError, OSError) as err:
_LOGGER.warning(
"TaskMate: could not remove retired resource %s: %s",
item.get("url"), err,
)
except (AttributeError, KeyError, TypeError, OSError) as err:
_LOGGER.error("TaskMate: error managing Lovelace resources: %s", err)
@@ -0,0 +1,76 @@
"""Token-gated ICS calendar feed view (FEAT-10).
A calendar app subscribing to a URL can't send an HA bearer token, so this view
is public-by-URL and authenticated by an unguessable per-instance token in the
``?token=`` query param (compared in constant time). It serves a read-only feed
of upcoming chores; no mutation is possible.
"""
from __future__ import annotations
import hmac
import logging
from datetime import timedelta
from http import HTTPStatus
from aiohttp import web
from homeassistant.components.http import HomeAssistantView
from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dt_util
from . import ics
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
CALENDAR_URL = "/api/taskmate/calendar.ics"
HTTP_CAL_REGISTERED = "calendar_http_registered"
def _get_coordinator(hass: HomeAssistant):
from .coordinator import TaskMateCoordinator
for value in hass.data.get(DOMAIN, {}).values():
if isinstance(value, TaskMateCoordinator):
return value
return None
class TaskMateCalendarFeedView(HomeAssistantView):
"""Serve an ICS feed of upcoming chores, gated by the instance token."""
url = CALENDAR_URL
name = "api:taskmate:calendar"
requires_auth = False
def __init__(self, hass: HomeAssistant) -> None:
self.hass = hass
async def get(self, request: web.Request) -> web.Response:
coordinator = _get_coordinator(self.hass)
if coordinator is None:
return web.Response(status=HTTPStatus.NOT_FOUND)
expected = coordinator.storage.get_setting("ics_token", "")
supplied = request.query.get("token", "")
if not expected or not supplied or not hmac.compare_digest(str(expected), supplied):
return web.Response(status=HTTPStatus.UNAUTHORIZED)
days = coordinator._calendar_projection_days()
start = dt_util.now().date()
end = start + timedelta(days=days)
events = ics.build_chore_events(coordinator, start, end)
body = ics.build_calendar(events, dt_util.now())
return web.Response(
body=body.encode("utf-8"),
content_type="text/calendar",
charset="utf-8",
headers={"Cache-Control": "private, max-age=3600"},
)
def async_register_calendar_view(hass: HomeAssistant) -> None:
"""Register the ICS feed view once."""
if hass.data.get(DOMAIN, {}).get(HTTP_CAL_REGISTERED):
return
hass.http.register_view(TaskMateCalendarFeedView(hass))
hass.data.setdefault(DOMAIN, {})[HTTP_CAL_REGISTERED] = True
_LOGGER.debug("Registered TaskMate calendar ICS view")
+141
View File
@@ -0,0 +1,141 @@
"""Authenticated HTTP endpoints for uploading and serving chore evidence photos.
Two views, both auth-gated by ``HomeAssistantView`` (so photos are never public):
* ``POST /api/taskmate/photo`` — upload one image, returns ``{"photo_url": ...}``
* ``GET /api/taskmate/photo/<name>`` — serve a stored image
Pure path/validation logic lives in :mod:`.photos` (unit-tested); this module is
the thin aiohttp wrapper, verified on the dev HA instance.
"""
from __future__ import annotations
import logging
import uuid
from http import HTTPStatus
from aiohttp import web
from homeassistant.components.http import HomeAssistantView
from homeassistant.core import HomeAssistant
from . import photos
_LOGGER = logging.getLogger(__name__)
HTTP_VIEWS_REGISTERED = "photo_http_registered"
class TaskMatePhotoUploadView(HomeAssistantView):
"""Receive a multipart image upload and store it under the config dir."""
url = photos.URL_PREFIX
name = "api:taskmate:photo:upload"
def __init__(self, hass: HomeAssistant) -> None:
self.hass = hass
async def post(self, request: web.Request) -> web.Response:
# Cheap pre-check on the declared length before reading the body.
if request.content_length and request.content_length > photos.MAX_UPLOAD_BYTES:
return self.json_message(
"File too large", HTTPStatus.REQUEST_ENTITY_TOO_LARGE
)
try:
reader = await request.multipart()
except (ValueError, AssertionError):
return self.json_message("Expected multipart form", HTTPStatus.BAD_REQUEST)
# Find the "file" part.
field = await reader.next()
while field is not None and field.name != "file":
field = await reader.next()
if field is None:
return self.json_message("No file provided", HTTPStatus.BAD_REQUEST)
# Stream the part, enforcing the size cap as we go.
data = bytearray()
while True:
chunk = await field.read_chunk()
if not chunk:
break
data.extend(chunk)
if len(data) > photos.MAX_UPLOAD_BYTES:
return self.json_message(
"File too large", HTTPStatus.REQUEST_ENTITY_TOO_LARGE
)
ext = photos.detect_image_ext(bytes(data))
if ext is None:
return self.json_message("Not a valid image", HTTPStatus.BAD_REQUEST)
# DoS guard: reject if the photo store is already at its disk budget.
used = await self.hass.async_add_executor_job(
photos.total_photos_bytes, self.hass
)
if used + len(data) > photos.MAX_TOTAL_BYTES:
return self.json_message(
"Photo storage full", HTTPStatus.INSUFFICIENT_STORAGE
)
name = f"{uuid.uuid4().hex}.{ext}"
directory = photos.photos_path(self.hass)
payload = bytes(data)
def _write() -> None:
directory.mkdir(parents=True, exist_ok=True)
(directory / name).write_bytes(payload)
try:
await self.hass.async_add_executor_job(_write)
except OSError as err:
_LOGGER.error("Failed to store evidence photo: %s", err)
return self.json_message(
"Could not store photo", HTTPStatus.INTERNAL_SERVER_ERROR
)
return self.json({"photo_url": f"{photos.URL_PREFIX}/{name}"})
class TaskMatePhotoServeView(HomeAssistantView):
"""Serve a stored evidence photo by its generated filename."""
url = photos.URL_PREFIX + "/{filename}"
name = "api:taskmate:photo:serve"
def __init__(self, hass: HomeAssistant) -> None:
self.hass = hass
async def get(self, request: web.Request, filename: str) -> web.Response:
if not photos.FILENAME_RE.match(filename):
return web.Response(status=HTTPStatus.NOT_FOUND)
path = photos.photos_path(self.hass) / filename
def _read() -> bytes | None:
try:
return path.read_bytes()
except (FileNotFoundError, OSError):
return None
data = await self.hass.async_add_executor_job(_read)
if data is None:
return web.Response(status=HTTPStatus.NOT_FOUND)
return web.Response(
body=data,
content_type=photos.content_type_for(filename),
headers={"Cache-Control": "private, max-age=31536000"},
)
def async_register_photo_views(hass: HomeAssistant) -> None:
"""Register the upload + serve views once."""
from .const import DOMAIN
if hass.data.get(DOMAIN, {}).get(HTTP_VIEWS_REGISTERED):
return
hass.http.register_view(TaskMatePhotoUploadView(hass))
hass.http.register_view(TaskMatePhotoServeView(hass))
hass.data.setdefault(DOMAIN, {})[HTTP_VIEWS_REGISTERED] = True
_LOGGER.debug("Registered TaskMate photo HTTP views")
+35
View File
@@ -0,0 +1,35 @@
<svg width="300" height="300" viewBox="0 0 300 300" xmlns="http://www.w3.org/2000/svg">
<!-- Flame body -->
<path d="M150 40
C200 90, 220 130, 200 180
C185 215, 160 240, 150 250
C140 240, 115 215, 100 180
C80 130, 100 90, 150 40 Z"
fill="#FF8C42"
stroke="#2E2E2E"
stroke-width="6"/>
<!-- Inner flame -->
<path d="M150 85
C175 115, 175 150, 150 185
C125 150, 125 115, 150 85 Z"
fill="#F9C74F"/>
<!-- Eyes -->
<circle cx="135" cy="150" r="6" fill="#2E2E2E"/>
<circle cx="165" cy="150" r="6" fill="#2E2E2E"/>
<!-- Smile -->
<path d="M135 170 Q150 180 165 170"
stroke="#2E2E2E"
stroke-width="4"
fill="none"/>
<!-- Checkmark -->
<path d="M120 210 L140 225 L180 195"
stroke="#2E2E2E"
stroke-width="6"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 951 B

+127
View File
@@ -0,0 +1,127 @@
"""ICS (iCalendar) feed generation for TaskMate chores (FEAT-10).
Pure-ish helpers that turn the chore calendar projection into an RFC 5545 feed a
calendar app (Google/Apple/Outlook) can subscribe to. Token auth + the HTTP view
live in ``http_calendar.py``; this module only builds text.
"""
from __future__ import annotations
import hashlib
from datetime import date, datetime, timedelta, timezone
PRODID = "-//TaskMate//Chores//EN"
def _escape(text: str) -> str:
"""Escape a value per RFC 5545 (backslash, comma, semicolon, newline)."""
return (
str(text)
.replace("\\", "\\\\")
.replace("\n", "\\n")
.replace(",", "\\,")
.replace(";", "\\;")
)
def _fold(line: str) -> str:
"""Fold a content line to <=75 octets with CRLF + space continuation."""
raw = line.encode("utf-8")
if len(raw) <= 75:
return line
out = []
while len(raw) > 75:
# Don't split a multibyte char: back off to a UTF-8 boundary.
cut = 75
while cut > 0 and (raw[cut] & 0xC0) == 0x80:
cut -= 1
out.append(raw[:cut].decode("utf-8"))
raw = raw[cut:]
out.append(raw.decode("utf-8"))
return "\r\n ".join(out)
def _dt_utc(dt: datetime) -> str:
return dt.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
def _date(d: date) -> str:
return d.strftime("%Y%m%d")
def make_uid(*parts: str) -> str:
digest = hashlib.sha1("|".join(parts).encode("utf-8")).hexdigest()[:20]
return f"{digest}@taskmate"
def build_calendar(events: list[dict], now: datetime, name: str = "TaskMate Chores") -> str:
"""Render ``events`` (dicts: uid, summary, description, start, end, all_day)
into an ICS document. ``start``/``end`` are dates (all-day) or aware datetimes."""
stamp = _dt_utc(now)
lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
f"PRODID:{PRODID}",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
f"X-WR-CALNAME:{_escape(name)}",
]
for ev in events:
lines.append("BEGIN:VEVENT")
lines.append(f"UID:{ev['uid']}")
lines.append(f"DTSTAMP:{stamp}")
if ev["all_day"]:
lines.append(f"DTSTART;VALUE=DATE:{_date(ev['start'])}")
lines.append(f"DTEND;VALUE=DATE:{_date(ev['end'])}")
else:
lines.append(f"DTSTART:{_dt_utc(ev['start'])}")
lines.append(f"DTEND:{_dt_utc(ev['end'])}")
lines.append(_fold(f"SUMMARY:{_escape(ev['summary'])}"))
if ev.get("description"):
lines.append(_fold(f"DESCRIPTION:{_escape(ev['description'])}"))
lines.append("END:VEVENT")
lines.append("END:VCALENDAR")
return "\r\n".join(lines) + "\r\n"
def build_chore_events(coordinator, start_day: date, end_day: date) -> list[dict]:
"""Project each child's scheduled chores over [start_day, end_day] into event dicts."""
from .calendar import _chore_applies_to_child, _chore_description
events: list[dict] = []
children = coordinator.storage.get_children()
chores = coordinator.storage.get_chores()
for child in children:
day = start_day
while day <= end_day:
if not coordinator._is_child_on_vacation(child, day):
for chore in chores:
if not _chore_applies_to_child(coordinator, chore, child.id, day):
continue
window = coordinator._time_category_window(
getattr(chore, "time_category", "anytime"), day
)
summary = f"{chore.name}{child.name}"
desc = _chore_description(chore)
if window is None:
events.append({
"uid": make_uid(chore.id, child.id, day.isoformat(), "allday"),
"summary": summary, "description": desc,
"start": day, "end": day + timedelta(days=1), "all_day": True,
})
else:
start_dt, end_dt = window
events.append({
"uid": make_uid(chore.id, child.id, day.isoformat(), "timed"),
"summary": summary, "description": desc,
"start": _ensure_aware(start_dt),
"end": _ensure_aware(end_dt), "all_day": False,
})
day += timedelta(days=1)
return events
def _ensure_aware(dt: datetime) -> datetime:
from homeassistant.util import dt as dt_util
if dt.tzinfo is None:
return dt.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE)
return dt
+104
View File
@@ -0,0 +1,104 @@
"""Home Assistant conversation intents for TaskMate (FEAT-12).
Registers intent handlers so a voice/text assistant can answer questions like
"how many chores does Malia have left?" or "how many stars does Alex have?".
The default conversation agent matches *sentences* to these intent names; ship
the example sentences in ``custom_sentences/<lang>/taskmate.yaml`` into your HA
config (see custom_sentences/README.md). The speech-building logic is kept in
pure helpers so it is unit-testable without the conversation stack.
"""
from __future__ import annotations
import logging
import voluptuous as vol
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import intent
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
INTENT_CHORES_LEFT = "TaskMateChoresLeft"
INTENT_POINTS = "TaskMatePoints"
def _get_coordinator(hass: HomeAssistant):
from .coordinator import TaskMateCoordinator
for value in hass.data.get(DOMAIN, {}).values():
if isinstance(value, TaskMateCoordinator):
return value
return None
def _find_child(coordinator, name: str):
"""Case-insensitive child lookup by name."""
if coordinator is None or not name:
return None
target = name.strip().lower()
for child in coordinator.storage.get_children():
if child.name.strip().lower() == target:
return child
return None
def chores_left_speech(coordinator, name: str) -> str:
"""Speech for 'how many chores does <name> have left'."""
child = _find_child(coordinator, name)
if child is None:
return f"I couldn't find anyone called {name}."
count = len(coordinator.get_due_chores_for_child(child.id))
if count == 0:
return f"{child.name} has finished all their chores. Nice work!"
if count == 1:
return f"{child.name} has 1 chore left today."
return f"{child.name} has {count} chores left today."
def points_speech(coordinator, name: str) -> str:
"""Speech for 'how many points does <name> have'."""
child = _find_child(coordinator, name)
if child is None:
return f"I couldn't find anyone called {name}."
points_name = coordinator.storage.get_points_name()
return f"{child.name} has {child.points} {points_name}."
class _TaskMateIntentBase(intent.IntentHandler):
slot_schema = {vol.Required("name"): cv.string}
def _speech(self, coordinator, name: str) -> str: # pragma: no cover - overridden
raise NotImplementedError
async def async_handle(self, intent_obj):
hass = intent_obj.hass
coordinator = _get_coordinator(hass)
name = ""
slot = (intent_obj.slots or {}).get("name") or {}
name = slot.get("value", "") if isinstance(slot, dict) else str(slot)
response = intent_obj.create_response()
response.async_set_speech(self._speech(coordinator, name))
return response
class ChoresLeftIntentHandler(_TaskMateIntentBase):
intent_type = INTENT_CHORES_LEFT
def _speech(self, coordinator, name: str) -> str:
return chores_left_speech(coordinator, name)
class PointsIntentHandler(_TaskMateIntentBase):
intent_type = INTENT_POINTS
def _speech(self, coordinator, name: str) -> str:
return points_speech(coordinator, name)
def async_setup_intents(hass: HomeAssistant) -> None:
"""Register TaskMate conversation intents (idempotent enough for reloads)."""
intent.async_register(hass, ChoresLeftIntentHandler())
intent.async_register(hass, PointsIntentHandler())
_LOGGER.debug("Registered TaskMate conversation intents")
+21
View File
@@ -0,0 +1,21 @@
{
"domain": "taskmate",
"name": "TaskMate",
"codeowners": [
"@tempus2016"
],
"config_flow": true,
"dependencies": [
"http",
"lovelace",
"frontend",
"panel_custom",
"websocket_api"
],
"documentation": "https://github.com/tempus2016/taskmate",
"integration_type": "service",
"iot_class": "calculated",
"issue_tracker": "https://github.com/tempus2016/taskmate/issues",
"requirements": [],
"version": "4.4.4"
}
File diff suppressed because it is too large Load Diff
+73
View File
@@ -0,0 +1,73 @@
"""Number platform — expose key numeric TaskMate settings as entities (FEAT-9).
Lets the points-bearing knobs be read and changed from automations/scripts and
the HA UI without a service call. Values are persisted through the same
settings store the panel uses, so panel and entity stay in sync.
"""
from __future__ import annotations
from homeassistant.components.number import NumberEntity, NumberMode
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import TaskMateCoordinator
# (setting_key, translation_key, min, max, step, default, icon)
_NUMBERS = [
("weekend_multiplier", "weekend_multiplier", 1.0, 5.0, 0.5, 2.0, "mdi:calendar-weekend"),
("perfect_week_bonus", "perfect_week_bonus", 0, 1000, 1, 50, "mdi:trophy-award"),
]
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up the TaskMate setting-number entities."""
coordinator: TaskMateCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(TaskMateSettingNumber(coordinator, entry, *cfg) for cfg in _NUMBERS)
class TaskMateSettingNumber(CoordinatorEntity, NumberEntity):
"""A single numeric TaskMate setting surfaced as a number entity."""
_attr_has_entity_name = True
_attr_mode = NumberMode.BOX
def __init__(self, coordinator, entry, key, tkey, mn, mx, step, default, icon) -> None:
super().__init__(coordinator)
self._entry = entry
self._key = key
self._default = default
self._attr_translation_key = tkey
self._attr_unique_id = f"{entry.entry_id}_setting_{key}"
self._attr_native_min_value = mn
self._attr_native_max_value = mx
self._attr_native_step = step
self._attr_icon = icon
@property
def device_info(self) -> DeviceInfo:
return DeviceInfo(
identifiers={(DOMAIN, self._entry.entry_id)},
name="TaskMate",
manufacturer="TaskMate",
model="Family Chore Manager",
)
@property
def native_value(self) -> float:
raw = self.coordinator.storage.get_setting(self._key, self._default)
try:
return float(raw)
except (TypeError, ValueError):
return float(self._default)
async def async_set_native_value(self, value: float) -> None:
stored = int(value) if float(value).is_integer() else value
self.coordinator.storage.set_setting(self._key, stored)
await self.coordinator.storage.async_save()
await self.coordinator.async_refresh()
+68
View File
@@ -0,0 +1,68 @@
"""Sidebar panel registration for the TaskMate admin UI."""
from __future__ import annotations
import json
import logging
from pathlib import Path
from typing import Final
from homeassistant.components import panel_custom
from homeassistant.core import HomeAssistant
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
PANEL_REGISTERED: Final = "panel_registered"
# URL slug must NOT collide with the /taskmate/ static-files prefix used by
# frontend.py — a bare GET /taskmate gets caught by the static handler trying
# to serve a directory and returns 403. Sidebar label is unaffected.
PANEL_URL_PATH: Final = "taskmate-admin"
PANEL_WEBCOMPONENT: Final = "taskmate-panel"
PANEL_MODULE_FILE: Final = "taskmate-panel.js"
async def _async_get_version(hass: HomeAssistant) -> str:
"""Read manifest.json version off the executor for cache busting."""
manifest_path = Path(__file__).parent / "manifest.json"
try:
content = await hass.async_add_executor_job(manifest_path.read_text, "utf-8")
return json.loads(content).get("version", "1.0.0")
except (OSError, json.JSONDecodeError, AttributeError):
return "1.0.0"
async def async_register_panel(hass: HomeAssistant) -> None:
"""Register the TaskMate sidebar panel.
Idempotent — running this on a second config-entry setup is a no-op.
The panel JS is served by frontend.async_register_frontend() out of the
integration's own www/ directory at /taskmate/.
"""
if hass.data.get(DOMAIN, {}).get(PANEL_REGISTERED):
_LOGGER.debug("TaskMate panel already registered, skipping")
return
version = await _async_get_version(hass)
module_url = f"/taskmate/{PANEL_MODULE_FILE}?v={version}"
try:
await panel_custom.async_register_panel(
hass,
webcomponent_name=PANEL_WEBCOMPONENT,
frontend_url_path=PANEL_URL_PATH,
sidebar_title="TaskMate",
sidebar_icon="mdi:checkbox-marked-circle-plus-outline",
module_url=module_url,
embed_iframe=False,
require_admin=True,
)
except ValueError as err:
# async_register_panel raises ValueError if the url_path is taken —
# treat as already-registered and move on.
_LOGGER.warning("TaskMate panel registration skipped: %s", err)
hass.data.setdefault(DOMAIN, {})[PANEL_REGISTERED] = True
return
hass.data.setdefault(DOMAIN, {})[PANEL_REGISTERED] = True
_LOGGER.info("Registered TaskMate sidebar panel at /%s (module: %s)", PANEL_URL_PATH, module_url)
+203
View File
@@ -0,0 +1,203 @@
"""Pure storage/validation helpers for chore evidence photos.
This module deliberately has NO Home Assistant HTTP / aiohttp imports so it can
be imported from the coordinator modules (for cleanup) and unit-tested without a
real HA install. The aiohttp views live in ``http_photos.py``.
Photos are stored as ``<32 hex>.<ext>`` under ``<config>/taskmate_photos`` and
served (auth-gated) at ``/api/taskmate/photo/<name>``.
"""
from __future__ import annotations
import logging
import re
import time
from pathlib import Path
_LOGGER = logging.getLogger(__name__)
# Directory under the HA config dir (survives integration upgrades).
PHOTOS_DIR = "taskmate_photos"
# Public URL prefix for upload (POST) and serve (GET /<name>).
URL_PREFIX = "/api/taskmate/photo"
# Defensive cap. The child card downscales + JPEG-compresses before upload, so a
# legitimate evidence photo is well under this; the cap only catches abuse/bugs.
MAX_UPLOAD_BYTES = 8 * 1024 * 1024 # 8 MB
# Total disk budget for all stored evidence photos. Each photo is small (the
# card downscales + JPEG-compresses before upload), so this is generous — it
# only caps runaway/abusive uploads (SEC-2).
MAX_TOTAL_BYTES = 512 * 1024 * 1024 # 512 MB
# Stored filenames are always a generated uuid4().hex + a known image extension.
# Anything else is rejected before any filesystem access (path-traversal safety).
FILENAME_RE = re.compile(r"^[0-9a-f]{32}\.(jpg|png|webp|heic)$")
CONTENT_TYPES = {
"jpg": "image/jpeg",
"png": "image/png",
"webp": "image/webp",
"heic": "image/heic",
}
def detect_image_ext(data: bytes) -> str | None:
"""Return a file extension for known image magic bytes, else None.
Sniffs the actual bytes rather than trusting any client-declared type. The
card always sends JPEG; PNG/WebP/HEIC are accepted defensively for direct
API callers.
"""
if data[:3] == b"\xff\xd8\xff":
return "jpg"
if data[:8] == b"\x89PNG\r\n\x1a\n":
return "png"
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
return "webp"
# ISO-BMFF: "....ftyp<brand>"; HEIC brands start the major-brand field.
if data[4:8] == b"ftyp" and data[8:12] in (b"heic", b"heix", b"mif1", b"heif"):
return "heic"
return None
def content_type_for(filename: str) -> str:
"""Map a stored filename to its Content-Type (defaults to image/jpeg)."""
ext = filename.rsplit(".", 1)[-1].lower()
return CONTENT_TYPES.get(ext, "image/jpeg")
def photos_path(hass) -> Path:
"""Absolute path to the evidence-photos directory."""
return Path(hass.config.path(PHOTOS_DIR))
def is_taskmate_photo_url(photo_url: str) -> bool:
"""True only for a well-formed ``/api/taskmate/photo/<name>`` URL of ours.
Pure (no hass / no filesystem) so it can guard untrusted ``photo_url`` input
at the service/coordinator boundary. Rejects blanks, foreign URLs and any
dangerous scheme (``javascript:``, external ``http(s):``) and anything whose
name fails the strict filename pattern.
"""
if not photo_url:
return False
prefix = URL_PREFIX + "/"
if not photo_url.startswith(prefix):
return False
return bool(FILENAME_RE.match(photo_url[len(prefix):]))
def photo_file_for_url(hass, photo_url: str) -> Path | None:
"""Map a ``/api/taskmate/photo/<name>`` URL to its file path.
Returns None for anything that isn't one of our photo URLs or whose name
fails the strict filename pattern (rejects traversal, sub-paths, foreign
URLs). Never touches the filesystem.
"""
if not photo_url:
return None
prefix = URL_PREFIX + "/"
if not photo_url.startswith(prefix):
return None
name = photo_url[len(prefix):]
if not FILENAME_RE.match(name):
return None
return photos_path(hass) / name
def sign_photo_url(hass, photo_url: str, expiration_hours: int = 24) -> str:
"""Return a self-authenticating signed URL for one of our photo URLs.
Browsers don't send the HA bearer token on plain ``<img>`` / top-level
navigation requests, so the auth-gated serve view returns 401 on a bare
URL. ``async_sign_path`` appends an ``?authSig=`` token the HTTP auth
middleware accepts, so the admin panel can render thumbnails and open/save
the full photo. Foreign/blank URLs are returned unchanged.
The HA imports are local so this module stays importable without a real HA
install (the pure helpers above are unit-tested).
"""
if not photo_url or not photo_url.startswith(URL_PREFIX + "/"):
return photo_url
from datetime import timedelta
from homeassistant.components.http.auth import async_sign_path
try:
return async_sign_path(hass, photo_url, timedelta(hours=expiration_hours))
except Exception: # noqa: BLE001 - never break state delivery over a signing hiccup
_LOGGER.debug("Could not sign photo URL %s", photo_url, exc_info=True)
return photo_url
async def async_delete_photo(hass, photo_url: str) -> None:
"""Best-effort delete of the file backing a photo URL.
No-op for foreign URLs or a missing file; logs other OS errors at debug.
"""
path = photo_file_for_url(hass, photo_url)
if path is None:
return
def _unlink() -> None:
try:
path.unlink()
except FileNotFoundError:
pass
except OSError as err: # pragma: no cover - defensive
_LOGGER.debug("Could not delete evidence photo %s: %s", path, err)
await hass.async_add_executor_job(_unlink)
def total_photos_bytes(hass) -> int:
"""Sum of all stored evidence-photo file sizes (0 if the dir is absent)."""
directory = photos_path(hass)
if not directory.is_dir():
return 0
total = 0
for p in directory.iterdir():
if p.is_file() and FILENAME_RE.match(p.name):
try:
total += p.stat().st_size
except OSError: # pragma: no cover - defensive
pass
return total
async def async_sweep_orphan_photos(hass, referenced_urls, max_age_hours: int = 24) -> int:
"""Delete stored photos not referenced by any completion (SEC-2).
Covers photos orphaned by history pruning and uploads that were never
attached to a completion. Files younger than ``max_age_hours`` are kept so
an in-flight upload (uploaded but not yet submitted) is not removed.
Returns the number of files deleted.
"""
prefix = URL_PREFIX + "/"
referenced = {
url[len(prefix):] for url in referenced_urls
if url and url.startswith(prefix)
}
directory = photos_path(hass)
def _sweep() -> int:
if not directory.is_dir():
return 0
cutoff = time.time() - max_age_hours * 3600
removed = 0
for p in directory.iterdir():
if not (p.is_file() and FILENAME_RE.match(p.name)):
continue
if p.name in referenced:
continue
try:
if p.stat().st_mtime < cutoff:
p.unlink()
removed += 1
except OSError: # pragma: no cover - defensive
pass
return removed
return await hass.async_add_executor_job(_sweep)
+63
View File
@@ -0,0 +1,63 @@
"""Select platform — expose key choice TaskMate settings as entities (FEAT-9)."""
from __future__ import annotations
from homeassistant.components.select import SelectEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import TaskMateCoordinator
# (setting_key, translation_key, options, default, icon)
_SELECTS = [
("streak_reset_mode", "streak_reset_mode", ["reset", "pause"], "reset", "mdi:restart"),
("card_design", "card_design", ["classic", "playroom", "console", "cleanpro"], "classic", "mdi:palette"),
]
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up the TaskMate setting-select entities."""
coordinator: TaskMateCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(TaskMateSettingSelect(coordinator, entry, *cfg) for cfg in _SELECTS)
class TaskMateSettingSelect(CoordinatorEntity, SelectEntity):
"""A single choice TaskMate setting surfaced as a select entity."""
_attr_has_entity_name = True
def __init__(self, coordinator, entry, key, tkey, options, default, icon) -> None:
super().__init__(coordinator)
self._entry = entry
self._key = key
self._default = default
self._attr_translation_key = tkey
self._attr_unique_id = f"{entry.entry_id}_setting_{key}"
self._attr_options = options
self._attr_icon = icon
@property
def device_info(self) -> DeviceInfo:
return DeviceInfo(
identifiers={(DOMAIN, self._entry.entry_id)},
name="TaskMate",
manufacturer="TaskMate",
model="Family Chore Manager",
)
@property
def current_option(self) -> str:
val = str(self.coordinator.storage.get_setting(self._key, self._default))
return val if val in self._attr_options else self._default
async def async_select_option(self, option: str) -> None:
if option not in self._attr_options:
return
self.coordinator.storage.set_setting(self._key, option)
await self.coordinator.storage.async_save()
await self.coordinator.async_refresh()
File diff suppressed because it is too large Load Diff
+983
View File
@@ -0,0 +1,983 @@
complete_chore:
name: Complete Chore
description: Mark a chore as completed by a child
fields:
chore_id:
name: Chore ID
description: The ID of the chore to complete
required: true
selector:
text:
child_id:
name: Child ID
description: The ID of the child completing the chore
required: true
selector:
text:
as_parent:
name: As parent
description: Complete on behalf of the child and award points immediately, bypassing approval.
required: false
default: false
selector:
boolean:
photo_url:
name: Photo URL
description: Optional URL or path to a photo submitted as evidence for the completion (e.g. a camera snapshot). Shown to the parent during approval.
required: false
selector:
text:
complete_bonus_subtask:
name: Complete Bonus Sub-task
description: Complete a bonus sub-task after the parent chore is done
fields:
chore_id:
name: Chore ID
description: The ID of the parent chore
required: true
selector:
text:
bonus_subtask_id:
name: Bonus Sub-task ID
description: The ID of the bonus sub-task to complete
required: true
selector:
text:
child_id:
name: Child ID
description: The ID of the child completing the bonus sub-task
required: true
selector:
text:
approve_chore:
name: Approve Chore
description: Approve a completed chore and award points
fields:
completion_id:
name: Completion ID
description: The ID of the chore completion to approve
required: true
selector:
text:
approve_all_chores:
name: Approve All Chores
description: Approve all pending chore completions (or a specific set) and award points
fields:
completion_ids:
name: Completion IDs
description: Specific completion IDs to approve; omit to approve all pending
required: false
selector:
object:
reject_chore:
name: Reject Chore
description: Reject a completed chore
fields:
completion_id:
name: Completion ID
description: The ID of the chore completion to reject
required: true
selector:
text:
apply_mandatory_penalty:
name: Apply Mandatory Penalty
description: Deduct the configured penalty points for a missed mandatory chore and clear the review item.
fields:
miss_id:
name: Miss ID
description: The ID of the mandatory-miss review item
required: true
selector:
text:
postpone_mandatory_chore:
name: Postpone Mandatory Chore
description: Give a missed mandatory chore another period (or tomorrow) without a penalty, and clear the review item.
fields:
miss_id:
name: Miss ID
description: The ID of the mandatory-miss review item
required: true
selector:
text:
dismiss_mandatory_chore:
name: Dismiss Mandatory Chore
description: Clear a missed mandatory chore review item with no penalty.
fields:
miss_id:
name: Miss ID
description: The ID of the mandatory-miss review item
required: true
selector:
text:
undo_transaction:
name: Undo Transaction
description: Reverse a points transaction by its ID — penalties, bonuses, manual add/remove adjustments and gifts (both legs). Automatic transactions (weekend/streak/perfect-week bonuses, pool moves, decay, interest) can't be undone.
fields:
transaction_id:
name: Transaction ID
description: The ID of the points transaction to reverse
required: true
selector:
text:
undo_chore_approval:
name: Undo Chore Approval
description: Reverse an accidental chore approval — removes the awarded points and returns the completion to pending so it can be approved again.
fields:
completion_id:
name: Completion ID
description: The ID of the approved chore completion to revert to pending
required: true
selector:
text:
test_notification:
name: Test Notification
description: Send a sample notification of the given type to its enabled recipients (ignores the master on/off so routing can be verified).
fields:
type_id:
name: Notification type
description: "Notification type id, e.g. pending_chore_approval, badge_earned, streak_milestone"
required: true
selector:
text:
record_allowance_payout:
name: Record Allowance Payout
description: Deduct points and record a real-money allowance payout in the ledger (fixed conversion rate; parent-controlled).
fields:
child_id:
name: Child ID
required: true
selector:
text:
points:
name: Points
description: Points to convert to cash and deduct.
required: true
selector:
number:
min: 1
max: 100000
mode: box
gift_points:
name: Gift Points
description: Transfer spendable points from one child to another (parent-controlled).
fields:
from_child_id:
name: From child ID
required: true
selector:
text:
to_child_id:
name: To child ID
required: true
selector:
text:
points:
name: Points
required: true
selector:
number:
min: 1
max: 10000
mode: box
request_swap:
name: Request Chore Swap
description: A child requests to take over today's rotation assignment of a chore (pending parent approval).
fields:
chore_id:
name: Chore ID
required: true
selector:
text:
requester_id:
name: Requester child ID
required: true
selector:
text:
choose_avatar:
name: Choose Avatar
description: A child switches to an avatar they have unlocked.
fields:
child_id:
name: Child ID
required: true
selector:
text:
icon:
name: Avatar icon
description: The mdi icon of an unlocked catalogue avatar (e.g. mdi:rocket-launch).
required: true
selector:
text:
claim_reward:
name: Claim Reward
description: Child claims a reward using their points
fields:
reward_id:
name: Reward ID
description: The ID of the reward to claim
required: true
selector:
text:
child_id:
name: Child ID
description: The ID of the child claiming the reward
required: true
selector:
text:
approve_reward:
name: Approve Reward
description: Approve a reward claim
fields:
claim_id:
name: Claim ID
description: The ID of the reward claim to approve
required: true
selector:
text:
allocate_points_to_pool:
name: Allocate Points to Pool
description: Move points from a child's spendable balance into a reward's savings pool (locked).
fields:
child_id:
name: Child ID
description: The ID of the child making the allocation
required: true
selector:
text:
reward_id:
name: Reward ID
description: The ID of the reward pool to deposit into
required: true
selector:
text:
points:
name: Points
description: Number of points to allocate (capped at pool capacity and spendable balance)
required: true
selector:
number:
min: 1
max: 10000
mode: box
add_points:
name: Add Points
description: Add bonus points to a child
fields:
child_id:
name: Child ID
description: The ID of the child to add points to
required: true
selector:
text:
points:
name: Points
description: The number of points to add
required: true
selector:
number:
min: 1
max: 10000
mode: box
reason:
name: Reason
description: Optional reason for the bonus
required: false
selector:
text:
remove_points:
name: Remove Points
description: Remove points from a child (penalty)
fields:
child_id:
name: Child ID
description: The ID of the child to remove points from
required: true
selector:
text:
points:
name: Points
description: The number of points to remove
required: true
selector:
number:
min: 1
max: 10000
mode: box
reason:
name: Reason
description: Optional reason for the penalty
required: false
selector:
text:
reject_reward:
name: Reject Reward
description: Reject a reward claim (no points deducted as approval was pending)
fields:
claim_id:
name: Claim ID
description: The ID of the reward claim to reject
required: true
selector:
text:
preview_sound:
name: Preview Sound
description: Preview a completion sound effect in the browser
fields:
sound:
name: Sound
description: The sound to preview
required: true
selector:
select:
options:
- "none"
- "coin"
- "levelup"
- "fanfare"
- "chime"
- "powerup"
- "undo"
- "fart1"
- "fart2"
- "fart3"
- "fart4"
- "fart5"
- "fart6"
- "fart7"
- "fart8"
- "fart9"
- "fart10"
- "fart_random"
set_chore_order:
name: Set Chore Order
description: Set the display order of chores for a child
fields:
child_id:
name: Child ID
description: The ID of the child to set chore order for
required: true
selector:
text:
chore_order:
name: Chore Order
description: Ordered list of chore IDs
required: true
selector:
object:
add_penalty:
name: Add Penalty
description: Create a new penalty definition
fields:
name:
name: Name
description: The name of the penalty
required: true
selector:
text:
points:
name: Points
description: The number of points to deduct when applied
required: true
selector:
number:
min: 1
max: 10000
mode: box
description:
name: Description
description: Optional description of the penalty
required: false
selector:
text:
icon:
name: Icon
description: MDI icon for the penalty
required: false
selector:
icon:
assigned_to:
name: Assigned To
description: List of child IDs this penalty applies to (empty for all)
required: false
selector:
object:
update_penalty:
name: Update Penalty
description: Update an existing penalty definition
fields:
penalty_id:
name: Penalty ID
description: The ID of the penalty to update
required: true
selector:
text:
name:
name: Name
description: New name for the penalty
required: false
selector:
text:
points:
name: Points
description: New points value for the penalty
required: false
selector:
number:
min: 1
max: 10000
mode: box
description:
name: Description
description: New description for the penalty
required: false
selector:
text:
icon:
name: Icon
description: New icon for the penalty
required: false
selector:
icon:
assigned_to:
name: Assigned To
description: New list of child IDs this penalty applies to
required: false
selector:
object:
remove_penalty:
name: Remove Penalty
description: Remove a penalty definition
fields:
penalty_id:
name: Penalty ID
description: The ID of the penalty to remove
required: true
selector:
text:
apply_penalty:
name: Apply Penalty
description: Apply a penalty to a child, deducting points
fields:
penalty_id:
name: Penalty ID
description: The ID of the penalty to apply
required: true
selector:
text:
child_id:
name: Child ID
description: The ID of the child to apply the penalty to
required: true
selector:
text:
add_bonus:
name: Add Bonus
description: Create a new bonus definition
fields:
name:
name: Name
description: The name of the bonus
required: true
selector:
text:
points:
name: Points
description: The number of points to award when applied
required: true
selector:
number:
min: 1
max: 10000
mode: box
description:
name: Description
description: Optional description of the bonus
required: false
selector:
text:
icon:
name: Icon
description: MDI icon for the bonus
required: false
selector:
icon:
assigned_to:
name: Assigned To
description: List of child IDs this bonus applies to (empty for all)
required: false
selector:
object:
update_bonus:
name: Update Bonus
description: Update an existing bonus definition
fields:
bonus_id:
name: Bonus ID
description: The ID of the bonus to update
required: true
selector:
text:
name:
name: Name
description: New name for the bonus
required: false
selector:
text:
points:
name: Points
description: New points value for the bonus
required: false
selector:
number:
min: 1
max: 10000
mode: box
description:
name: Description
description: New description for the bonus
required: false
selector:
text:
icon:
name: Icon
description: New icon for the bonus
required: false
selector:
icon:
assigned_to:
name: Assigned To
description: New list of child IDs this bonus applies to
required: false
selector:
object:
remove_bonus:
name: Remove Bonus
description: Remove a bonus definition
fields:
bonus_id:
name: Bonus ID
description: The ID of the bonus to remove
required: true
selector:
text:
apply_bonus:
name: Apply Bonus
description: Apply a bonus to a child, awarding points
fields:
bonus_id:
name: Bonus ID
description: The ID of the bonus to apply
required: true
selector:
text:
child_id:
name: Child ID
description: The ID of the child to apply the bonus to
required: true
selector:
text:
add_chore:
name: Add Chore
description: Create a new chore dynamically, typically from an automation or script
fields:
name:
name: Name
description: The name of the chore
required: true
selector:
text:
description:
name: Description
description: Optional description of the chore
required: false
selector:
text:
points:
name: Points
description: Points awarded when this chore is completed
required: false
default: 10
selector:
number:
min: 1
max: 10000
mode: box
assigned_to:
name: Assigned To
description: List of child IDs to assign this chore to. Leave empty to assign to all children
required: false
selector:
object:
time_category:
name: Time Category
description: When this chore should be completed
required: false
default: anytime
selector:
select:
options:
- morning
- afternoon
- evening
- night
- anytime
difficulty:
name: Difficulty
description: Difficulty tier; scales awarded points by the tier multiplier (medium = ×1.0 baseline)
required: false
default: medium
selector:
select:
options:
- easy
- medium
- hard
one_shot:
name: One-Shot
description: If true, this is a one-time chore that expires after being completed or at the end of the day
required: false
default: false
selector:
boolean:
requires_approval:
name: Requires Approval
description: If true, a parent must approve the completion before points are awarded
required: false
default: true
selector:
boolean:
skip_chore:
name: Skip Chore
description: Skip today's assigned child for a rotating chore and move the pointer to the next child in the pool. Ephemeral — tomorrow the rotation resumes its normal schedule.
fields:
chore_id:
name: Chore ID
description: The ID of the chore to skip
required: true
selector:
text:
set_chore_manual_start:
name: Set Manual Start Child
description: Set which child the rotation starts with for a rotating chore. For alternating mode this reorders the pool and resets the anchor; for random/balanced it overrides today's cached assignee only.
fields:
chore_id:
name: Chore ID
description: The ID of the chore to update
required: true
selector:
text:
child_id:
name: Child ID
description: The ID of the child who should start the rotation
required: true
selector:
text:
add_task_group:
name: Add Task Group
description: Create a task group that coordinates assignments across chores (sticky = same child, spread = different children).
fields:
name:
name: Name
description: Display name for the group
required: true
selector:
text:
policy:
name: Policy
description: sticky (same child) or spread (different children)
required: true
default: sticky
selector:
select:
options:
- sticky
- spread
chore_ids:
name: Chore IDs
description: List of chore IDs to include in the group. For sticky, the first chore is the leader.
required: false
selector:
object:
update_task_group:
name: Update Task Group
description: Update a task group's name, policy, or chore membership.
fields:
group_id:
name: Group ID
description: The ID of the group to update
required: true
selector:
text:
name:
name: Name
description: New display name
required: false
selector:
text:
policy:
name: Policy
description: New policy (sticky or spread)
required: false
selector:
select:
options:
- sticky
- spread
chore_ids:
name: Chore IDs
description: Replacement list of chore IDs. For sticky, the first chore is the leader.
required: false
selector:
object:
remove_task_group:
name: Remove Task Group
description: Delete a task group. The member chores are not affected.
fields:
group_id:
name: Group ID
description: The ID of the group to delete
required: true
selector:
text:
start_timed_task:
name: Start Timed Task
description: Start or resume the timer on a timed task
fields:
chore_id:
name: Chore ID
description: The ID of the timed chore
required: true
selector:
text:
child_id:
name: Child ID
description: The ID of the child starting the timer
required: true
selector:
text:
pause_timed_task:
name: Pause Timed Task
description: Pause a running timed task timer
fields:
chore_id:
name: Chore ID
description: The ID of the timed chore
required: true
selector:
text:
child_id:
name: Child ID
description: The ID of the child pausing the timer
required: true
selector:
text:
stop_timed_task:
name: Stop Timed Task
description: Stop a timed task timer and submit for approval
fields:
chore_id:
name: Chore ID
description: The ID of the timed chore
required: true
selector:
text:
child_id:
name: Child ID
description: The ID of the child stopping the timer
required: true
selector:
text:
add_badge:
name: Add Badge
description: Create a custom badge.
fields:
name:
name: Name
required: true
example: "Holiday Helper"
selector:
text:
description:
name: Description
example: "Earned for big effort during school holidays"
selector:
text:
icon:
name: Icon
example: "mdi:beach"
selector:
icon:
tier:
name: Tier
default: bronze
selector:
select:
options:
- bronze
- silver
- gold
- platinum
point_bonus:
name: Point bonus
default: 0
selector:
number:
min: 0
max: 1000
step: 1
mode: box
criteria:
name: Criteria
description: "List of metric / operator / value dicts (operator one of >=, ==, <=, >, <, !=). Empty = manual-award only."
selector:
object:
combinator:
name: Combinator
description: "How multiple criteria combine: AND (all must pass) or OR (any). Default AND."
default: AND
selector:
select:
options:
- AND
- OR
assigned_to:
name: Assigned to
description: "List of child IDs (empty = all)"
selector:
object:
notify_on_earn:
name: Notify on earn
default: true
selector:
boolean:
update_badge:
name: Update Badge
description: Update an existing badge. For built-ins, only point_bonus, tier, assigned_to, enabled, notify_on_earn are editable.
fields:
badge_id:
name: Badge ID
required: true
selector:
text:
name:
selector:
text:
description:
selector:
text:
icon:
selector:
icon:
tier:
selector:
select:
options:
- bronze
- silver
- gold
- platinum
point_bonus:
selector:
number:
min: 0
max: 1000
step: 1
mode: box
criteria:
selector:
object:
combinator:
selector:
select:
options:
- AND
- OR
assigned_to:
selector:
object:
enabled:
selector:
boolean:
notify_on_earn:
selector:
boolean:
remove_badge:
name: Remove Badge
description: Remove a custom badge. Built-ins cannot be removed.
fields:
badge_id:
name: Badge ID
required: true
selector:
text:
award_badge_manually:
name: Award Badge Manually
description: Manually award a badge to a child.
fields:
badge_id:
name: Badge ID
required: true
selector:
text:
child_id:
name: Child ID
required: true
selector:
text:
revoke_badge:
name: Revoke Badge
description: Revoke an awarded badge. If a point bonus was credited, it is reversed.
fields:
awarded_badge_id:
name: Awarded Badge ID
required: true
selector:
text:
rebuild_badges:
name: Rebuild Badges
description: Re-evaluate all badges across all children silently (no notifications, no point bonuses).
File diff suppressed because it is too large Load Diff
+884
View File
@@ -0,0 +1,884 @@
{
"config": {
"step": {
"user": {
"title": "Welcome to TaskMate!",
"description": "Set up your family chore management system. You can customize the points currency that children will earn.",
"data": {
"points_name": "Points Currency Name (e.g., Stars, Coins, Bucks)",
"points_icon": "Points Icon"
}
}
},
"error": {
"name_required": "Name is required"
},
"abort": {
"already_configured": "TaskMate is already configured"
}
},
"selector": {
"chore_action": {
"options": {
"save": "Save Changes",
"re_enable": "Re-enable Chore",
"disable": "Disable Chore",
"delete": "Delete This Chore"
}
},
"reward_action": {
"options": {
"save": "Save Changes",
"delete": "Delete Reward"
}
},
"time_category": {
"options": {
"morning": "Morning",
"afternoon": "Afternoon",
"evening": "Evening",
"night": "Night",
"anytime": "Anytime"
}
},
"schedule_mode": {
"options": {
"specific_days": "Specific days of the week",
"recurring": "Recurring (every N days/weeks/months)",
"one_shot": "One-time task (single day)"
}
},
"due_days_option": {
"options": {
"monday": "Monday",
"tuesday": "Tuesday",
"wednesday": "Wednesday",
"thursday": "Thursday",
"friday": "Friday",
"saturday": "Saturday",
"sunday": "Sunday"
}
},
"recurrence": {
"options": {
"every_2_days": "Every 2 days",
"weekly": "Weekly",
"every_2_weeks": "Every 2 weeks",
"monthly": "Monthly",
"every_3_months": "Every 3 months",
"every_6_months": "Every 6 months"
}
},
"recurrence_day_option": {
"options": {
"monday": "Monday",
"tuesday": "Tuesday",
"wednesday": "Wednesday",
"thursday": "Thursday",
"friday": "Friday",
"saturday": "Saturday",
"sunday": "Sunday",
"any_day": "Any day"
}
},
"first_occurrence_mode": {
"options": {
"available_immediately": "Available immediately",
"wait_for_first_occurrence": "Wait for first scheduled occurrence"
}
},
"streak_reset_mode": {
"options": {
"reset": "Reset — streak goes to 0 on missed day",
"pause": "Pause — streak preserved until next completion"
}
},
"visibility_operator": {
"options": {
"none": "No filter — Always show chore",
"equals": "Equals — Show when entity state matches exactly",
"not_equals": "Not Equal — Show when entity state does not match",
"gte": "Greater than or equal (≥) — Show when numeric value is ≥ target",
"lte": "Less than or equal (≤) — Show when numeric value is ≤ target",
"gt": "Greater than (>) — Show when numeric value is > target",
"lt": "Less than (<) — Show when numeric value is < target"
}
},
"assignment_mode": {
"options": {
"everyone": "Everyone — every assigned child sees the chore",
"alternating": "Alternating — rotate through the assigned children, one per day",
"random": "Random — pick one assigned child at random each day",
"balanced": "Balanced — split today's balanced chores evenly across the assigned children",
"first_come": "First come, first served — first child to complete it wins; it hides for the rest",
"unassigned": "Unassigned — no child sees this chore"
}
},
"task_group_policy": {
"options": {
"sticky": "Sticky — same child for all chores in the group",
"spread": "Spread — different children across the group today"
}
},
"task_group_action": {
"options": {
"save": "Save Changes",
"delete": "Delete Group"
}
}
},
"services": {
"complete_chore": {
"name": "Complete Chore",
"description": "Mark a chore as completed by a child",
"fields": {
"chore_id": {
"name": "Chore ID",
"description": "The ID of the chore to complete"
},
"child_id": {
"name": "Child ID",
"description": "The ID of the child completing the chore"
}
}
},
"complete_bonus_subtask": {
"name": "Complete Bonus Sub-task",
"description": "Complete a bonus sub-task after the parent chore is done",
"fields": {
"chore_id": {
"name": "Chore ID",
"description": "The ID of the parent chore"
},
"bonus_subtask_id": {
"name": "Bonus Sub-task ID",
"description": "The ID of the bonus sub-task to complete"
},
"child_id": {
"name": "Child ID",
"description": "The ID of the child completing the bonus sub-task"
}
}
},
"approve_chore": {
"name": "Approve Chore",
"description": "Approve a completed chore and award points",
"fields": {
"completion_id": {
"name": "Completion ID",
"description": "The ID of the chore completion to approve"
}
}
},
"approve_all_chores": {
"name": "Approve All Chores",
"description": "Approve all pending chore completions (or a specific set) and award points",
"fields": {
"completion_ids": {
"name": "Completion IDs",
"description": "Specific completion IDs to approve; omit to approve all pending"
}
}
},
"reject_chore": {
"name": "Reject Chore",
"description": "Reject a completed chore",
"fields": {
"completion_id": {
"name": "Completion ID",
"description": "The ID of the chore completion to reject"
}
}
},
"undo_chore_approval": {
"name": "Undo Chore Approval",
"description": "Reverse an accidental chore approval — removes the awarded points and returns the completion to pending so it can be approved again.",
"fields": {
"completion_id": {
"name": "Completion ID",
"description": "The ID of the approved chore completion to revert to pending"
}
}
},
"undo_transaction": {
"name": "Undo Transaction",
"description": "Reverse a points transaction by its ID — penalties, bonuses, manual add/remove adjustments and gifts (both legs). Automatic transactions (weekend/streak/perfect-week bonuses, pool moves, decay, interest) can't be undone.",
"fields": {
"transaction_id": {
"name": "Transaction ID",
"description": "The ID of the points transaction to reverse"
}
}
},
"claim_reward": {
"name": "Claim Reward",
"description": "Child claims a reward using their points",
"fields": {
"reward_id": {
"name": "Reward ID",
"description": "The ID of the reward to claim"
},
"child_id": {
"name": "Child ID",
"description": "The ID of the child claiming the reward"
}
}
},
"approve_reward": {
"name": "Approve Reward",
"description": "Approve a reward claim",
"fields": {
"claim_id": {
"name": "Claim ID",
"description": "The ID of the reward claim to approve"
}
}
},
"allocate_points_to_pool": {
"name": "Allocate Points to Pool",
"description": "Move points from a child's spendable balance into a reward's savings pool (locked).",
"fields": {
"child_id": {
"name": "Child ID",
"description": "The ID of the child making the allocation"
},
"reward_id": {
"name": "Reward ID",
"description": "The ID of the reward pool to deposit into"
},
"points": {
"name": "Points",
"description": "Number of points to allocate (capped at pool capacity and spendable balance)"
}
}
},
"add_points": {
"name": "Add Points",
"description": "Add bonus points to a child",
"fields": {
"child_id": {
"name": "Child ID",
"description": "The ID of the child to add points to"
},
"points": {
"name": "Points",
"description": "The number of points to add"
},
"reason": {
"name": "Reason",
"description": "Optional reason for the bonus"
}
}
},
"remove_points": {
"name": "Remove Points",
"description": "Remove points from a child (penalty)",
"fields": {
"child_id": {
"name": "Child ID",
"description": "The ID of the child to remove points from"
},
"points": {
"name": "Points",
"description": "The number of points to remove"
},
"reason": {
"name": "Reason",
"description": "Optional reason for the penalty"
}
}
},
"reject_reward": {
"name": "Reject Reward",
"description": "Reject a reward claim",
"fields": {
"claim_id": {
"name": "Claim ID",
"description": "The ID of the reward claim to reject"
}
}
},
"preview_sound": {
"name": "Preview Sound",
"description": "Preview a completion sound effect in the browser",
"fields": {
"sound": {
"name": "Sound",
"description": "The sound to preview"
}
}
},
"set_chore_order": {
"name": "Set Chore Order",
"description": "Set the display order of chores for a child",
"fields": {
"child_id": {
"name": "Child ID",
"description": "The ID of the child to set chore order for"
},
"chore_order": {
"name": "Chore Order",
"description": "Ordered list of chore IDs"
}
}
},
"add_penalty": {
"name": "Add Penalty",
"description": "Create a new penalty definition",
"fields": {
"name": {
"name": "Name",
"description": "The name of the penalty"
},
"points": {
"name": "Points",
"description": "The number of points to deduct when applied"
},
"description": {
"name": "Description",
"description": "Optional description of the penalty"
},
"icon": {
"name": "Icon",
"description": "MDI icon for the penalty"
},
"assigned_to": {
"name": "Assigned To",
"description": "List of child IDs this penalty applies to"
}
}
},
"update_penalty": {
"name": "Update Penalty",
"description": "Update an existing penalty definition",
"fields": {
"penalty_id": {
"name": "Penalty ID",
"description": "The ID of the penalty to update"
},
"name": {
"name": "Name",
"description": "New name for the penalty"
},
"points": {
"name": "Points",
"description": "New points value"
},
"description": {
"name": "Description",
"description": "New description"
},
"icon": {
"name": "Icon",
"description": "New icon"
},
"assigned_to": {
"name": "Assigned To",
"description": "New list of child IDs"
}
}
},
"remove_penalty": {
"name": "Remove Penalty",
"description": "Remove a penalty definition",
"fields": {
"penalty_id": {
"name": "Penalty ID",
"description": "The ID of the penalty to remove"
}
}
},
"apply_penalty": {
"name": "Apply Penalty",
"description": "Apply a penalty to a child, deducting points",
"fields": {
"penalty_id": {
"name": "Penalty ID",
"description": "The ID of the penalty to apply"
},
"child_id": {
"name": "Child ID",
"description": "The ID of the child to apply the penalty to"
}
}
},
"add_bonus": {
"name": "Add Bonus",
"description": "Create a new bonus definition",
"fields": {
"name": {
"name": "Name",
"description": "The name of the bonus"
},
"points": {
"name": "Points",
"description": "The number of points to award when applied"
},
"description": {
"name": "Description",
"description": "Optional description of the bonus"
},
"icon": {
"name": "Icon",
"description": "MDI icon for the bonus"
},
"assigned_to": {
"name": "Assigned To",
"description": "List of child IDs this bonus applies to"
}
}
},
"update_bonus": {
"name": "Update Bonus",
"description": "Update an existing bonus definition",
"fields": {
"bonus_id": {
"name": "Bonus ID",
"description": "The ID of the bonus to update"
},
"name": {
"name": "Name",
"description": "New name for the bonus"
},
"points": {
"name": "Points",
"description": "New points value"
},
"description": {
"name": "Description",
"description": "New description"
},
"icon": {
"name": "Icon",
"description": "New icon"
},
"assigned_to": {
"name": "Assigned To",
"description": "New list of child IDs"
}
}
},
"remove_bonus": {
"name": "Remove Bonus",
"description": "Remove a bonus definition",
"fields": {
"bonus_id": {
"name": "Bonus ID",
"description": "The ID of the bonus to remove"
}
}
},
"apply_bonus": {
"name": "Apply Bonus",
"description": "Apply a bonus to a child, awarding points",
"fields": {
"bonus_id": {
"name": "Bonus ID",
"description": "The ID of the bonus to apply"
},
"child_id": {
"name": "Child ID",
"description": "The ID of the child to apply the bonus to"
}
}
},
"add_chore": {
"name": "Add Chore",
"description": "Create a new chore dynamically, typically from an automation or script",
"fields": {
"name": {
"name": "Name",
"description": "The name of the chore"
},
"description": {
"name": "Description",
"description": "Optional description of the chore"
},
"points": {
"name": "Points",
"description": "Points awarded when this chore is completed"
},
"assigned_to": {
"name": "Assigned To",
"description": "List of child IDs to assign this chore to. Leave empty to assign to all children"
},
"time_category": {
"name": "Time Category",
"description": "When this chore should be completed: morning, afternoon, evening, night, or anytime"
},
"one_shot": {
"name": "One-Shot",
"description": "If true, this is a one-time chore that expires after being completed or at the end of the day"
},
"requires_approval": {
"name": "Requires Approval",
"description": "If true, a parent must approve the completion before points are awarded"
}
}
},
"skip_chore": {
"name": "Skip Chore",
"description": "Advance today's rotation past the current assignee. Ephemeral — tomorrow's rotation resumes as normal.",
"fields": {
"chore_id": {
"name": "Chore ID",
"description": "The ID of the chore to skip"
}
}
},
"set_chore_manual_start": {
"name": "Set Manual Start Child",
"description": "Set which child starts a rotating chore. For alternating mode this reorders the pool; for random/balanced it only overrides today's assignee.",
"fields": {
"chore_id": {
"name": "Chore ID",
"description": "The ID of the chore to update"
},
"child_id": {
"name": "Child ID",
"description": "The ID of the child who should start the rotation"
}
}
},
"add_task_group": {
"name": "Add Task Group",
"description": "Create a task group (sticky = same child, spread = different children).",
"fields": {
"name": {
"name": "Name",
"description": "Display name for the group"
},
"policy": {
"name": "Policy",
"description": "sticky (same child) or spread (different children)"
},
"chore_ids": {
"name": "Chore IDs",
"description": "List of chore IDs in the group. For sticky, the first chore is the leader."
}
}
},
"update_task_group": {
"name": "Update Task Group",
"description": "Update a task group's name, policy, or membership.",
"fields": {
"group_id": {
"name": "Group ID",
"description": "The ID of the group to update"
},
"name": {
"name": "Name",
"description": "New display name"
},
"policy": {
"name": "Policy",
"description": "New policy (sticky or spread)"
},
"chore_ids": {
"name": "Chore IDs",
"description": "Replacement chore ID list. For sticky, the first chore is the leader."
}
}
},
"remove_task_group": {
"name": "Remove Task Group",
"description": "Delete a task group. Member chores are not affected.",
"fields": {
"group_id": {
"name": "Group ID",
"description": "The ID of the group to delete"
}
}
},
"start_timed_task": {
"name": "Start Timed Task",
"description": "Start or resume the timer on a timed task",
"fields": {
"chore_id": {
"name": "Chore ID",
"description": "The ID of the timed chore"
},
"child_id": {
"name": "Child ID",
"description": "The ID of the child starting the timer"
}
}
},
"pause_timed_task": {
"name": "Pause Timed Task",
"description": "Pause a running timed task timer",
"fields": {
"chore_id": {
"name": "Chore ID",
"description": "The ID of the timed chore"
},
"child_id": {
"name": "Child ID",
"description": "The ID of the child pausing the timer"
}
}
},
"stop_timed_task": {
"name": "Stop Timed Task",
"description": "Stop a timed task timer and submit for approval",
"fields": {
"chore_id": {
"name": "Chore ID",
"description": "The ID of the timed chore"
},
"child_id": {
"name": "Child ID",
"description": "The ID of the child stopping the timer"
}
}
},
"add_badge": {
"name": "Add Badge",
"description": "Create a custom badge.",
"fields": {
"name": {
"name": "Name",
"description": "The display name of the badge"
},
"description": {
"name": "Description",
"description": "Description of how the badge is earned"
},
"icon": {
"name": "Icon",
"description": "Icon for the badge (e.g. mdi:beach)"
},
"tier": {
"name": "Tier",
"description": "Badge tier: bronze, silver, gold or platinum"
},
"point_bonus": {
"name": "Point bonus",
"description": "Points credited to the child when the badge is earned"
},
"criteria": {
"name": "Criteria",
"description": "List of metric / operator / value dicts. Empty = manual-award only."
},
"assigned_to": {
"name": "Assigned to",
"description": "List of child IDs (empty = all)"
},
"notify_on_earn": {
"name": "Notify on earn",
"description": "Send a notification when the badge is earned"
}
}
},
"update_badge": {
"name": "Update Badge",
"description": "Update an existing badge. For built-ins, only point_bonus, tier, assigned_to, enabled, notify_on_earn are editable.",
"fields": {
"badge_id": {
"name": "Badge ID",
"description": "The ID of the badge to update"
},
"name": {
"name": "Name",
"description": "New display name of the badge"
},
"description": {
"name": "Description",
"description": "New description of the badge"
},
"icon": {
"name": "Icon",
"description": "New icon for the badge"
},
"tier": {
"name": "Tier",
"description": "Badge tier: bronze, silver, gold or platinum"
},
"point_bonus": {
"name": "Point bonus",
"description": "Points credited to the child when the badge is earned"
},
"criteria": {
"name": "Criteria",
"description": "List of metric / operator / value dicts. Empty = manual-award only."
},
"assigned_to": {
"name": "Assigned to",
"description": "List of child IDs (empty = all)"
},
"enabled": {
"name": "Enabled",
"description": "Whether the badge is active"
},
"notify_on_earn": {
"name": "Notify on earn",
"description": "Send a notification when the badge is earned"
}
}
},
"remove_badge": {
"name": "Remove Badge",
"description": "Remove a custom badge. Built-ins cannot be removed.",
"fields": {
"badge_id": {
"name": "Badge ID",
"description": "The ID of the badge to remove"
}
}
},
"award_badge_manually": {
"name": "Award Badge Manually",
"description": "Manually award a badge to a child.",
"fields": {
"badge_id": {
"name": "Badge ID",
"description": "The ID of the badge to award"
},
"child_id": {
"name": "Child ID",
"description": "The ID of the child receiving the badge"
}
}
},
"revoke_badge": {
"name": "Revoke Badge",
"description": "Revoke an awarded badge. If a point bonus was credited, it is reversed.",
"fields": {
"awarded_badge_id": {
"name": "Awarded Badge ID",
"description": "The ID of the awarded badge record to revoke"
}
}
},
"rebuild_badges": {
"name": "Rebuild Badges",
"description": "Re-evaluate all badges across all children silently (no notifications, no point bonuses)."
},
"apply_mandatory_penalty": {
"name": "Apply Mandatory Penalty",
"description": "Deduct the configured penalty points for a missed mandatory chore and clear the review item.",
"fields": {
"miss_id": {
"name": "Miss ID",
"description": "The ID of the mandatory-miss review item"
}
}
},
"choose_avatar": {
"name": "Choose Avatar",
"description": "A child switches to an avatar they have unlocked.",
"fields": {
"child_id": {
"name": "Child ID",
"description": "The ID of the child"
},
"icon": {
"name": "Avatar icon",
"description": "The mdi icon of an unlocked catalogue avatar (e.g. mdi:rocket-launch)."
}
}
},
"dismiss_mandatory_chore": {
"name": "Dismiss Mandatory Chore",
"description": "Clear a missed mandatory chore review item with no penalty.",
"fields": {
"miss_id": {
"name": "Miss ID",
"description": "The ID of the mandatory-miss review item"
}
}
},
"gift_points": {
"name": "Gift Points",
"description": "Transfer spendable points from one child to another (parent-controlled).",
"fields": {
"from_child_id": {
"name": "From child ID",
"description": "The ID of the child sending points"
},
"to_child_id": {
"name": "To child ID",
"description": "The ID of the child receiving points"
},
"points": {
"name": "Points",
"description": "The number of points to transfer"
}
}
},
"postpone_mandatory_chore": {
"name": "Postpone Mandatory Chore",
"description": "Give a missed mandatory chore another period (or tomorrow) without a penalty, and clear the review item.",
"fields": {
"miss_id": {
"name": "Miss ID",
"description": "The ID of the mandatory-miss review item"
}
}
},
"request_swap": {
"name": "Request Chore Swap",
"description": "A child requests to take over today's rotation assignment of a chore (pending parent approval).",
"fields": {
"chore_id": {
"name": "Chore ID",
"description": "The ID of the chore to take over"
},
"requester_id": {
"name": "Requester child ID",
"description": "The ID of the child requesting the swap"
}
}
},
"test_notification": {
"name": "Test Notification",
"description": "Send a sample notification of the given type to its enabled recipients (ignores the master on/off so routing can be verified).",
"fields": {
"type_id": {
"name": "Notification type",
"description": "Notification type id, e.g. pending_chore_approval, badge_earned, streak_milestone"
}
}
},
"record_allowance_payout": {
"name": "Record Allowance Payout",
"description": "Deduct points and record a real-money allowance payout in the ledger (fixed conversion rate; parent-controlled).",
"fields": {
"child_id": {
"name": "Child ID",
"description": "The ID of the child receiving the payout"
},
"points": {
"name": "Points",
"description": "Points to convert to cash and deduct"
}
}
}
},
"entity": {
"sensor": {
"child_stats": {
"unit_of_measurement": "chores"
},
"child_badges": {
"unit_of_measurement": "badges"
}
},
"number": {
"weekend_multiplier": {
"name": "Weekend multiplier"
},
"perfect_week_bonus": {
"name": "Perfect week bonus"
}
},
"select": {
"streak_reset_mode": {
"name": "Streak reset mode"
},
"card_design": {
"name": "Card design"
}
}
}
}
+90
View File
@@ -0,0 +1,90 @@
"""Built-in chore template packs for TaskMate."""
from __future__ import annotations
TEMPLATE_CHORE_FIELDS = (
"name", "points", "description", "requires_approval", "time_category",
"daily_limit", "completion_sound", "schedule_mode", "due_days",
"recurrence", "recurrence_day", "recurrence_start", "first_occurrence_mode",
"assignment_mode", "require_availability", "visibility_entity",
"visibility_state", "visibility_operator", "task_type",
"timed_rate_points", "timed_rate_minutes", "timed_max_daily_minutes",
)
_WEEKDAYS = ["monday", "tuesday", "wednesday", "thursday", "friday"]
_WEEKENDS = ["saturday", "sunday"]
BUILT_IN_TEMPLATES: list[dict] = [
{
"id": "morning_routine",
"name": "Morning routine",
"icon": "mdi:weather-sunny",
"builtin": True,
"chores": [
{"name": "Make bed", "points": 2, "time_category": "morning", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
{"name": "Brush teeth", "points": 1, "time_category": "morning", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
{"name": "Get dressed", "points": 1, "time_category": "morning", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
{"name": "Pack school bag", "points": 2, "time_category": "morning", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
],
},
{
"id": "evening_routine",
"name": "Evening routine",
"icon": "mdi:weather-night",
"builtin": True,
"chores": [
{"name": "Brush teeth", "points": 1, "time_category": "evening", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
{"name": "Put on pyjamas", "points": 1, "time_category": "evening", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
{"name": "Tidy room", "points": 2, "time_category": "evening", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
{"name": "Set out clothes for tomorrow", "points": 1, "time_category": "evening", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
],
},
{
"id": "kitchen_helper",
"name": "Kitchen helper",
"icon": "mdi:silverware-fork-knife",
"builtin": True,
"chores": [
{"name": "Set table", "points": 2, "time_category": "evening", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
{"name": "Clear plates", "points": 2, "time_category": "evening", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
{"name": "Load dishwasher", "points": 3, "time_category": "evening", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
{"name": "Wipe counters", "points": 2, "time_category": "evening", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
],
},
{
"id": "weekend_helper",
"name": "Weekend helper",
"icon": "mdi:broom",
"builtin": True,
"chores": [
{"name": "Tidy bedroom", "points": 3, "time_category": "anytime", "schedule_mode": "specific_days", "due_days": list(_WEEKENDS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
{"name": "Hoover", "points": 4, "time_category": "anytime", "schedule_mode": "specific_days", "due_days": list(_WEEKENDS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
{"name": "Help with laundry", "points": 3, "time_category": "anytime", "schedule_mode": "specific_days", "due_days": list(_WEEKENDS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
{"name": "Take bins out", "points": 2, "time_category": "anytime", "schedule_mode": "specific_days", "due_days": list(_WEEKENDS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
],
},
{
"id": "pet_care",
"name": "Pet care",
"icon": "mdi:paw",
"builtin": True,
"chores": [
{"name": "Feed pet", "points": 2, "time_category": "morning", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
{"name": "Fill water bowl", "points": 1, "time_category": "morning", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
{"name": "Walk dog", "points": 3, "time_category": "afternoon", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
{"name": "Clean litter tray", "points": 3, "time_category": "anytime", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
],
},
{
"id": "homework_reading",
"name": "Homework & reading",
"icon": "mdi:book-open-variant",
"builtin": True,
"chores": [
{"name": "Do homework", "points": 3, "time_category": "afternoon", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
{"name": "Read for 20 minutes", "points": 2, "time_category": "anytime", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
{"name": "Practice instrument", "points": 2, "time_category": "anytime", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
],
},
]
BUILT_IN_IDS = frozenset(t["id"] for t in BUILT_IN_TEMPLATES)
+80
View File
@@ -0,0 +1,80 @@
"""Todo platform — each child's outstanding chores as a native HA to-do list (FEAT-8).
Exposes one TodoListEntity per child, listing the chores they still need to do
today (via the shared get_due_chores_for_child). Checking an item off completes
the chore, so the native HA to-do card and voice assistants can drive TaskMate
without the custom cards.
"""
from __future__ import annotations
from homeassistant.components.todo import (
TodoItem,
TodoItemStatus,
TodoListEntity,
TodoListEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import TaskMateCoordinator
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up a to-do list per child, adding new children as they appear."""
coordinator: TaskMateCoordinator = hass.data[DOMAIN][entry.entry_id]
tracked: set[str] = set()
@callback
def _add_new() -> None:
new = []
for child in coordinator.data.get("children", []):
if child.id not in tracked:
new.append(TaskMateChildTodoList(coordinator, entry, child))
tracked.add(child.id)
if new:
async_add_entities(new)
_add_new()
entry.async_on_unload(coordinator.async_add_listener(_add_new))
class TaskMateChildTodoList(CoordinatorEntity, TodoListEntity):
"""A child's outstanding chores as a to-do list."""
_attr_supported_features = TodoListEntityFeature.UPDATE_TODO_ITEM
def __init__(self, coordinator, entry, child) -> None:
super().__init__(coordinator)
self._entry = entry
self._child_id = child.id
self._attr_unique_id = f"{entry.entry_id}_{child.id}_todo"
self._attr_name = child.name
@property
def device_info(self) -> DeviceInfo:
return DeviceInfo(
identifiers={(DOMAIN, self._entry.entry_id)},
name="TaskMate",
manufacturer="TaskMate",
model="Family Chore Manager",
)
@property
def todo_items(self) -> list[TodoItem]:
if not self.coordinator.get_child(self._child_id):
return []
return [
TodoItem(summary=chore.name, uid=chore.id, status=TodoItemStatus.NEEDS_ACTION)
for chore in self.coordinator.get_due_chores_for_child(self._child_id)
]
async def async_update_todo_item(self, item: TodoItem) -> None:
"""Checking an item off completes the chore for this child."""
if item.status == TodoItemStatus.COMPLETED and item.uid:
await self.coordinator.async_complete_chore(item.uid, self._child_id)
@@ -0,0 +1,884 @@
{
"config": {
"step": {
"user": {
"title": "Willkommen bei TaskMate!",
"description": "Richten Sie Ihr Familien Aufgabenverwaltungssystem ein. Sie können die Punktewährung anpassen, die Kinder verdienen können.",
"data": {
"points_name": "Name der Punktewährung (z. B. Sterne, Münzen, Dollar)",
"points_icon": "Punkte-Symbol"
}
}
},
"error": {
"name_required": "Name ist erforderlich"
},
"abort": {
"already_configured": "TaskMate ist bereits konfiguriert"
}
},
"selector": {
"chore_action": {
"options": {
"save": "Änderungen speichern",
"re_enable": "Aufgabe erneut aktivieren",
"disable": "Aufgabe deaktivieren",
"delete": "Diese Aufgabe löschen"
}
},
"reward_action": {
"options": {
"save": "Änderungen speichern",
"delete": "Belohnung löschen"
}
},
"time_category": {
"options": {
"morning": "Morgen",
"afternoon": "Nachmittag",
"evening": "Abend",
"night": "Nacht",
"anytime": "Jederzeit"
}
},
"schedule_mode": {
"options": {
"specific_days": "Bestimmte Wochentage",
"recurring": "Wiederkehrend (alle N Tage/Wochen/Monate)",
"one_shot": "Einmalige Aufgabe (einzelner Tag)"
}
},
"due_days_option": {
"options": {
"monday": "Montag",
"tuesday": "Dienstag",
"wednesday": "Mittwoch",
"thursday": "Donnerstag",
"friday": "Freitag",
"saturday": "Samstag",
"sunday": "Sonntag"
}
},
"recurrence": {
"options": {
"every_2_days": "Alle 2 Tage",
"weekly": "Wöchentlich",
"every_2_weeks": "Alle 2 Wochen",
"monthly": "Monatlich",
"every_3_months": "Alle 3 Monate",
"every_6_months": "Alle 6 Monate"
}
},
"recurrence_day_option": {
"options": {
"monday": "Montag",
"tuesday": "Dienstag",
"wednesday": "Mittwoch",
"thursday": "Donnerstag",
"friday": "Freitag",
"saturday": "Samstag",
"sunday": "Sonntag",
"any_day": "Jeder Tag"
}
},
"first_occurrence_mode": {
"options": {
"available_immediately": "Sofort verfügbar",
"wait_for_first_occurrence": "Warten Sie auf das erste geplante Ereignis"
}
},
"streak_reset_mode": {
"options": {
"reset": "Zurücksetzen Serie geht am verpassten Tag auf 0",
"pause": "Pause die Serie bleibt bis zum nächsten Abschluss erhalten"
}
},
"visibility_operator": {
"options": {
"none": "Kein Filter Aufgaben immer anzeigen",
"equals": "Gleicht Wird angezeigt, wenn der Entitätsstatus genau übereinstimmt",
"not_equals": "Nicht gleich Wird angezeigt, wenn der Entitätsstatus nicht übereinstimmt",
"gte": "Größer als oder gleich (≥) Wird angezeigt, wenn der numerische Wert ≥ dem Zielwert ist",
"lte": "Kleiner oder gleich (≤) Wird angezeigt, wenn der numerische Wert ≤ Ziel ist",
"gt": "Größer als (>) Wird angezeigt, wenn der numerische Wert > Ziel ist",
"lt": "Kleiner als (<) Wird angezeigt, wenn der numerische Wert < Ziel ist"
}
},
"assignment_mode": {
"options": {
"everyone": "Jeder jedes zugewiesene Kind sieht die Aufgabe",
"alternating": "Abwechselnd wechseln Sie durch die zugewiesenen Kinder, eines pro Tag",
"random": "Zufällig Wählen Sie jeden Tag zufällig ein zugewiesenes Kind aus",
"balanced": "Ausgewogen Verteilen Sie die heutigen ausgeglichenen Aufgaben gleichmäßig auf die zugewiesenen Kinder",
"first_come": "Wer zuerst kommt, mahlt zuerst — das erste Kind, das sie erledigt, gewinnt; bei den anderen ausgeblendet",
"unassigned": "Nicht zugewiesen — kein Kind sieht diese Aufgabe"
}
},
"task_group_policy": {
"options": {
"sticky": "Festgelegt dasselbe Kind für alle Aufgaben in der Gruppe",
"spread": "Verteilt - für verschiedene Kinder in der Gruppe heute"
}
},
"task_group_action": {
"options": {
"save": "Änderungen speichern",
"delete": "Gruppe löschen"
}
}
},
"services": {
"complete_chore": {
"name": "Erledige Aufgabe",
"description": "Markieren Sie eine Aufgabe als von einem Kind erledigt",
"fields": {
"chore_id": {
"name": "Aufgaben-ID",
"description": "Die ID der Aufgabe, die abgeschlossen werden soll"
},
"child_id": {
"name": "Kinder-ID",
"description": "Die ID des Kindes, das die Aufgabe erledigt"
}
}
},
"complete_bonus_subtask": {
"name": "Bonus-Unteraufgabe erledigen",
"description": "Erledigen Sie eine Bonus-Unteraufgabe, nachdem die übergeordnete Aufgabe abgeschlossen wurde",
"fields": {
"chore_id": {
"name": "Aufgaben-ID",
"description": "Die ID der übergeordneten Aufgabe"
},
"bonus_subtask_id": {
"name": "Bonus-Unteraufgaben-ID",
"description": "Die ID der zu erledigenden Bonus-Unteraufgabe"
},
"child_id": {
"name": "Kinder-ID",
"description": "Die ID des Kindes, das die Bonus-Unteraufgabe erledigt"
}
}
},
"approve_chore": {
"name": "Genehmigen Sie die Aufgabe",
"description": "Genehmigen Sie eine erledigte Aufgabe und vergeben Sie Punkte",
"fields": {
"completion_id": {
"name": "Abschluss-ID",
"description": "Die ID des zu genehmigenden Aufgabenabschlusses"
}
}
},
"approve_all_chores": {
"name": "Alle Aufgaben genehmigen",
"description": "Alle ausstehenden Aufgabenerledigungen (oder eine bestimmte Auswahl) genehmigen und Punkte vergeben",
"fields": {
"completion_ids": {
"name": "Erledigungs-IDs",
"description": "Bestimmte Erledigungs-IDs zum Genehmigen; leer lassen, um alle ausstehenden zu genehmigen"
}
}
},
"reject_chore": {
"name": "Aufgabe ablehnen",
"description": "Eine erledigte Aufgabe ablehnen",
"fields": {
"completion_id": {
"name": "Abschluss-ID",
"description": "Die ID des abzulehnenden Aufgabenabschlusses"
}
}
},
"undo_chore_approval": {
"name": "Aufgabengenehmigung rückgängig machen",
"description": "Eine versehentliche Aufgabengenehmigung rückgängig machen — entfernt die vergebenen Punkte und setzt die Erledigung zurück auf ausstehend, sodass sie erneut genehmigt werden kann.",
"fields": {
"completion_id": {
"name": "Erledigungs-ID",
"description": "Die ID der genehmigten Aufgabenerledigung, die auf ausstehend zurückgesetzt werden soll"
}
}
},
"undo_transaction": {
"name": "Transaktion rückgängig machen",
"description": "Eine Punktetransaktion anhand ihrer ID rückgängig machen — Strafen, Boni, manuelle Anpassungen (Hinzufügen/Entfernen) und Geschenke (beide Seiten). Automatische Transaktionen (Wochenend-/Serien-/Perfekte-Woche-Boni, Sparglas-Buchungen, Verfall, Zinsen) können nicht rückgängig gemacht werden.",
"fields": {
"transaction_id": {
"name": "Transaktions-ID",
"description": "Die ID der rückgängig zu machenden Punktetransaktion"
}
}
},
"claim_reward": {
"name": "Fordern Sie eine Belohnung an",
"description": "Das Kind erhält mit seinen Punkten eine Belohnung",
"fields": {
"reward_id": {
"name": "Prämien-ID",
"description": "Die ID der zu beanspruchenden Belohnung"
},
"child_id": {
"name": "Kinder-ID",
"description": "Die ID des Kindes, das die Belohnung beansprucht"
}
}
},
"approve_reward": {
"name": "Belohnung genehmigen",
"description": "Genehmigen Sie einen Prämienanspruch",
"fields": {
"claim_id": {
"name": "Anspruchs-ID",
"description": "Die ID des zu genehmigenden Prämienanspruchs"
}
}
},
"allocate_points_to_pool": {
"name": "Punkte dem Pool zuweisen",
"description": "Verschieben Sie Punkte vom ausgebbaren Guthaben eines Kindes in den Sparpool einer Belohnung (gesperrt).",
"fields": {
"child_id": {
"name": "Kinder-ID",
"description": "Die ID des Kindes, das die Zuweisung vornimmt"
},
"reward_id": {
"name": "Prämien-ID",
"description": "Die ID des Belohnungs-Pools, in den eingezahlt werden soll"
},
"points": {
"name": "Punkte",
"description": "Anzahl der zuzuweisenden Punkte (begrenzt durch Poolkapazität und ausgebbares Guthaben)"
}
}
},
"add_points": {
"name": "Punkte hinzufügen",
"description": "Fügen Sie einem Kind Bonuspunkte hinzu",
"fields": {
"child_id": {
"name": "Kinder-ID",
"description": "Die ID des Kindes, dem Punkte hinzugefügt werden sollen"
},
"points": {
"name": "Punkte",
"description": "Die Anzahl der hinzuzufügenden Punkte"
},
"reason": {
"name": "Grund",
"description": "Optionaler Grund für den Bonus"
}
}
},
"remove_points": {
"name": "Punkte entfernen",
"description": "Punkte von einem Kind abziehen (Strafe)",
"fields": {
"child_id": {
"name": "Kinder-ID",
"description": "Die ID des Kindes, von dem Punkte entfernt werden sollen"
},
"points": {
"name": "Punkte",
"description": "Die Anzahl der zu entfernenden Punkte"
},
"reason": {
"name": "Grund",
"description": "Optionaler Grund für die Strafe"
}
}
},
"reject_reward": {
"name": "Belohnung ablehnen",
"description": "Einen Prämienanspruch ablehnen",
"fields": {
"claim_id": {
"name": "Anspruchs-ID",
"description": "Die ID des abzulehnenden Prämienanspruchs"
}
}
},
"preview_sound": {
"name": "Vorschau des Tons",
"description": "Vorschau eines Abschlusssoundeffekts im Browser",
"fields": {
"sound": {
"name": "Klang",
"description": "Der Ton zur Vorschau"
}
}
},
"set_chore_order": {
"name": "Legen Sie die Aufgabenreihenfolge fest",
"description": "Legen Sie die Anzeigereihenfolge der Aufgaben für ein Kind fest",
"fields": {
"child_id": {
"name": "Kinder-ID",
"description": "Die ID des Kindes, für das die Aufgabenreihenfolge festgelegt werden soll"
},
"chore_order": {
"name": "Aufgabenreihenfolge",
"description": "Geordnete Liste der Aufgaben-IDs"
}
}
},
"add_penalty": {
"name": "Strafe hinzufügen",
"description": "Erstellen Sie eine neue Strafdefinition",
"fields": {
"name": {
"name": "Name",
"description": "Der Name der Strafe"
},
"points": {
"name": "Punkte",
"description": "Die Anzahl der Punkte, die bei der Anwendung abgezogen werden"
},
"description": {
"name": "Beschreibung",
"description": "Optionale Beschreibung der Strafe"
},
"icon": {
"name": "Symbol",
"description": "MDI-Symbol für den Elfmeter"
},
"assigned_to": {
"name": "Zugewiesen an",
"description": "Liste der Kinder-IDs, für die diese Strafe gilt"
}
}
},
"update_penalty": {
"name": "Strafe aktualisieren",
"description": "Aktualisieren Sie eine vorhandene Strafdefinition",
"fields": {
"penalty_id": {
"name": "Straf-ID",
"description": "Die ID der zu aktualisierenden Strafe"
},
"name": {
"name": "Name",
"description": "Neuer Name für den Elfmeter"
},
"points": {
"name": "Punkte",
"description": "Neuer Punktewert"
},
"description": {
"name": "Beschreibung",
"description": "Neue Beschreibung"
},
"icon": {
"name": "Symbol",
"description": "Neues Symbol"
},
"assigned_to": {
"name": "Zugewiesen an",
"description": "Neue Liste der Kinder-IDs"
}
}
},
"remove_penalty": {
"name": "Strafe entfernen",
"description": "Entfernen Sie eine Strafdefinition",
"fields": {
"penalty_id": {
"name": "Straf-ID",
"description": "Die ID der zu entfernenden Strafe"
}
}
},
"apply_penalty": {
"name": "Strafe anwenden",
"description": "Verhängen Sie eine Strafe gegen ein Kind und ziehen Sie Punkte ab",
"fields": {
"penalty_id": {
"name": "Straf-ID",
"description": "Die ID der anzuwendenden Strafe"
},
"child_id": {
"name": "Kinder-ID",
"description": "Die ID des Kindes, auf das die Strafe angewendet werden soll"
}
}
},
"add_bonus": {
"name": "Bonus hinzufügen",
"description": "Erstellen Sie eine neue Bonusdefinition",
"fields": {
"name": {
"name": "Name",
"description": "Der Name des Bonus"
},
"points": {
"name": "Punkte",
"description": "Die Anzahl der bei der Bewerbung zu vergebenden Punkte"
},
"description": {
"name": "Beschreibung",
"description": "Optionale Beschreibung des Bonus"
},
"icon": {
"name": "Symbol",
"description": "MDI-Symbol für den Bonus"
},
"assigned_to": {
"name": "Zugewiesen an",
"description": "Liste der Kinder-IDs, für die dieser Bonus gilt"
}
}
},
"update_bonus": {
"name": "Update-Bonus",
"description": "Aktualisieren Sie eine vorhandene Bonusdefinition",
"fields": {
"bonus_id": {
"name": "Bonus-ID",
"description": "Die ID des zu aktualisierenden Bonus"
},
"name": {
"name": "Name",
"description": "Neuer Name für den Bonus"
},
"points": {
"name": "Punkte",
"description": "Neuer Punktewert"
},
"description": {
"name": "Beschreibung",
"description": "Neue Beschreibung"
},
"icon": {
"name": "Symbol",
"description": "Neues Symbol"
},
"assigned_to": {
"name": "Zugewiesen an",
"description": "Neue Liste der Kinder-IDs"
}
}
},
"remove_bonus": {
"name": "Bonus entfernen",
"description": "Entfernen Sie eine Bonusdefinition",
"fields": {
"bonus_id": {
"name": "Bonus-ID",
"description": "Die ID des zu entfernenden Bonus"
}
}
},
"apply_bonus": {
"name": "Bonus anwenden",
"description": "Geben Sie einem Kind einen Bonus und vergeben Sie Punkte",
"fields": {
"bonus_id": {
"name": "Bonus-ID",
"description": "Die ID des anzuwendenden Bonus"
},
"child_id": {
"name": "Kinder-ID",
"description": "Die ID des Kindes, auf das der Bonus angewendet werden soll"
}
}
},
"add_chore": {
"name": "Aufgaben hinzufügen",
"description": "Erstellen Sie dynamisch eine neue Aufgabe, normalerweise aus einer Automatisierung oder einem Skript",
"fields": {
"name": {
"name": "Name",
"description": "Der Name der Aufgabe"
},
"description": {
"name": "Beschreibung",
"description": "Optionale Beschreibung der Aufgabe"
},
"points": {
"name": "Punkte",
"description": "Punkte werden vergeben, wenn diese Aufgabe erledigt ist"
},
"assigned_to": {
"name": "Zugewiesen an",
"description": "Liste der Kinder-IDs, denen diese Aufgabe zugewiesen werden soll. Lassen Sie das Feld leer, um es allen Kindern zuzuweisen"
},
"time_category": {
"name": "Zeitkategorie",
"description": "Wann diese Aufgabe erledigt sein sollte: morgens, nachmittags, abends, abends oder jederzeit"
},
"one_shot": {
"name": "One-Shot",
"description": "Wenn dies zutrifft, handelt es sich um eine einmalige Aufgabe, die nach Abschluss oder am Ende des Tages abläuft"
},
"requires_approval": {
"name": "Erfordert eine Genehmigung",
"description": "Wenn dies zutrifft, muss ein Elternteil den Abschluss genehmigen, bevor Punkte vergeben werden"
}
}
},
"skip_chore": {
"name": "Überspringen Sie lästige Aufgaben",
"description": "Führen Sie die heutige Rotation über den aktuellen Beauftragten hinaus weiter. Ephemeral die morgige Rotation wird wie gewohnt fortgesetzt.",
"fields": {
"chore_id": {
"name": "Aufgaben-ID",
"description": "Die ID der Aufgabe, die übersprungen werden soll"
}
}
},
"set_chore_manual_start": {
"name": "Manuelles Start-Kind festlegen",
"description": "Legen Sie fest, welches Kind eine rotierende Aufgabe beginnt. Im Wechselmodus ordnet dies den Pool neu; Bei „Zufällig/Ausgeglichen“ überschreibt es nur den heutigen Beauftragten.",
"fields": {
"chore_id": {
"name": "Aufgaben-ID",
"description": "Die ID des zu aktualisierenden Jobs"
},
"child_id": {
"name": "Kinder-ID",
"description": "Die ID des Kindes, das die Rotation starten soll"
}
}
},
"add_task_group": {
"name": "Aufgabengruppe hinzufügen",
"description": "Erstellen Sie eine Aufgabengruppe (festgelegt = dasselbe untergeordnete Element, verteilt = verschiedene untergeordnete Elemente).",
"fields": {
"name": {
"name": "Name",
"description": "Anzeigename für die Gruppe"
},
"policy": {
"name": "Richtlinie",
"description": "festglegt (gleiches Kind) oder verteilt (verschiedene Kinder)"
},
"chore_ids": {
"name": "Aufgaben-IDs",
"description": "Liste der Aufgaben-IDs in der Gruppe. Für Festgelegt ist die erste Aufgabe der Anführer."
}
}
},
"update_task_group": {
"name": "Aufgabengruppe aktualisieren",
"description": "Aktualisieren Sie den Namen, die Richtlinie oder die Mitgliedschaft einer Aufgabengruppe.",
"fields": {
"group_id": {
"name": "Gruppen-ID",
"description": "Die ID der zu aktualisierenden Gruppe"
},
"name": {
"name": "Name",
"description": "Neuer Anzeigename"
},
"policy": {
"name": "Richtlinie",
"description": "Neue Richtlinie (festgelegt oder verteilt)"
},
"chore_ids": {
"name": "Aufgaben-IDs",
"description": "Ersatz-Aufgaben-ID-Liste. Für Festgelegt ist die erste Aufgabe der Anführer."
}
}
},
"remove_task_group": {
"name": "Aufgabengruppe entfernen",
"description": "Löschen Sie eine Aufgabengruppe. Die Aufgaben der Mitglieder sind nicht betroffen.",
"fields": {
"group_id": {
"name": "Gruppen-ID",
"description": "Die ID der zu löschenden Gruppe"
}
}
},
"start_timed_task": {
"name": "Zeitbasierte Aufgabe starten",
"description": "Starten Sie den Timer einer zeitbasierten Aufgabe oder setzen Sie ihn fort",
"fields": {
"chore_id": {
"name": "Aufgaben-ID",
"description": "Die ID der zeitbasierten Aufgabe"
},
"child_id": {
"name": "Kinder-ID",
"description": "Die ID des Kindes, das den Timer startet"
}
}
},
"pause_timed_task": {
"name": "Zeitbasierte Aufgabe pausieren",
"description": "Pausieren Sie den laufenden Timer einer zeitbasierten Aufgabe",
"fields": {
"chore_id": {
"name": "Aufgaben-ID",
"description": "Die ID der zeitbasierten Aufgabe"
},
"child_id": {
"name": "Kinder-ID",
"description": "Die ID des Kindes, das den Timer pausiert"
}
}
},
"stop_timed_task": {
"name": "Zeitbasierte Aufgabe stoppen",
"description": "Stoppen Sie den Timer einer zeitbasierten Aufgabe und reichen Sie sie zur Genehmigung ein",
"fields": {
"chore_id": {
"name": "Aufgaben-ID",
"description": "Die ID der zeitbasierten Aufgabe"
},
"child_id": {
"name": "Kinder-ID",
"description": "Die ID des Kindes, das den Timer stoppt"
}
}
},
"add_badge": {
"name": "Abzeichen hinzufügen",
"description": "Erstellen Sie ein eigenes Abzeichen.",
"fields": {
"name": {
"name": "Name",
"description": "Der Anzeigename des Abzeichens"
},
"description": {
"name": "Beschreibung",
"description": "Beschreibung, wie das Abzeichen verdient wird"
},
"icon": {
"name": "Symbol",
"description": "Symbol für das Abzeichen (z. B. mdi:beach)"
},
"tier": {
"name": "Stufe",
"description": "Stufe des Abzeichens: Bronze, Silber, Gold oder Platin"
},
"point_bonus": {
"name": "Punktebonus",
"description": "Punkte, die dem Kind gutgeschrieben werden, wenn es das Abzeichen verdient"
},
"criteria": {
"name": "Kriterien",
"description": "Liste von metric / operator / value-Einträgen. Leer = nur manuelle Vergabe."
},
"assigned_to": {
"name": "Zugewiesen an",
"description": "Liste von Kinder-IDs (leer = alle)"
},
"notify_on_earn": {
"name": "Benachrichtigung beim Verdienen",
"description": "Senden Sie eine Benachrichtigung, wenn das Abzeichen verdient wird"
}
}
},
"update_badge": {
"name": "Abzeichen aktualisieren",
"description": "Aktualisieren Sie ein vorhandenes Abzeichen. Bei integrierten Abzeichen sind nur point_bonus, tier, assigned_to, enabled und notify_on_earn bearbeitbar.",
"fields": {
"badge_id": {
"name": "Abzeichen-ID",
"description": "Die ID des zu aktualisierenden Abzeichens"
},
"name": {
"name": "Name",
"description": "Neuer Anzeigename des Abzeichens"
},
"description": {
"name": "Beschreibung",
"description": "Neue Beschreibung des Abzeichens"
},
"icon": {
"name": "Symbol",
"description": "Neues Symbol für das Abzeichen"
},
"tier": {
"name": "Stufe",
"description": "Stufe des Abzeichens: Bronze, Silber, Gold oder Platin"
},
"point_bonus": {
"name": "Punktebonus",
"description": "Punkte, die dem Kind gutgeschrieben werden, wenn es das Abzeichen verdient"
},
"criteria": {
"name": "Kriterien",
"description": "Liste von metric / operator / value-Einträgen. Leer = nur manuelle Vergabe."
},
"assigned_to": {
"name": "Zugewiesen an",
"description": "Liste von Kinder-IDs (leer = alle)"
},
"enabled": {
"name": "Aktiviert",
"description": "Ob das Abzeichen aktiv ist"
},
"notify_on_earn": {
"name": "Benachrichtigung beim Verdienen",
"description": "Senden Sie eine Benachrichtigung, wenn das Abzeichen verdient wird"
}
}
},
"remove_badge": {
"name": "Abzeichen entfernen",
"description": "Entfernen Sie ein eigenes Abzeichen. Integrierte Abzeichen können nicht entfernt werden.",
"fields": {
"badge_id": {
"name": "Abzeichen-ID",
"description": "Die ID des zu entfernenden Abzeichens"
}
}
},
"award_badge_manually": {
"name": "Abzeichen manuell vergeben",
"description": "Vergeben Sie ein Abzeichen manuell an ein Kind.",
"fields": {
"badge_id": {
"name": "Abzeichen-ID",
"description": "Die ID des zu vergebenden Abzeichens"
},
"child_id": {
"name": "Kinder-ID",
"description": "Die ID des Kindes, das das Abzeichen erhält"
}
}
},
"revoke_badge": {
"name": "Abzeichen widerrufen",
"description": "Widerrufen Sie ein vergebenes Abzeichen. Wenn ein Punktebonus gutgeschrieben wurde, wird er rückgängig gemacht.",
"fields": {
"awarded_badge_id": {
"name": "ID des vergebenen Abzeichens",
"description": "Die ID des vergebenen Abzeichens, das widerrufen werden soll"
}
}
},
"rebuild_badges": {
"name": "Abzeichen neu berechnen",
"description": "Bewerten Sie alle Abzeichen für alle Kinder im Hintergrund neu (keine Benachrichtigungen, keine Punkteboni)."
},
"apply_mandatory_penalty": {
"name": "Pflichtstrafe anwenden",
"description": "Zieht die konfigurierten Strafpunkte für eine verpasste Pflichtaufgabe ab und entfernt den Prüfeintrag.",
"fields": {
"miss_id": {
"name": "Versäumnis-ID",
"description": "Die ID des Prüfeintrags für die verpasste Pflichtaufgabe"
}
}
},
"choose_avatar": {
"name": "Avatar wählen",
"description": "Ein Kind wechselt zu einem freigeschalteten Avatar.",
"fields": {
"child_id": {
"name": "Kind-ID",
"description": "Die ID des Kindes"
},
"icon": {
"name": "Avatar-Symbol",
"description": "Das mdi-Symbol eines freigeschalteten Katalog-Avatars (z. B. mdi:rocket-launch)."
}
}
},
"dismiss_mandatory_chore": {
"name": "Pflichtaufgabe verwerfen",
"description": "Entfernt den Prüfeintrag einer verpassten Pflichtaufgabe ohne Strafe.",
"fields": {
"miss_id": {
"name": "Versäumnis-ID",
"description": "Die ID des Prüfeintrags für die verpasste Pflichtaufgabe"
}
}
},
"gift_points": {
"name": "Punkte verschenken",
"description": "Überträgt ausgebbare Punkte von einem Kind auf ein anderes (elterngesteuert).",
"fields": {
"from_child_id": {
"name": "Von Kind-ID",
"description": "Die ID des Kindes, das Punkte sendet"
},
"to_child_id": {
"name": "An Kind-ID",
"description": "Die ID des Kindes, das Punkte erhält"
},
"points": {
"name": "Punkte",
"description": "Die Anzahl der zu übertragenden Punkte"
}
}
},
"postpone_mandatory_chore": {
"name": "Pflichtaufgabe verschieben",
"description": "Gewährt einer verpassten Pflichtaufgabe ohne Strafe einen weiteren Zeitraum (oder morgen) und entfernt den Prüfeintrag.",
"fields": {
"miss_id": {
"name": "Versäumnis-ID",
"description": "Die ID des Prüfeintrags für die verpasste Pflichtaufgabe"
}
}
},
"request_swap": {
"name": "Aufgabentausch anfragen",
"description": "Ein Kind beantragt, die heutige Rotationszuweisung einer Aufgabe zu übernehmen (Genehmigung der Eltern erforderlich).",
"fields": {
"chore_id": {
"name": "Aufgaben-ID",
"description": "Die ID der zu übernehmenden Aufgabe"
},
"requester_id": {
"name": "Anfragende Kind-ID",
"description": "Die ID des Kindes, das den Tausch beantragt"
}
}
},
"test_notification": {
"name": "Test-Benachrichtigung",
"description": "Sendet eine Beispiel-Benachrichtigung des angegebenen Typs an die aktivierten Empfänger (ignoriert den Hauptschalter, damit das Routing geprüft werden kann).",
"fields": {
"type_id": {
"name": "Benachrichtigungstyp",
"description": "ID des Benachrichtigungstyps, z. B. pending_chore_approval, badge_earned, streak_milestone"
}
}
},
"record_allowance_payout": {
"name": "Taschengeld-Auszahlung erfassen",
"description": "Punkte abziehen und eine reale Taschengeld-Auszahlung im Verlauf erfassen (fester Umrechnungskurs; elterngesteuert).",
"fields": {
"child_id": {
"name": "Kind-ID",
"description": "Die ID des Kindes, das die Auszahlung erhält"
},
"points": {
"name": "Punkte",
"description": "Punkte, die in Bargeld umgerechnet und abgezogen werden"
}
}
}
},
"entity": {
"sensor": {
"child_stats": {
"unit_of_measurement": "Aufgaben"
},
"child_badges": {
"unit_of_measurement": "Abzeichen"
}
},
"number": {
"weekend_multiplier": {
"name": "Wochenend-Multiplikator"
},
"perfect_week_bonus": {
"name": "Bonus für perfekte Woche"
}
},
"select": {
"streak_reset_mode": {
"name": "Streak-Zurücksetzmodus"
},
"card_design": {
"name": "Kartendesign"
}
}
}
}
@@ -0,0 +1,884 @@
{
"config": {
"step": {
"user": {
"title": "Welcome to TaskMate!",
"description": "Set up your family chore management system. You can customize the points currency that children will earn.",
"data": {
"points_name": "Points Currency Name (e.g., Stars, Coins, Bucks)",
"points_icon": "Points Icon"
}
}
},
"error": {
"name_required": "Name is required"
},
"abort": {
"already_configured": "TaskMate is already configured"
}
},
"selector": {
"chore_action": {
"options": {
"save": "Save Changes",
"re_enable": "Re-enable Chore",
"disable": "Disable Chore",
"delete": "Delete This Chore"
}
},
"reward_action": {
"options": {
"save": "Save Changes",
"delete": "Delete Reward"
}
},
"time_category": {
"options": {
"morning": "Morning",
"afternoon": "Afternoon",
"evening": "Evening",
"night": "Night",
"anytime": "Anytime"
}
},
"schedule_mode": {
"options": {
"specific_days": "Specific days of the week",
"recurring": "Recurring (every N days/weeks/months)",
"one_shot": "One-time task (single day)"
}
},
"due_days_option": {
"options": {
"monday": "Monday",
"tuesday": "Tuesday",
"wednesday": "Wednesday",
"thursday": "Thursday",
"friday": "Friday",
"saturday": "Saturday",
"sunday": "Sunday"
}
},
"recurrence": {
"options": {
"every_2_days": "Every 2 days",
"weekly": "Weekly",
"every_2_weeks": "Every 2 weeks",
"monthly": "Monthly",
"every_3_months": "Every 3 months",
"every_6_months": "Every 6 months"
}
},
"recurrence_day_option": {
"options": {
"monday": "Monday",
"tuesday": "Tuesday",
"wednesday": "Wednesday",
"thursday": "Thursday",
"friday": "Friday",
"saturday": "Saturday",
"sunday": "Sunday",
"any_day": "Any day"
}
},
"first_occurrence_mode": {
"options": {
"available_immediately": "Available immediately",
"wait_for_first_occurrence": "Wait for first scheduled occurrence"
}
},
"streak_reset_mode": {
"options": {
"reset": "Reset — streak goes to 0 on missed day",
"pause": "Pause — streak preserved until next completion"
}
},
"visibility_operator": {
"options": {
"none": "No filter — Always show chore",
"equals": "Equals — Show when entity state matches exactly",
"not_equals": "Not Equal — Show when entity state does not match",
"gte": "Greater than or equal (≥) — Show when numeric value is ≥ target",
"lte": "Less than or equal (≤) — Show when numeric value is ≤ target",
"gt": "Greater than (>) — Show when numeric value is > target",
"lt": "Less than (<) — Show when numeric value is < target"
}
},
"assignment_mode": {
"options": {
"everyone": "Everyone — every assigned child sees the chore",
"alternating": "Alternating — rotate through the assigned children, one per day",
"random": "Random — pick one assigned child at random each day",
"balanced": "Balanced — split today's balanced chores evenly across the assigned children",
"first_come": "First come, first served — first child to complete it wins; it hides for the rest",
"unassigned": "Unassigned — no child sees this chore"
}
},
"task_group_policy": {
"options": {
"sticky": "Sticky — same child for all chores in the group",
"spread": "Spread — different children across the group today"
}
},
"task_group_action": {
"options": {
"save": "Save Changes",
"delete": "Delete Group"
}
}
},
"services": {
"complete_chore": {
"name": "Complete Chore",
"description": "Mark a chore as completed by a child",
"fields": {
"chore_id": {
"name": "Chore ID",
"description": "The ID of the chore to complete"
},
"child_id": {
"name": "Child ID",
"description": "The ID of the child completing the chore"
}
}
},
"complete_bonus_subtask": {
"name": "Complete Bonus Sub-task",
"description": "Complete a bonus sub-task after the parent chore is done",
"fields": {
"chore_id": {
"name": "Chore ID",
"description": "The ID of the parent chore"
},
"bonus_subtask_id": {
"name": "Bonus Sub-task ID",
"description": "The ID of the bonus sub-task to complete"
},
"child_id": {
"name": "Child ID",
"description": "The ID of the child completing the bonus sub-task"
}
}
},
"approve_chore": {
"name": "Approve Chore",
"description": "Approve a completed chore and award points",
"fields": {
"completion_id": {
"name": "Completion ID",
"description": "The ID of the chore completion to approve"
}
}
},
"approve_all_chores": {
"name": "Approve All Chores",
"description": "Approve all pending chore completions (or a specific set) and award points",
"fields": {
"completion_ids": {
"name": "Completion IDs",
"description": "Specific completion IDs to approve; omit to approve all pending"
}
}
},
"reject_chore": {
"name": "Reject Chore",
"description": "Reject a completed chore",
"fields": {
"completion_id": {
"name": "Completion ID",
"description": "The ID of the chore completion to reject"
}
}
},
"undo_chore_approval": {
"name": "Undo Chore Approval",
"description": "Reverse an accidental chore approval — removes the awarded points and returns the completion to pending so it can be approved again.",
"fields": {
"completion_id": {
"name": "Completion ID",
"description": "The ID of the approved chore completion to revert to pending"
}
}
},
"undo_transaction": {
"name": "Undo Transaction",
"description": "Reverse a points transaction by its ID — penalties, bonuses, manual add/remove adjustments and gifts (both legs). Automatic transactions (weekend/streak/perfect-week bonuses, pool moves, decay, interest) can't be undone.",
"fields": {
"transaction_id": {
"name": "Transaction ID",
"description": "The ID of the points transaction to reverse"
}
}
},
"claim_reward": {
"name": "Claim Reward",
"description": "Child claims a reward using their points",
"fields": {
"reward_id": {
"name": "Reward ID",
"description": "The ID of the reward to claim"
},
"child_id": {
"name": "Child ID",
"description": "The ID of the child claiming the reward"
}
}
},
"approve_reward": {
"name": "Approve Reward",
"description": "Approve a reward claim",
"fields": {
"claim_id": {
"name": "Claim ID",
"description": "The ID of the reward claim to approve"
}
}
},
"allocate_points_to_pool": {
"name": "Allocate Points to Pool",
"description": "Move points from a child's spendable balance into a reward's savings pool (locked).",
"fields": {
"child_id": {
"name": "Child ID",
"description": "The ID of the child making the allocation"
},
"reward_id": {
"name": "Reward ID",
"description": "The ID of the reward pool to deposit into"
},
"points": {
"name": "Points",
"description": "Number of points to allocate (capped at pool capacity and spendable balance)"
}
}
},
"add_points": {
"name": "Add Points",
"description": "Add bonus points to a child",
"fields": {
"child_id": {
"name": "Child ID",
"description": "The ID of the child to add points to"
},
"points": {
"name": "Points",
"description": "The number of points to add"
},
"reason": {
"name": "Reason",
"description": "Optional reason for the bonus"
}
}
},
"remove_points": {
"name": "Remove Points",
"description": "Remove points from a child (penalty)",
"fields": {
"child_id": {
"name": "Child ID",
"description": "The ID of the child to remove points from"
},
"points": {
"name": "Points",
"description": "The number of points to remove"
},
"reason": {
"name": "Reason",
"description": "Optional reason for the penalty"
}
}
},
"reject_reward": {
"name": "Reject Reward",
"description": "Reject a reward claim",
"fields": {
"claim_id": {
"name": "Claim ID",
"description": "The ID of the reward claim to reject"
}
}
},
"preview_sound": {
"name": "Preview Sound",
"description": "Preview a completion sound effect in the browser",
"fields": {
"sound": {
"name": "Sound",
"description": "The sound to preview"
}
}
},
"set_chore_order": {
"name": "Set Chore Order",
"description": "Set the display order of chores for a child",
"fields": {
"child_id": {
"name": "Child ID",
"description": "The ID of the child to set chore order for"
},
"chore_order": {
"name": "Chore Order",
"description": "Ordered list of chore IDs"
}
}
},
"add_penalty": {
"name": "Add Penalty",
"description": "Create a new penalty definition",
"fields": {
"name": {
"name": "Name",
"description": "The name of the penalty"
},
"points": {
"name": "Points",
"description": "The number of points to deduct when applied"
},
"description": {
"name": "Description",
"description": "Optional description of the penalty"
},
"icon": {
"name": "Icon",
"description": "MDI icon for the penalty"
},
"assigned_to": {
"name": "Assigned To",
"description": "List of child IDs this penalty applies to"
}
}
},
"update_penalty": {
"name": "Update Penalty",
"description": "Update an existing penalty definition",
"fields": {
"penalty_id": {
"name": "Penalty ID",
"description": "The ID of the penalty to update"
},
"name": {
"name": "Name",
"description": "New name for the penalty"
},
"points": {
"name": "Points",
"description": "New points value"
},
"description": {
"name": "Description",
"description": "New description"
},
"icon": {
"name": "Icon",
"description": "New icon"
},
"assigned_to": {
"name": "Assigned To",
"description": "New list of child IDs"
}
}
},
"remove_penalty": {
"name": "Remove Penalty",
"description": "Remove a penalty definition",
"fields": {
"penalty_id": {
"name": "Penalty ID",
"description": "The ID of the penalty to remove"
}
}
},
"apply_penalty": {
"name": "Apply Penalty",
"description": "Apply a penalty to a child, deducting points",
"fields": {
"penalty_id": {
"name": "Penalty ID",
"description": "The ID of the penalty to apply"
},
"child_id": {
"name": "Child ID",
"description": "The ID of the child to apply the penalty to"
}
}
},
"add_bonus": {
"name": "Add Bonus",
"description": "Create a new bonus definition",
"fields": {
"name": {
"name": "Name",
"description": "The name of the bonus"
},
"points": {
"name": "Points",
"description": "The number of points to award when applied"
},
"description": {
"name": "Description",
"description": "Optional description of the bonus"
},
"icon": {
"name": "Icon",
"description": "MDI icon for the bonus"
},
"assigned_to": {
"name": "Assigned To",
"description": "List of child IDs this bonus applies to"
}
}
},
"update_bonus": {
"name": "Update Bonus",
"description": "Update an existing bonus definition",
"fields": {
"bonus_id": {
"name": "Bonus ID",
"description": "The ID of the bonus to update"
},
"name": {
"name": "Name",
"description": "New name for the bonus"
},
"points": {
"name": "Points",
"description": "New points value"
},
"description": {
"name": "Description",
"description": "New description"
},
"icon": {
"name": "Icon",
"description": "New icon"
},
"assigned_to": {
"name": "Assigned To",
"description": "New list of child IDs"
}
}
},
"remove_bonus": {
"name": "Remove Bonus",
"description": "Remove a bonus definition",
"fields": {
"bonus_id": {
"name": "Bonus ID",
"description": "The ID of the bonus to remove"
}
}
},
"apply_bonus": {
"name": "Apply Bonus",
"description": "Apply a bonus to a child, awarding points",
"fields": {
"bonus_id": {
"name": "Bonus ID",
"description": "The ID of the bonus to apply"
},
"child_id": {
"name": "Child ID",
"description": "The ID of the child to apply the bonus to"
}
}
},
"add_chore": {
"name": "Add Chore",
"description": "Create a new chore dynamically, typically from an automation or script",
"fields": {
"name": {
"name": "Name",
"description": "The name of the chore"
},
"description": {
"name": "Description",
"description": "Optional description of the chore"
},
"points": {
"name": "Points",
"description": "Points awarded when this chore is completed"
},
"assigned_to": {
"name": "Assigned To",
"description": "List of child IDs to assign this chore to. Leave empty to assign to all children"
},
"time_category": {
"name": "Time Category",
"description": "When this chore should be completed: morning, afternoon, evening, night, or anytime"
},
"one_shot": {
"name": "One-Shot",
"description": "If true, this is a one-time chore that expires after being completed or at the end of the day"
},
"requires_approval": {
"name": "Requires Approval",
"description": "If true, a parent must approve the completion before points are awarded"
}
}
},
"skip_chore": {
"name": "Skip Chore",
"description": "Advance today's rotation past the current assignee. Ephemeral — tomorrow's rotation resumes as normal.",
"fields": {
"chore_id": {
"name": "Chore ID",
"description": "The ID of the chore to skip"
}
}
},
"set_chore_manual_start": {
"name": "Set Manual Start Child",
"description": "Set which child starts a rotating chore. For alternating mode this reorders the pool; for random/balanced it only overrides today's assignee.",
"fields": {
"chore_id": {
"name": "Chore ID",
"description": "The ID of the chore to update"
},
"child_id": {
"name": "Child ID",
"description": "The ID of the child who should start the rotation"
}
}
},
"add_task_group": {
"name": "Add Task Group",
"description": "Create a task group (sticky = same child, spread = different children).",
"fields": {
"name": {
"name": "Name",
"description": "Display name for the group"
},
"policy": {
"name": "Policy",
"description": "sticky (same child) or spread (different children)"
},
"chore_ids": {
"name": "Chore IDs",
"description": "List of chore IDs in the group. For sticky, the first chore is the leader."
}
}
},
"update_task_group": {
"name": "Update Task Group",
"description": "Update a task group's name, policy, or membership.",
"fields": {
"group_id": {
"name": "Group ID",
"description": "The ID of the group to update"
},
"name": {
"name": "Name",
"description": "New display name"
},
"policy": {
"name": "Policy",
"description": "New policy (sticky or spread)"
},
"chore_ids": {
"name": "Chore IDs",
"description": "Replacement chore ID list. For sticky, the first chore is the leader."
}
}
},
"remove_task_group": {
"name": "Remove Task Group",
"description": "Delete a task group. Member chores are not affected.",
"fields": {
"group_id": {
"name": "Group ID",
"description": "The ID of the group to delete"
}
}
},
"start_timed_task": {
"name": "Start Timed Task",
"description": "Start or resume the timer on a timed task",
"fields": {
"chore_id": {
"name": "Chore ID",
"description": "The ID of the timed chore"
},
"child_id": {
"name": "Child ID",
"description": "The ID of the child starting the timer"
}
}
},
"pause_timed_task": {
"name": "Pause Timed Task",
"description": "Pause a running timed task timer",
"fields": {
"chore_id": {
"name": "Chore ID",
"description": "The ID of the timed chore"
},
"child_id": {
"name": "Child ID",
"description": "The ID of the child pausing the timer"
}
}
},
"stop_timed_task": {
"name": "Stop Timed Task",
"description": "Stop a timed task timer and submit for approval",
"fields": {
"chore_id": {
"name": "Chore ID",
"description": "The ID of the timed chore"
},
"child_id": {
"name": "Child ID",
"description": "The ID of the child stopping the timer"
}
}
},
"add_badge": {
"name": "Add Badge",
"description": "Create a custom badge.",
"fields": {
"name": {
"name": "Name",
"description": "The display name of the badge"
},
"description": {
"name": "Description",
"description": "Description of how the badge is earned"
},
"icon": {
"name": "Icon",
"description": "Icon for the badge (e.g. mdi:beach)"
},
"tier": {
"name": "Tier",
"description": "Badge tier: bronze, silver, gold or platinum"
},
"point_bonus": {
"name": "Point bonus",
"description": "Points credited to the child when the badge is earned"
},
"criteria": {
"name": "Criteria",
"description": "List of metric / operator / value dicts. Empty = manual-award only."
},
"assigned_to": {
"name": "Assigned to",
"description": "List of child IDs (empty = all)"
},
"notify_on_earn": {
"name": "Notify on earn",
"description": "Send a notification when the badge is earned"
}
}
},
"update_badge": {
"name": "Update Badge",
"description": "Update an existing badge. For built-ins, only point_bonus, tier, assigned_to, enabled, notify_on_earn are editable.",
"fields": {
"badge_id": {
"name": "Badge ID",
"description": "The ID of the badge to update"
},
"name": {
"name": "Name",
"description": "New display name of the badge"
},
"description": {
"name": "Description",
"description": "New description of the badge"
},
"icon": {
"name": "Icon",
"description": "New icon for the badge"
},
"tier": {
"name": "Tier",
"description": "Badge tier: bronze, silver, gold or platinum"
},
"point_bonus": {
"name": "Point bonus",
"description": "Points credited to the child when the badge is earned"
},
"criteria": {
"name": "Criteria",
"description": "List of metric / operator / value dicts. Empty = manual-award only."
},
"assigned_to": {
"name": "Assigned to",
"description": "List of child IDs (empty = all)"
},
"enabled": {
"name": "Enabled",
"description": "Whether the badge is active"
},
"notify_on_earn": {
"name": "Notify on earn",
"description": "Send a notification when the badge is earned"
}
}
},
"remove_badge": {
"name": "Remove Badge",
"description": "Remove a custom badge. Built-ins cannot be removed.",
"fields": {
"badge_id": {
"name": "Badge ID",
"description": "The ID of the badge to remove"
}
}
},
"award_badge_manually": {
"name": "Award Badge Manually",
"description": "Manually award a badge to a child.",
"fields": {
"badge_id": {
"name": "Badge ID",
"description": "The ID of the badge to award"
},
"child_id": {
"name": "Child ID",
"description": "The ID of the child receiving the badge"
}
}
},
"revoke_badge": {
"name": "Revoke Badge",
"description": "Revoke an awarded badge. If a point bonus was credited, it is reversed.",
"fields": {
"awarded_badge_id": {
"name": "Awarded Badge ID",
"description": "The ID of the awarded badge record to revoke"
}
}
},
"rebuild_badges": {
"name": "Rebuild Badges",
"description": "Re-evaluate all badges across all children silently (no notifications, no point bonuses)."
},
"apply_mandatory_penalty": {
"name": "Apply Mandatory Penalty",
"description": "Deduct the configured penalty points for a missed mandatory chore and clear the review item.",
"fields": {
"miss_id": {
"name": "Miss ID",
"description": "The ID of the mandatory-miss review item"
}
}
},
"choose_avatar": {
"name": "Choose Avatar",
"description": "A child switches to an avatar they have unlocked.",
"fields": {
"child_id": {
"name": "Child ID",
"description": "The ID of the child"
},
"icon": {
"name": "Avatar icon",
"description": "The mdi icon of an unlocked catalogue avatar (e.g. mdi:rocket-launch)."
}
}
},
"dismiss_mandatory_chore": {
"name": "Dismiss Mandatory Chore",
"description": "Clear a missed mandatory chore review item with no penalty.",
"fields": {
"miss_id": {
"name": "Miss ID",
"description": "The ID of the mandatory-miss review item"
}
}
},
"gift_points": {
"name": "Gift Points",
"description": "Transfer spendable points from one child to another (parent-controlled).",
"fields": {
"from_child_id": {
"name": "From child ID",
"description": "The ID of the child sending points"
},
"to_child_id": {
"name": "To child ID",
"description": "The ID of the child receiving points"
},
"points": {
"name": "Points",
"description": "The number of points to transfer"
}
}
},
"postpone_mandatory_chore": {
"name": "Postpone Mandatory Chore",
"description": "Give a missed mandatory chore another period (or tomorrow) without a penalty, and clear the review item.",
"fields": {
"miss_id": {
"name": "Miss ID",
"description": "The ID of the mandatory-miss review item"
}
}
},
"request_swap": {
"name": "Request Chore Swap",
"description": "A child requests to take over today's rotation assignment of a chore (pending parent approval).",
"fields": {
"chore_id": {
"name": "Chore ID",
"description": "The ID of the chore to take over"
},
"requester_id": {
"name": "Requester child ID",
"description": "The ID of the child requesting the swap"
}
}
},
"test_notification": {
"name": "Test Notification",
"description": "Send a sample notification of the given type to its enabled recipients (ignores the master on/off so routing can be verified).",
"fields": {
"type_id": {
"name": "Notification type",
"description": "Notification type id, e.g. pending_chore_approval, badge_earned, streak_milestone"
}
}
},
"record_allowance_payout": {
"name": "Record Allowance Payout",
"description": "Deduct points and record a real-money allowance payout in the ledger (fixed conversion rate; parent-controlled).",
"fields": {
"child_id": {
"name": "Child ID",
"description": "The ID of the child receiving the payout"
},
"points": {
"name": "Points",
"description": "Points to convert to cash and deduct"
}
}
}
},
"entity": {
"sensor": {
"child_stats": {
"unit_of_measurement": "chores"
},
"child_badges": {
"unit_of_measurement": "badges"
}
},
"number": {
"weekend_multiplier": {
"name": "Weekend multiplier"
},
"perfect_week_bonus": {
"name": "Perfect week bonus"
}
},
"select": {
"streak_reset_mode": {
"name": "Streak reset mode"
},
"card_design": {
"name": "Card design"
}
}
}
}
@@ -0,0 +1,884 @@
{
"config": {
"step": {
"user": {
"title": "Welcome to TaskMate!",
"description": "Set up your family chore management system. You can customize the points currency that children will earn.",
"data": {
"points_name": "Points Currency Name (e.g., Stars, Coins, Bucks)",
"points_icon": "Points Icon"
}
}
},
"error": {
"name_required": "Name is required"
},
"abort": {
"already_configured": "TaskMate is already configured"
}
},
"selector": {
"chore_action": {
"options": {
"save": "Save Changes",
"re_enable": "Re-enable Chore",
"disable": "Disable Chore",
"delete": "Delete This Chore"
}
},
"reward_action": {
"options": {
"save": "Save Changes",
"delete": "Delete Reward"
}
},
"time_category": {
"options": {
"morning": "Morning",
"afternoon": "Afternoon",
"evening": "Evening",
"night": "Night",
"anytime": "Anytime"
}
},
"schedule_mode": {
"options": {
"specific_days": "Specific days of the week",
"recurring": "Recurring (every N days/weeks/months)",
"one_shot": "One-time task (single day)"
}
},
"due_days_option": {
"options": {
"monday": "Monday",
"tuesday": "Tuesday",
"wednesday": "Wednesday",
"thursday": "Thursday",
"friday": "Friday",
"saturday": "Saturday",
"sunday": "Sunday"
}
},
"recurrence": {
"options": {
"every_2_days": "Every 2 days",
"weekly": "Weekly",
"every_2_weeks": "Every 2 weeks",
"monthly": "Monthly",
"every_3_months": "Every 3 months",
"every_6_months": "Every 6 months"
}
},
"recurrence_day_option": {
"options": {
"monday": "Monday",
"tuesday": "Tuesday",
"wednesday": "Wednesday",
"thursday": "Thursday",
"friday": "Friday",
"saturday": "Saturday",
"sunday": "Sunday",
"any_day": "Any day"
}
},
"first_occurrence_mode": {
"options": {
"available_immediately": "Available immediately",
"wait_for_first_occurrence": "Wait for first scheduled occurrence"
}
},
"streak_reset_mode": {
"options": {
"reset": "Reset — streak goes to 0 on missed day",
"pause": "Pause — streak preserved until next completion"
}
},
"visibility_operator": {
"options": {
"none": "No filter — Always show chore",
"equals": "Equals — Show when entity state matches exactly",
"not_equals": "Not Equal — Show when entity state does not match",
"gte": "Greater than or equal (≥) — Show when numeric value is ≥ target",
"lte": "Less than or equal (≤) — Show when numeric value is ≤ target",
"gt": "Greater than (>) — Show when numeric value is > target",
"lt": "Less than (<) — Show when numeric value is < target"
}
},
"assignment_mode": {
"options": {
"everyone": "Everyone — every assigned child sees the chore",
"alternating": "Alternating — rotate through the assigned children, one per day",
"random": "Random — pick one assigned child at random each day",
"balanced": "Balanced — split today's balanced chores evenly across the assigned children",
"first_come": "First come, first served — first child to complete it wins; it hides for the rest",
"unassigned": "Unassigned — no child sees this chore"
}
},
"task_group_policy": {
"options": {
"sticky": "Sticky — same child for all chores in the group",
"spread": "Spread — different children across the group today"
}
},
"task_group_action": {
"options": {
"save": "Save Changes",
"delete": "Delete Group"
}
}
},
"services": {
"complete_chore": {
"name": "Complete Chore",
"description": "Mark a chore as completed by a child",
"fields": {
"chore_id": {
"name": "Chore ID",
"description": "The ID of the chore to complete"
},
"child_id": {
"name": "Child ID",
"description": "The ID of the child completing the chore"
}
}
},
"complete_bonus_subtask": {
"name": "Complete Bonus Sub-task",
"description": "Complete a bonus sub-task after the parent chore is done",
"fields": {
"chore_id": {
"name": "Chore ID",
"description": "The ID of the parent chore"
},
"bonus_subtask_id": {
"name": "Bonus Sub-task ID",
"description": "The ID of the bonus sub-task to complete"
},
"child_id": {
"name": "Child ID",
"description": "The ID of the child completing the bonus sub-task"
}
}
},
"approve_chore": {
"name": "Approve Chore",
"description": "Approve a completed chore and award points",
"fields": {
"completion_id": {
"name": "Completion ID",
"description": "The ID of the chore completion to approve"
}
}
},
"approve_all_chores": {
"name": "Approve All Chores",
"description": "Approve all pending chore completions (or a specific set) and award points",
"fields": {
"completion_ids": {
"name": "Completion IDs",
"description": "Specific completion IDs to approve; omit to approve all pending"
}
}
},
"reject_chore": {
"name": "Reject Chore",
"description": "Reject a completed chore",
"fields": {
"completion_id": {
"name": "Completion ID",
"description": "The ID of the chore completion to reject"
}
}
},
"undo_chore_approval": {
"name": "Undo Chore Approval",
"description": "Reverse an accidental chore approval — removes the awarded points and returns the completion to pending so it can be approved again.",
"fields": {
"completion_id": {
"name": "Completion ID",
"description": "The ID of the approved chore completion to revert to pending"
}
}
},
"undo_transaction": {
"name": "Undo Transaction",
"description": "Reverse a points transaction by its ID — penalties, bonuses, manual add/remove adjustments and gifts (both legs). Automatic transactions (weekend/streak/perfect-week bonuses, pool moves, decay, interest) can't be undone.",
"fields": {
"transaction_id": {
"name": "Transaction ID",
"description": "The ID of the points transaction to reverse"
}
}
},
"claim_reward": {
"name": "Claim Reward",
"description": "Child claims a reward using their points",
"fields": {
"reward_id": {
"name": "Reward ID",
"description": "The ID of the reward to claim"
},
"child_id": {
"name": "Child ID",
"description": "The ID of the child claiming the reward"
}
}
},
"approve_reward": {
"name": "Approve Reward",
"description": "Approve a reward claim",
"fields": {
"claim_id": {
"name": "Claim ID",
"description": "The ID of the reward claim to approve"
}
}
},
"allocate_points_to_pool": {
"name": "Allocate Points to Pool",
"description": "Move points from a child's spendable balance into a reward's savings pool (locked).",
"fields": {
"child_id": {
"name": "Child ID",
"description": "The ID of the child making the allocation"
},
"reward_id": {
"name": "Reward ID",
"description": "The ID of the reward pool to deposit into"
},
"points": {
"name": "Points",
"description": "Number of points to allocate (capped at pool capacity and spendable balance)"
}
}
},
"add_points": {
"name": "Add Points",
"description": "Add bonus points to a child",
"fields": {
"child_id": {
"name": "Child ID",
"description": "The ID of the child to add points to"
},
"points": {
"name": "Points",
"description": "The number of points to add"
},
"reason": {
"name": "Reason",
"description": "Optional reason for the bonus"
}
}
},
"remove_points": {
"name": "Remove Points",
"description": "Remove points from a child (penalty)",
"fields": {
"child_id": {
"name": "Child ID",
"description": "The ID of the child to remove points from"
},
"points": {
"name": "Points",
"description": "The number of points to remove"
},
"reason": {
"name": "Reason",
"description": "Optional reason for the penalty"
}
}
},
"reject_reward": {
"name": "Reject Reward",
"description": "Reject a reward claim",
"fields": {
"claim_id": {
"name": "Claim ID",
"description": "The ID of the reward claim to reject"
}
}
},
"preview_sound": {
"name": "Preview Sound",
"description": "Preview a completion sound effect in the browser",
"fields": {
"sound": {
"name": "Sound",
"description": "The sound to preview"
}
}
},
"set_chore_order": {
"name": "Set Chore Order",
"description": "Set the display order of chores for a child",
"fields": {
"child_id": {
"name": "Child ID",
"description": "The ID of the child to set chore order for"
},
"chore_order": {
"name": "Chore Order",
"description": "Ordered list of chore IDs"
}
}
},
"add_penalty": {
"name": "Add Penalty",
"description": "Create a new penalty definition",
"fields": {
"name": {
"name": "Name",
"description": "The name of the penalty"
},
"points": {
"name": "Points",
"description": "The number of points to deduct when applied"
},
"description": {
"name": "Description",
"description": "Optional description of the penalty"
},
"icon": {
"name": "Icon",
"description": "MDI icon for the penalty"
},
"assigned_to": {
"name": "Assigned To",
"description": "List of child IDs this penalty applies to"
}
}
},
"update_penalty": {
"name": "Update Penalty",
"description": "Update an existing penalty definition",
"fields": {
"penalty_id": {
"name": "Penalty ID",
"description": "The ID of the penalty to update"
},
"name": {
"name": "Name",
"description": "New name for the penalty"
},
"points": {
"name": "Points",
"description": "New points value"
},
"description": {
"name": "Description",
"description": "New description"
},
"icon": {
"name": "Icon",
"description": "New icon"
},
"assigned_to": {
"name": "Assigned To",
"description": "New list of child IDs"
}
}
},
"remove_penalty": {
"name": "Remove Penalty",
"description": "Remove a penalty definition",
"fields": {
"penalty_id": {
"name": "Penalty ID",
"description": "The ID of the penalty to remove"
}
}
},
"apply_penalty": {
"name": "Apply Penalty",
"description": "Apply a penalty to a child, deducting points",
"fields": {
"penalty_id": {
"name": "Penalty ID",
"description": "The ID of the penalty to apply"
},
"child_id": {
"name": "Child ID",
"description": "The ID of the child to apply the penalty to"
}
}
},
"add_bonus": {
"name": "Add Bonus",
"description": "Create a new bonus definition",
"fields": {
"name": {
"name": "Name",
"description": "The name of the bonus"
},
"points": {
"name": "Points",
"description": "The number of points to award when applied"
},
"description": {
"name": "Description",
"description": "Optional description of the bonus"
},
"icon": {
"name": "Icon",
"description": "MDI icon for the bonus"
},
"assigned_to": {
"name": "Assigned To",
"description": "List of child IDs this bonus applies to"
}
}
},
"update_bonus": {
"name": "Update Bonus",
"description": "Update an existing bonus definition",
"fields": {
"bonus_id": {
"name": "Bonus ID",
"description": "The ID of the bonus to update"
},
"name": {
"name": "Name",
"description": "New name for the bonus"
},
"points": {
"name": "Points",
"description": "New points value"
},
"description": {
"name": "Description",
"description": "New description"
},
"icon": {
"name": "Icon",
"description": "New icon"
},
"assigned_to": {
"name": "Assigned To",
"description": "New list of child IDs"
}
}
},
"remove_bonus": {
"name": "Remove Bonus",
"description": "Remove a bonus definition",
"fields": {
"bonus_id": {
"name": "Bonus ID",
"description": "The ID of the bonus to remove"
}
}
},
"apply_bonus": {
"name": "Apply Bonus",
"description": "Apply a bonus to a child, awarding points",
"fields": {
"bonus_id": {
"name": "Bonus ID",
"description": "The ID of the bonus to apply"
},
"child_id": {
"name": "Child ID",
"description": "The ID of the child to apply the bonus to"
}
}
},
"add_chore": {
"name": "Add Chore",
"description": "Create a new chore dynamically, typically from an automation or script",
"fields": {
"name": {
"name": "Name",
"description": "The name of the chore"
},
"description": {
"name": "Description",
"description": "Optional description of the chore"
},
"points": {
"name": "Points",
"description": "Points awarded when this chore is completed"
},
"assigned_to": {
"name": "Assigned To",
"description": "List of child IDs to assign this chore to. Leave empty to assign to all children"
},
"time_category": {
"name": "Time Category",
"description": "When this chore should be completed: morning, afternoon, evening, night, or anytime"
},
"one_shot": {
"name": "One-Shot",
"description": "If true, this is a one-time chore that expires after being completed or at the end of the day"
},
"requires_approval": {
"name": "Requires Approval",
"description": "If true, a parent must approve the completion before points are awarded"
}
}
},
"skip_chore": {
"name": "Skip Chore",
"description": "Advance today's rotation past the current assignee. Ephemeral — tomorrow's rotation resumes as normal.",
"fields": {
"chore_id": {
"name": "Chore ID",
"description": "The ID of the chore to skip"
}
}
},
"set_chore_manual_start": {
"name": "Set Manual Start Child",
"description": "Set which child starts a rotating chore. For alternating mode this reorders the pool; for random/balanced it only overrides today's assignee.",
"fields": {
"chore_id": {
"name": "Chore ID",
"description": "The ID of the chore to update"
},
"child_id": {
"name": "Child ID",
"description": "The ID of the child who should start the rotation"
}
}
},
"add_task_group": {
"name": "Add Task Group",
"description": "Create a task group (sticky = same child, spread = different children).",
"fields": {
"name": {
"name": "Name",
"description": "Display name for the group"
},
"policy": {
"name": "Policy",
"description": "sticky (same child) or spread (different children)"
},
"chore_ids": {
"name": "Chore IDs",
"description": "List of chore IDs in the group. For sticky, the first chore is the leader."
}
}
},
"update_task_group": {
"name": "Update Task Group",
"description": "Update a task group's name, policy, or membership.",
"fields": {
"group_id": {
"name": "Group ID",
"description": "The ID of the group to update"
},
"name": {
"name": "Name",
"description": "New display name"
},
"policy": {
"name": "Policy",
"description": "New policy (sticky or spread)"
},
"chore_ids": {
"name": "Chore IDs",
"description": "Replacement chore ID list. For sticky, the first chore is the leader."
}
}
},
"remove_task_group": {
"name": "Remove Task Group",
"description": "Delete a task group. Member chores are not affected.",
"fields": {
"group_id": {
"name": "Group ID",
"description": "The ID of the group to delete"
}
}
},
"start_timed_task": {
"name": "Start Timed Task",
"description": "Start or resume the timer on a timed task",
"fields": {
"chore_id": {
"name": "Chore ID",
"description": "The ID of the timed chore"
},
"child_id": {
"name": "Child ID",
"description": "The ID of the child starting the timer"
}
}
},
"pause_timed_task": {
"name": "Pause Timed Task",
"description": "Pause a running timed task timer",
"fields": {
"chore_id": {
"name": "Chore ID",
"description": "The ID of the timed chore"
},
"child_id": {
"name": "Child ID",
"description": "The ID of the child pausing the timer"
}
}
},
"stop_timed_task": {
"name": "Stop Timed Task",
"description": "Stop a timed task timer and submit for approval",
"fields": {
"chore_id": {
"name": "Chore ID",
"description": "The ID of the timed chore"
},
"child_id": {
"name": "Child ID",
"description": "The ID of the child stopping the timer"
}
}
},
"add_badge": {
"name": "Add Badge",
"description": "Create a custom badge.",
"fields": {
"name": {
"name": "Name",
"description": "The display name of the badge"
},
"description": {
"name": "Description",
"description": "Description of how the badge is earned"
},
"icon": {
"name": "Icon",
"description": "Icon for the badge (e.g. mdi:beach)"
},
"tier": {
"name": "Tier",
"description": "Badge tier: bronze, silver, gold or platinum"
},
"point_bonus": {
"name": "Point bonus",
"description": "Points credited to the child when the badge is earned"
},
"criteria": {
"name": "Criteria",
"description": "List of metric / operator / value dicts. Empty = manual-award only."
},
"assigned_to": {
"name": "Assigned to",
"description": "List of child IDs (empty = all)"
},
"notify_on_earn": {
"name": "Notify on earn",
"description": "Send a notification when the badge is earned"
}
}
},
"update_badge": {
"name": "Update Badge",
"description": "Update an existing badge. For built-ins, only point_bonus, tier, assigned_to, enabled, notify_on_earn are editable.",
"fields": {
"badge_id": {
"name": "Badge ID",
"description": "The ID of the badge to update"
},
"name": {
"name": "Name",
"description": "New display name of the badge"
},
"description": {
"name": "Description",
"description": "New description of the badge"
},
"icon": {
"name": "Icon",
"description": "New icon for the badge"
},
"tier": {
"name": "Tier",
"description": "Badge tier: bronze, silver, gold or platinum"
},
"point_bonus": {
"name": "Point bonus",
"description": "Points credited to the child when the badge is earned"
},
"criteria": {
"name": "Criteria",
"description": "List of metric / operator / value dicts. Empty = manual-award only."
},
"assigned_to": {
"name": "Assigned to",
"description": "List of child IDs (empty = all)"
},
"enabled": {
"name": "Enabled",
"description": "Whether the badge is active"
},
"notify_on_earn": {
"name": "Notify on earn",
"description": "Send a notification when the badge is earned"
}
}
},
"remove_badge": {
"name": "Remove Badge",
"description": "Remove a custom badge. Built-ins cannot be removed.",
"fields": {
"badge_id": {
"name": "Badge ID",
"description": "The ID of the badge to remove"
}
}
},
"award_badge_manually": {
"name": "Award Badge Manually",
"description": "Manually award a badge to a child.",
"fields": {
"badge_id": {
"name": "Badge ID",
"description": "The ID of the badge to award"
},
"child_id": {
"name": "Child ID",
"description": "The ID of the child receiving the badge"
}
}
},
"revoke_badge": {
"name": "Revoke Badge",
"description": "Revoke an awarded badge. If a point bonus was credited, it is reversed.",
"fields": {
"awarded_badge_id": {
"name": "Awarded Badge ID",
"description": "The ID of the awarded badge record to revoke"
}
}
},
"rebuild_badges": {
"name": "Rebuild Badges",
"description": "Re-evaluate all badges across all children silently (no notifications, no point bonuses)."
},
"apply_mandatory_penalty": {
"name": "Apply Mandatory Penalty",
"description": "Deduct the configured penalty points for a missed mandatory chore and clear the review item.",
"fields": {
"miss_id": {
"name": "Miss ID",
"description": "The ID of the mandatory-miss review item"
}
}
},
"choose_avatar": {
"name": "Choose Avatar",
"description": "A child switches to an avatar they have unlocked.",
"fields": {
"child_id": {
"name": "Child ID",
"description": "The ID of the child"
},
"icon": {
"name": "Avatar icon",
"description": "The mdi icon of an unlocked catalogue avatar (e.g. mdi:rocket-launch)."
}
}
},
"dismiss_mandatory_chore": {
"name": "Dismiss Mandatory Chore",
"description": "Clear a missed mandatory chore review item with no penalty.",
"fields": {
"miss_id": {
"name": "Miss ID",
"description": "The ID of the mandatory-miss review item"
}
}
},
"gift_points": {
"name": "Gift Points",
"description": "Transfer spendable points from one child to another (parent-controlled).",
"fields": {
"from_child_id": {
"name": "From child ID",
"description": "The ID of the child sending points"
},
"to_child_id": {
"name": "To child ID",
"description": "The ID of the child receiving points"
},
"points": {
"name": "Points",
"description": "The number of points to transfer"
}
}
},
"postpone_mandatory_chore": {
"name": "Postpone Mandatory Chore",
"description": "Give a missed mandatory chore another period (or tomorrow) without a penalty, and clear the review item.",
"fields": {
"miss_id": {
"name": "Miss ID",
"description": "The ID of the mandatory-miss review item"
}
}
},
"request_swap": {
"name": "Request Chore Swap",
"description": "A child requests to take over today's rotation assignment of a chore (pending parent approval).",
"fields": {
"chore_id": {
"name": "Chore ID",
"description": "The ID of the chore to take over"
},
"requester_id": {
"name": "Requester child ID",
"description": "The ID of the child requesting the swap"
}
}
},
"test_notification": {
"name": "Test Notification",
"description": "Send a sample notification of the given type to its enabled recipients (ignores the master on/off so routing can be verified).",
"fields": {
"type_id": {
"name": "Notification type",
"description": "Notification type id, e.g. pending_chore_approval, badge_earned, streak_milestone"
}
}
},
"record_allowance_payout": {
"name": "Record Allowance Payout",
"description": "Deduct points and record a real-money allowance payout in the ledger (fixed conversion rate; parent-controlled).",
"fields": {
"child_id": {
"name": "Child ID",
"description": "The ID of the child receiving the payout"
},
"points": {
"name": "Points",
"description": "Points to convert to cash and deduct"
}
}
}
},
"entity": {
"sensor": {
"child_stats": {
"unit_of_measurement": "chores"
},
"child_badges": {
"unit_of_measurement": "badges"
}
},
"number": {
"weekend_multiplier": {
"name": "Weekend multiplier"
},
"perfect_week_bonus": {
"name": "Perfect week bonus"
}
},
"select": {
"streak_reset_mode": {
"name": "Streak reset mode"
},
"card_design": {
"name": "Card design"
}
}
}
}
@@ -0,0 +1,884 @@
{
"config": {
"step": {
"user": {
"title": "Bienvenue sur TaskMate !",
"description": "Configurez votre système de gestion des tâches familiales. Vous pouvez personnaliser le nom de la monnaie de points que les enfants gagneront.",
"data": {
"points_name": "Nom de la monnaie de points (ex: Étoiles, Pièces, Jetons)",
"points_icon": "Icône des points"
}
}
},
"error": {
"name_required": "Le nom est obligatoire"
},
"abort": {
"already_configured": "TaskMate est déjà configuré"
}
},
"selector": {
"chore_action": {
"options": {
"save": "Enregistrer les modifications",
"re_enable": "Réactiver la corvée",
"disable": "Désactiver la corvée",
"delete": "Supprimer cette corvée"
}
},
"reward_action": {
"options": {
"save": "Enregistrer les modifications",
"delete": "Supprimer la récompense"
}
},
"time_category": {
"options": {
"morning": "Matin",
"afternoon": "Après-midi",
"evening": "Soirée",
"night": "Nuit",
"anytime": "N'importe quand"
}
},
"schedule_mode": {
"options": {
"specific_days": "Jours spécifiques de la semaine",
"recurring": "Récurrent (tous les N jours/semaines/mois)",
"one_shot": "Tâche unique (un seul jour)"
}
},
"due_days_option": {
"options": {
"monday": "Lundi",
"tuesday": "Mardi",
"wednesday": "Mercredi",
"thursday": "Jeudi",
"friday": "Vendredi",
"saturday": "Samedi",
"sunday": "Dimanche"
}
},
"recurrence": {
"options": {
"every_2_days": "Tous les 2 jours",
"weekly": "Hebdomadaire",
"every_2_weeks": "Toutes les 2 semaines",
"monthly": "Mensuel",
"every_3_months": "Tous les 3 mois",
"every_6_months": "Tous les 6 mois"
}
},
"recurrence_day_option": {
"options": {
"monday": "Lundi",
"tuesday": "Mardi",
"wednesday": "Mercredi",
"thursday": "Jeudi",
"friday": "Vendredi",
"saturday": "Samedi",
"sunday": "Dimanche",
"any_day": "N'importe quel jour"
}
},
"first_occurrence_mode": {
"options": {
"available_immediately": "Disponible immédiatement",
"wait_for_first_occurrence": "Attendre la première occurrence prévue"
}
},
"streak_reset_mode": {
"options": {
"reset": "Réinitialiser — la série retombe à 0 en cas d'oubli",
"pause": "Pause — la série est préservée jusqu'à la prochaine exécution"
}
},
"visibility_operator": {
"options": {
"none": "Aucun filtre — Toujours afficher la corvée",
"equals": "Égale — Afficher quand l'état correspond exactement",
"not_equals": "Non égale — Afficher quand l'état ne correspond pas",
"gte": "Supérieur ou égal (≥) — Afficher quand la valeur est ≥ cible",
"lte": "Inférieur ou égal (≤) — Afficher quand la valeur est ≤ cible",
"gt": "Supérieur (>) — Afficher quand la valeur est > cible",
"lt": "Inférieur (<) — Afficher quand la valeur est < cible"
}
},
"assignment_mode": {
"options": {
"everyone": "Tout le monde — chaque enfant assigné voit la tâche",
"alternating": "Alternance — rotation entre les enfants assignés, un par jour",
"random": "Aléatoire — un enfant assigné choisi au hasard chaque jour",
"balanced": "Équilibré — répartit les tâches équilibrées du jour équitablement entre les enfants assignés",
"first_come": "Premier arrivé, premier servi — le premier enfant à la terminer gagne ; masquée pour les autres",
"unassigned": "Non attribué — aucun enfant ne voit cette tâche"
}
},
"task_group_policy": {
"options": {
"sticky": "Sticky — même enfant pour toutes les tâches du groupe",
"spread": "Spread — enfants différents dans le groupe aujourdhui"
}
},
"task_group_action": {
"options": {
"save": "Enregistrer",
"delete": "Supprimer le groupe"
}
}
},
"services": {
"complete_chore": {
"name": "Terminer une corvée",
"description": "Marquer une corvée comme terminée par un enfant",
"fields": {
"chore_id": {
"name": "ID de la corvée",
"description": "L'identifiant de la corvée à terminer"
},
"child_id": {
"name": "ID de l'enfant",
"description": "L'identifiant de l'enfant effectuant la corvée"
}
}
},
"complete_bonus_subtask": {
"name": "Terminer une sous-tâche bonus",
"description": "Terminer une sous-tâche bonus une fois la corvée parente terminée",
"fields": {
"chore_id": {
"name": "ID de la corvée",
"description": "L'identifiant de la corvée parente"
},
"bonus_subtask_id": {
"name": "ID de la sous-tâche bonus",
"description": "L'identifiant de la sous-tâche bonus à terminer"
},
"child_id": {
"name": "ID de l'enfant",
"description": "L'identifiant de l'enfant effectuant la sous-tâche bonus"
}
}
},
"approve_chore": {
"name": "Approuver une corvée",
"description": "Approuver une corvée terminée et attribuer les points",
"fields": {
"completion_id": {
"name": "ID de complétion",
"description": "L'identifiant de l'exécution à approuver"
}
}
},
"approve_all_chores": {
"name": "Approuver toutes les tâches",
"description": "Approuver toutes les tâches terminées en attente (ou un ensemble précis) et attribuer les points",
"fields": {
"completion_ids": {
"name": "Identifiants de complétion",
"description": "Identifiants de complétion à approuver ; laisser vide pour tout approuver"
}
}
},
"reject_chore": {
"name": "Rejeter une corvée",
"description": "Rejeter une corvée terminée",
"fields": {
"completion_id": {
"name": "ID de complétion",
"description": "L'identifiant de l'exécution à rejeter"
}
}
},
"undo_chore_approval": {
"name": "Annuler l'approbation d'une tâche",
"description": "Annuler l'approbation accidentelle d'une tâche — retire les points attribués et remet la réalisation en attente afin qu'elle puisse être approuvée à nouveau.",
"fields": {
"completion_id": {
"name": "ID de réalisation",
"description": "L'ID de la réalisation de tâche approuvée à remettre en attente"
}
}
},
"undo_transaction": {
"name": "Annuler une transaction",
"description": "Annuler une transaction de points via son ID — pénalités, bonus, ajustements manuels (ajout/retrait) et cadeaux (les deux volets). Les transactions automatiques (bonus de week-end/série/semaine parfaite, mouvements de cagnotte, dépréciation, intérêts) ne peuvent pas être annulées.",
"fields": {
"transaction_id": {
"name": "ID de transaction",
"description": "L'ID de la transaction de points à annuler"
}
}
},
"claim_reward": {
"name": "Réclamer une récompense",
"description": "L'enfant réclame une récompense en utilisant ses points",
"fields": {
"reward_id": {
"name": "ID de la récompense",
"description": "L'identifiant de la récompense à réclamer"
},
"child_id": {
"name": "ID de l'enfant",
"description": "L'identifiant de l'enfant réclamant la récompense"
}
}
},
"approve_reward": {
"name": "Approuver une récompense",
"description": "Approuver une demande de récompense",
"fields": {
"claim_id": {
"name": "ID de la demande",
"description": "L'identifiant de la demande de récompense à approuver"
}
}
},
"allocate_points_to_pool": {
"name": "Allouer des points à la cagnotte",
"description": "Déplacer des points du solde disponible d'un enfant vers la cagnotte d'une récompense (verrouillée).",
"fields": {
"child_id": {
"name": "ID de l'enfant",
"description": "L'identifiant de l'enfant effectuant l'allocation"
},
"reward_id": {
"name": "ID de la récompense",
"description": "L'identifiant de la cagnotte de récompense où déposer les points"
},
"points": {
"name": "Points",
"description": "Nombre de points à allouer (limité par la capacité de la cagnotte et le solde disponible)"
}
}
},
"add_points": {
"name": "Ajouter des points",
"description": "Ajouter des points bonus à un enfant",
"fields": {
"child_id": {
"name": "ID de l'enfant",
"description": "L'identifiant de l'enfant à qui ajouter des points"
},
"points": {
"name": "Points",
"description": "Le nombre de points à ajouter"
},
"reason": {
"name": "Raison",
"description": "Raison optionnelle pour le bonus"
}
}
},
"remove_points": {
"name": "Retirer des points",
"description": "Retirer des points à un enfant (pénalité)",
"fields": {
"child_id": {
"name": "ID de l'enfant",
"description": "L'identifiant de l'enfant à qui retirer des points"
},
"points": {
"name": "Points",
"description": "Le nombre de points à retirer"
},
"reason": {
"name": "Raison",
"description": "Raison optionnelle pour la pénalité"
}
}
},
"reject_reward": {
"name": "Rejeter une récompense",
"description": "Rejeter une demande de récompense",
"fields": {
"claim_id": {
"name": "ID de la demande",
"description": "L'identifiant de la demande à rejeter"
}
}
},
"preview_sound": {
"name": "Aperçu du son",
"description": "Tester un effet sonore de complétion dans le navigateur",
"fields": {
"sound": {
"name": "Son",
"description": "Le son à tester"
}
}
},
"set_chore_order": {
"name": "Définir l'ordre des corvées",
"description": "Définir l'ordre d'affichage des corvées pour un enfant",
"fields": {
"child_id": {
"name": "ID de l'enfant",
"description": "L'identifiant de l'enfant"
},
"chore_order": {
"name": "Ordre des corvées",
"description": "Liste ordonnée des IDs de corvées"
}
}
},
"add_penalty": {
"name": "Ajouter une pénalité",
"description": "Créer une nouvelle définition de pénalité",
"fields": {
"name": {
"name": "Nom",
"description": "Le nom de la pénalité"
},
"points": {
"name": "Points",
"description": "Le nombre de points à déduire"
},
"description": {
"name": "Description",
"description": "Description optionnelle"
},
"icon": {
"name": "Icône",
"description": "Icône MDI pour la pénalité"
},
"assigned_to": {
"name": "Assignée à",
"description": "Liste des IDs d'enfants concernés"
}
}
},
"update_penalty": {
"name": "Mettre à jour la pénalité",
"description": "Modifier une définition de pénalité existante",
"fields": {
"penalty_id": {
"name": "ID de pénalité",
"description": "L'identifiant de la pénalité à modifier"
},
"name": {
"name": "Nom",
"description": "Nouveau nom"
},
"points": {
"name": "Points",
"description": "Nouvelle valeur de points"
},
"description": {
"name": "Description",
"description": "Nouvelle description"
},
"icon": {
"name": "Icône",
"description": "Nouvelle icône"
},
"assigned_to": {
"name": "Assignée à",
"description": "Nouvelle liste d'identifiants d'enfants"
}
}
},
"remove_penalty": {
"name": "Supprimer la pénalité",
"description": "Supprimer une définition de pénalité",
"fields": {
"penalty_id": {
"name": "ID de pénalité",
"description": "L'identifiant de la pénalité à supprimer"
}
}
},
"apply_penalty": {
"name": "Appliquer une pénalité",
"description": "Appliquer une pénalité à un enfant, déduisant ses points",
"fields": {
"penalty_id": {
"name": "ID de pénalité",
"description": "L'identifiant de la pénalité à appliquer"
},
"child_id": {
"name": "ID de l'enfant",
"description": "L'identifiant de l'enfant à pénaliser"
}
}
},
"add_bonus": {
"name": "Ajouter un bonus",
"description": "Créer une nouvelle définition de bonus",
"fields": {
"name": {
"name": "Nom",
"description": "Le nom du bonus"
},
"points": {
"name": "Points",
"description": "Le nombre de points à attribuer lors de l'application"
},
"description": {
"name": "Description",
"description": "Description optionnelle du bonus"
},
"icon": {
"name": "Icône",
"description": "Icône MDI pour le bonus"
},
"assigned_to": {
"name": "Assigné à",
"description": "Liste des identifiants d'enfants auxquels ce bonus s'applique"
}
}
},
"update_bonus": {
"name": "Mettre à jour le bonus",
"description": "Mettre à jour une définition de bonus existante",
"fields": {
"bonus_id": {
"name": "ID de bonus",
"description": "L'identifiant du bonus à mettre à jour"
},
"name": {
"name": "Nom",
"description": "Nouveau nom pour le bonus"
},
"points": {
"name": "Points",
"description": "Nouvelle valeur de points"
},
"description": {
"name": "Description",
"description": "Nouvelle description"
},
"icon": {
"name": "Icône",
"description": "Nouvelle icône"
},
"assigned_to": {
"name": "Assigné à",
"description": "Nouvelle liste d'identifiants d'enfants"
}
}
},
"remove_bonus": {
"name": "Supprimer le bonus",
"description": "Supprimer une définition de bonus",
"fields": {
"bonus_id": {
"name": "ID de bonus",
"description": "L'identifiant du bonus à supprimer"
}
}
},
"apply_bonus": {
"name": "Appliquer un bonus",
"description": "Appliquer un bonus à un enfant, attribuant des points",
"fields": {
"bonus_id": {
"name": "ID de bonus",
"description": "L'identifiant du bonus à appliquer"
},
"child_id": {
"name": "ID de l'enfant",
"description": "L'identifiant de l'enfant à qui attribuer le bonus"
}
}
},
"add_chore": {
"name": "Ajouter une corvée",
"description": "Créer une nouvelle corvée dynamiquement, généralement depuis une automatisation ou un script",
"fields": {
"name": {
"name": "Nom",
"description": "Le nom de la corvée"
},
"description": {
"name": "Description",
"description": "Description optionnelle de la corvée"
},
"points": {
"name": "Points",
"description": "Points attribués à l'achèvement de cette corvée"
},
"assigned_to": {
"name": "Assignée à",
"description": "Liste des IDs d'enfants à qui assigner cette corvée. Laisser vide pour l'assigner à tous les enfants"
},
"time_category": {
"name": "Catégorie horaire",
"description": "Quand cette corvée doit être effectuée : morning, afternoon, evening, night ou anytime"
},
"one_shot": {
"name": "Unique",
"description": "Si vrai, c'est une corvée unique qui expire après exécution ou à la fin de la journée"
},
"requires_approval": {
"name": "Nécessite approbation",
"description": "Si vrai, un parent doit approuver l'exécution avant que les points soient attribués"
}
}
},
"skip_chore": {
"name": "Skip Chore",
"description": "Advance today's rotation past the current assignee. Ephemeral — tomorrow's rotation resumes as normal.",
"fields": {
"chore_id": {
"name": "Chore ID",
"description": "The ID of the chore to skip"
}
}
},
"set_chore_manual_start": {
"name": "Set Manual Start Child",
"description": "Set which child starts a rotating chore. For alternating mode this reorders the pool; for random/balanced it only overrides today's assignee.",
"fields": {
"chore_id": {
"name": "Chore ID",
"description": "The ID of the chore to update"
},
"child_id": {
"name": "Child ID",
"description": "The ID of the child who should start the rotation"
}
}
},
"add_task_group": {
"name": "Add Task Group",
"description": "Create a task group (sticky = same child, spread = different children).",
"fields": {
"name": {
"name": "Name",
"description": "Display name for the group"
},
"policy": {
"name": "Policy",
"description": "sticky (same child) or spread (different children)"
},
"chore_ids": {
"name": "Chore IDs",
"description": "List of chore IDs in the group. For sticky, the first chore is the leader."
}
}
},
"update_task_group": {
"name": "Update Task Group",
"description": "Update a task group's name, policy, or membership.",
"fields": {
"group_id": {
"name": "Group ID",
"description": "The ID of the group to update"
},
"name": {
"name": "Name",
"description": "New display name"
},
"policy": {
"name": "Policy",
"description": "New policy (sticky or spread)"
},
"chore_ids": {
"name": "Chore IDs",
"description": "Replacement chore ID list. For sticky, the first chore is the leader."
}
}
},
"remove_task_group": {
"name": "Remove Task Group",
"description": "Delete a task group. Member chores are not affected.",
"fields": {
"group_id": {
"name": "Group ID",
"description": "The ID of the group to delete"
}
}
},
"start_timed_task": {
"name": "Démarrer une tâche chronométrée",
"description": "Démarrer ou reprendre le minuteur d'une tâche chronométrée",
"fields": {
"chore_id": {
"name": "ID de la corvée",
"description": "L'identifiant de la corvée chronométrée"
},
"child_id": {
"name": "ID de l'enfant",
"description": "L'identifiant de l'enfant démarrant le minuteur"
}
}
},
"pause_timed_task": {
"name": "Mettre en pause une tâche chronométrée",
"description": "Mettre en pause le minuteur d'une tâche chronométrée en cours",
"fields": {
"chore_id": {
"name": "ID de la corvée",
"description": "L'identifiant de la corvée chronométrée"
},
"child_id": {
"name": "ID de l'enfant",
"description": "L'identifiant de l'enfant mettant le minuteur en pause"
}
}
},
"stop_timed_task": {
"name": "Arrêter une tâche chronométrée",
"description": "Arrêter le minuteur d'une tâche chronométrée et la soumettre pour approbation",
"fields": {
"chore_id": {
"name": "ID de la corvée",
"description": "L'identifiant de la corvée chronométrée"
},
"child_id": {
"name": "ID de l'enfant",
"description": "L'identifiant de l'enfant arrêtant le minuteur"
}
}
},
"add_badge": {
"name": "Ajouter un badge",
"description": "Créer un badge personnalisé.",
"fields": {
"name": {
"name": "Nom",
"description": "Le nom affiché du badge"
},
"description": {
"name": "Description",
"description": "Description de la façon d'obtenir le badge"
},
"icon": {
"name": "Icône",
"description": "Icône du badge (ex. mdi:beach)"
},
"tier": {
"name": "Niveau",
"description": "Niveau du badge : bronze, argent, or ou platine"
},
"point_bonus": {
"name": "Bonus de points",
"description": "Points crédités à l'enfant lorsque le badge est obtenu"
},
"criteria": {
"name": "Critères",
"description": "Liste de dictionnaires metric / operator / value. Vide = attribution manuelle uniquement."
},
"assigned_to": {
"name": "Attribué à",
"description": "Liste d'identifiants d'enfants (vide = tous)"
},
"notify_on_earn": {
"name": "Notifier à l'obtention",
"description": "Envoyer une notification lorsque le badge est obtenu"
}
}
},
"update_badge": {
"name": "Mettre à jour un badge",
"description": "Mettre à jour un badge existant. Pour les badges intégrés, seuls point_bonus, tier, assigned_to, enabled et notify_on_earn sont modifiables.",
"fields": {
"badge_id": {
"name": "ID du badge",
"description": "L'identifiant du badge à mettre à jour"
},
"name": {
"name": "Nom",
"description": "Nouveau nom affiché du badge"
},
"description": {
"name": "Description",
"description": "Nouvelle description du badge"
},
"icon": {
"name": "Icône",
"description": "Nouvelle icône du badge"
},
"tier": {
"name": "Niveau",
"description": "Niveau du badge : bronze, argent, or ou platine"
},
"point_bonus": {
"name": "Bonus de points",
"description": "Points crédités à l'enfant lorsque le badge est obtenu"
},
"criteria": {
"name": "Critères",
"description": "Liste de dictionnaires metric / operator / value. Vide = attribution manuelle uniquement."
},
"assigned_to": {
"name": "Attribué à",
"description": "Liste d'identifiants d'enfants (vide = tous)"
},
"enabled": {
"name": "Activé",
"description": "Indique si le badge est actif"
},
"notify_on_earn": {
"name": "Notifier à l'obtention",
"description": "Envoyer une notification lorsque le badge est obtenu"
}
}
},
"remove_badge": {
"name": "Supprimer un badge",
"description": "Supprimer un badge personnalisé. Les badges intégrés ne peuvent pas être supprimés.",
"fields": {
"badge_id": {
"name": "ID du badge",
"description": "L'identifiant du badge à supprimer"
}
}
},
"award_badge_manually": {
"name": "Attribuer un badge manuellement",
"description": "Attribuer manuellement un badge à un enfant.",
"fields": {
"badge_id": {
"name": "ID du badge",
"description": "L'identifiant du badge à attribuer"
},
"child_id": {
"name": "ID de l'enfant",
"description": "L'identifiant de l'enfant recevant le badge"
}
}
},
"revoke_badge": {
"name": "Révoquer un badge",
"description": "Révoquer un badge attribué. Si un bonus de points a été crédité, il est annulé.",
"fields": {
"awarded_badge_id": {
"name": "ID du badge attribué",
"description": "L'identifiant du badge attribué à révoquer"
}
}
},
"rebuild_badges": {
"name": "Recalculer les badges",
"description": "Réévaluer silencieusement tous les badges pour tous les enfants (sans notifications ni bonus de points)."
},
"apply_mandatory_penalty": {
"name": "Appliquer la pénalité obligatoire",
"description": "Déduit les points de pénalité configurés pour une tâche obligatoire manquée et supprime l'élément à vérifier.",
"fields": {
"miss_id": {
"name": "ID d'oubli",
"description": "L'ID de l'élément à vérifier pour la tâche obligatoire manquée"
}
}
},
"choose_avatar": {
"name": "Choisir un avatar",
"description": "Un enfant passe à un avatar qu'il a débloqué.",
"fields": {
"child_id": {
"name": "ID enfant",
"description": "L'ID de l'enfant"
},
"icon": {
"name": "Icône d'avatar",
"description": "L'icône mdi d'un avatar du catalogue débloqué (par ex. mdi:rocket-launch)."
}
}
},
"dismiss_mandatory_chore": {
"name": "Ignorer la tâche obligatoire",
"description": "Supprime l'élément à vérifier d'une tâche obligatoire manquée sans pénalité.",
"fields": {
"miss_id": {
"name": "ID d'oubli",
"description": "L'ID de l'élément à vérifier pour la tâche obligatoire manquée"
}
}
},
"gift_points": {
"name": "Offrir des points",
"description": "Transfère des points dépensables d'un enfant à un autre (contrôlé par les parents).",
"fields": {
"from_child_id": {
"name": "ID enfant source",
"description": "L'ID de l'enfant qui envoie les points"
},
"to_child_id": {
"name": "ID enfant destinataire",
"description": "L'ID de l'enfant qui reçoit les points"
},
"points": {
"name": "Points",
"description": "Le nombre de points à transférer"
}
}
},
"postpone_mandatory_chore": {
"name": "Reporter la tâche obligatoire",
"description": "Accorde à une tâche obligatoire manquée une autre période (ou demain) sans pénalité et supprime l'élément à vérifier.",
"fields": {
"miss_id": {
"name": "ID d'oubli",
"description": "L'ID de l'élément à vérifier pour la tâche obligatoire manquée"
}
}
},
"request_swap": {
"name": "Demander un échange de tâche",
"description": "Un enfant demande à reprendre l'attribution par rotation d'une tâche pour aujourd'hui (en attente d'approbation parentale).",
"fields": {
"chore_id": {
"name": "ID tâche",
"description": "L'ID de la tâche à reprendre"
},
"requester_id": {
"name": "ID enfant demandeur",
"description": "L'ID de l'enfant qui demande l'échange"
}
}
},
"test_notification": {
"name": "Notification de test",
"description": "Envoie un exemple de notification du type indiqué à ses destinataires activés (ignore l'interrupteur principal afin de vérifier le routage).",
"fields": {
"type_id": {
"name": "Type de notification",
"description": "ID du type de notification, par ex. pending_chore_approval, badge_earned, streak_milestone"
}
}
},
"record_allowance_payout": {
"name": "Enregistrer un versement d'argent de poche",
"description": "Déduire des points et enregistrer un versement d'argent de poche réel dans le journal (taux de conversion fixe ; contrôlé par les parents).",
"fields": {
"child_id": {
"name": "ID enfant",
"description": "L'ID de l'enfant recevant le versement"
},
"points": {
"name": "Points",
"description": "Points à convertir en argent et à déduire"
}
}
}
},
"entity": {
"sensor": {
"child_stats": {
"unit_of_measurement": "tâches"
},
"child_badges": {
"unit_of_measurement": "badges"
}
},
"number": {
"weekend_multiplier": {
"name": "Multiplicateur week-end"
},
"perfect_week_bonus": {
"name": "Bonus de semaine parfaite"
}
},
"select": {
"streak_reset_mode": {
"name": "Mode de réinitialisation de série"
},
"card_design": {
"name": "Design de carte"
}
}
}
}
@@ -0,0 +1,884 @@
{
"config": {
"step": {
"user": {
"title": "Velkommen til TaskMate!",
"description": "Sett opp ditt familieoppgavesystem. Du kan tilpasse poengvalutaen barna skal tjene.",
"data": {
"points_name": "Poengvalutanavn (f.eks. Stjerner, Mynter, Kroner)",
"points_icon": "Poengikon"
}
}
},
"error": {
"name_required": "Navn er påkrevd"
},
"abort": {
"already_configured": "TaskMate er allerede konfigurert"
}
},
"selector": {
"chore_action": {
"options": {
"save": "Lagre endringer",
"re_enable": "Reaktiver oppgave",
"disable": "Deaktiver oppgave",
"delete": "Slett denne oppgaven"
}
},
"reward_action": {
"options": {
"save": "Lagre endringer",
"delete": "Slett belønning"
}
},
"time_category": {
"options": {
"morning": "Morgen",
"afternoon": "Ettermiddag",
"evening": "Kveld",
"night": "Natt",
"anytime": "Når som helst"
}
},
"schedule_mode": {
"options": {
"specific_days": "Bestemte ukedager",
"recurring": "Gjentakende (hver N dag/uke/måned)",
"one_shot": "Engangsoppgave (én dag)"
}
},
"due_days_option": {
"options": {
"monday": "Mandag",
"tuesday": "Tirsdag",
"wednesday": "Onsdag",
"thursday": "Torsdag",
"friday": "Fredag",
"saturday": "Lørdag",
"sunday": "Søndag"
}
},
"recurrence": {
"options": {
"every_2_days": "Annenhver dag",
"weekly": "Ukentlig",
"every_2_weeks": "Annenhver uke",
"monthly": "Månedlig",
"every_3_months": "Hver 3. måned",
"every_6_months": "Hver 6. måned"
}
},
"recurrence_day_option": {
"options": {
"monday": "Mandag",
"tuesday": "Tirsdag",
"wednesday": "Onsdag",
"thursday": "Torsdag",
"friday": "Fredag",
"saturday": "Lørdag",
"sunday": "Søndag",
"any_day": "Hvilken som helst dag"
}
},
"first_occurrence_mode": {
"options": {
"available_immediately": "Tilgjengelig umiddelbart",
"wait_for_first_occurrence": "Vent på første planlagte forekomst"
}
},
"streak_reset_mode": {
"options": {
"reset": "Nullstill — rekken går til 0 ved tapt dag",
"pause": "Pause — rekken bevares til neste fullføring"
}
},
"visibility_operator": {
"options": {
"none": "Ingen filter — Vis oppgaven alltid",
"equals": "Lik — Vis når enhetstilstanden stemmer nøyaktig",
"not_equals": "Ikke lik — Vis når enhetstilstanden ikke stemmer",
"gte": "Større enn eller lik (≥) — Vis når numerisk verdi er ≥ målverdien",
"lte": "Mindre enn eller lik (≤) — Vis når numerisk verdi er ≤ målverdien",
"gt": "Større enn (>) — Vis når numerisk verdi er > målverdien",
"lt": "Mindre enn (<) — Vis når numerisk verdi er < målverdien"
}
},
"assignment_mode": {
"options": {
"everyone": "Alle — hvert tildelt barn ser oppgaven",
"alternating": "Vekslende — roter mellom de tildelte barna, ett per dag",
"random": "Tilfeldig — plukk ett tildelt barn tilfeldig hver dag",
"balanced": "Balansert — fordel dagens balanserte oppgaver jevnt mellom de tildelte barna",
"first_come": "Førstemann til mølla — første barn som fullfører den vinner; skjules for de andre",
"unassigned": "Ikke tildelt — ingen barn ser denne oppgaven"
}
},
"task_group_policy": {
"options": {
"sticky": "Sticky — samme barn for alle oppgaver i gruppen",
"spread": "Spread — ulike barn i gruppen i dag"
}
},
"task_group_action": {
"options": {
"save": "Lagre endringer",
"delete": "Slett gruppe"
}
}
},
"services": {
"complete_chore": {
"name": "Fullfør oppgave",
"description": "Merk en oppgave som fullført av et barn",
"fields": {
"chore_id": {
"name": "Oppgave-ID",
"description": "ID-en til oppgaven som skal fullføres"
},
"child_id": {
"name": "Barn-ID",
"description": "ID-en til barnet som fullfører oppgaven"
}
}
},
"complete_bonus_subtask": {
"name": "Fullfør bonusdeloppgave",
"description": "Fullfør en bonusdeloppgave etter at hovedoppgaven er fullført",
"fields": {
"chore_id": {
"name": "Oppgave-ID",
"description": "ID-en til hovedoppgaven"
},
"bonus_subtask_id": {
"name": "Bonusdeloppgave-ID",
"description": "ID-en til bonusdeloppgaven som skal fullføres"
},
"child_id": {
"name": "Barn-ID",
"description": "ID-en til barnet som fullfører bonusdeloppgaven"
}
}
},
"approve_chore": {
"name": "Godkjenn oppgave",
"description": "Godkjenn en fullført oppgave og tildel poeng",
"fields": {
"completion_id": {
"name": "Fullførings-ID",
"description": "ID-en til oppgavefullføringen som skal godkjennes"
}
}
},
"approve_all_chores": {
"name": "Godkjenn alle gjøremål",
"description": "Godkjenn alle ventende fullførte gjøremål (eller et bestemt utvalg) og tildel poeng",
"fields": {
"completion_ids": {
"name": "Fullførings-ID-er",
"description": "Bestemte fullførings-ID-er som skal godkjennes; utelat for å godkjenne alle ventende"
}
}
},
"reject_chore": {
"name": "Avvis oppgave",
"description": "Avvis en fullført oppgave",
"fields": {
"completion_id": {
"name": "Fullførings-ID",
"description": "ID-en til oppgavefullføringen som skal avvises"
}
}
},
"undo_chore_approval": {
"name": "Angre godkjenning av gjøremål",
"description": "Angre en utilsiktet godkjenning av et gjøremål — fjerner de tildelte poengene og setter fullføringen tilbake til venter, slik at den kan godkjennes på nytt.",
"fields": {
"completion_id": {
"name": "Fullførings-ID",
"description": "ID-en til den godkjente gjøremålsfullføringen som skal settes tilbake til venter"
}
}
},
"undo_transaction": {
"name": "Angre transaksjon",
"description": "Angre en poengtransaksjon med ID-en — straffer, bonuser, manuelle justeringer (legg til/fjern) og gaver (begge sider). Automatiske transaksjoner (helg-/rekke-/perfekt uke-bonuser, sparekrukke-bevegelser, forfall, renter) kan ikke angres.",
"fields": {
"transaction_id": {
"name": "Transaksjons-ID",
"description": "ID-en til poengtransaksjonen som skal angres"
}
}
},
"claim_reward": {
"name": "Hent belønning",
"description": "Barnet henter en belønning med sine poeng",
"fields": {
"reward_id": {
"name": "Belønnings-ID",
"description": "ID-en til belønningen som skal hentes"
},
"child_id": {
"name": "Barn-ID",
"description": "ID-en til barnet som henter belønningen"
}
}
},
"approve_reward": {
"name": "Godkjenn belønning",
"description": "Godkjenn et belønningskrav",
"fields": {
"claim_id": {
"name": "Krav-ID",
"description": "ID-en til belønningskravet som skal godkjennes"
}
}
},
"allocate_points_to_pool": {
"name": "Tildel poeng til sparegris",
"description": "Flytt poeng fra et barns disponible saldo til en belønnings sparegris (låst).",
"fields": {
"child_id": {
"name": "Barn-ID",
"description": "ID-en til barnet som gjør tildelingen"
},
"reward_id": {
"name": "Belønnings-ID",
"description": "ID-en til belønningens sparegris det skal settes inn i"
},
"points": {
"name": "Poeng",
"description": "Antall poeng å tildele (begrenset av sparegrisens kapasitet og disponibel saldo)"
}
}
},
"add_points": {
"name": "Legg til poeng",
"description": "Legg til bonuspoeng til et barn",
"fields": {
"child_id": {
"name": "Barn-ID",
"description": "ID-en til barnet som skal få poeng"
},
"points": {
"name": "Poeng",
"description": "Antall poeng å legge til"
},
"reason": {
"name": "Grunn",
"description": "Valgfri grunn for bonusen"
}
}
},
"remove_points": {
"name": "Fjern poeng",
"description": "Fjern poeng fra et barn (straff)",
"fields": {
"child_id": {
"name": "Barn-ID",
"description": "ID-en til barnet som skal miste poeng"
},
"points": {
"name": "Poeng",
"description": "Antall poeng å fjerne"
},
"reason": {
"name": "Grunn",
"description": "Valgfri grunn for straffen"
}
}
},
"reject_reward": {
"name": "Avvis belønning",
"description": "Avvis et belønningskrav",
"fields": {
"claim_id": {
"name": "Krav-ID",
"description": "ID-en til belønningskravet som skal avvises"
}
}
},
"preview_sound": {
"name": "Forhåndsvis lyd",
"description": "Forhåndsvis en fullføringslydeffekt i nettleseren",
"fields": {
"sound": {
"name": "Lyd",
"description": "Lyden som skal forhåndsvises"
}
}
},
"set_chore_order": {
"name": "Sett oppgaverekkefølge",
"description": "Sett visningsrekkefølgen for oppgaver for et barn",
"fields": {
"child_id": {
"name": "Barn-ID",
"description": "ID-en til barnet å sette oppgaverekkefølge for"
},
"chore_order": {
"name": "Oppgaverekkefølge",
"description": "Ordnet liste over oppgave-ID-er"
}
}
},
"add_penalty": {
"name": "Legg til straff",
"description": "Opprett en ny straffedefinisjon",
"fields": {
"name": {
"name": "Navn",
"description": "Navnet på straffen"
},
"points": {
"name": "Poeng",
"description": "Antall poeng å trekke når den brukes"
},
"description": {
"name": "Beskrivelse",
"description": "Valgfri beskrivelse av straffen"
},
"icon": {
"name": "Ikon",
"description": "MDI-ikon for straffen"
},
"assigned_to": {
"name": "Tildelt til",
"description": "Liste over barn-ID-er denne straffen gjelder for"
}
}
},
"update_penalty": {
"name": "Oppdater straff",
"description": "Oppdater en eksisterende straffedefinisjon",
"fields": {
"penalty_id": {
"name": "Straffe-ID",
"description": "ID-en til straffen som skal oppdateres"
},
"name": {
"name": "Navn",
"description": "Nytt navn for straffen"
},
"points": {
"name": "Poeng",
"description": "Ny poengverdi"
},
"description": {
"name": "Beskrivelse",
"description": "Ny beskrivelse"
},
"icon": {
"name": "Ikon",
"description": "Nytt ikon"
},
"assigned_to": {
"name": "Tildelt til",
"description": "Ny liste over barn-ID-er"
}
}
},
"remove_penalty": {
"name": "Fjern straff",
"description": "Fjern en straffedefinisjon",
"fields": {
"penalty_id": {
"name": "Straffe-ID",
"description": "ID-en til straffen som skal fjernes"
}
}
},
"apply_penalty": {
"name": "Bruk straff",
"description": "Bruk en straff på et barn og trekk poeng",
"fields": {
"penalty_id": {
"name": "Straffe-ID",
"description": "ID-en til straffen som skal brukes"
},
"child_id": {
"name": "Barn-ID",
"description": "ID-en til barnet straffen skal brukes på"
}
}
},
"add_bonus": {
"name": "Legg til bonus",
"description": "Opprett en ny bonusdefinisjon",
"fields": {
"name": {
"name": "Navn",
"description": "Navnet på bonusen"
},
"points": {
"name": "Poeng",
"description": "Antall poeng som tildeles når bonusen brukes"
},
"description": {
"name": "Beskrivelse",
"description": "Valgfri beskrivelse av bonusen"
},
"icon": {
"name": "Ikon",
"description": "MDI-ikon for bonusen"
},
"assigned_to": {
"name": "Tildelt til",
"description": "Liste over barn-ID-er bonusen gjelder for"
}
}
},
"update_bonus": {
"name": "Oppdater bonus",
"description": "Oppdater en eksisterende bonusdefinisjon",
"fields": {
"bonus_id": {
"name": "Bonus-ID",
"description": "ID-en til bonusen som skal oppdateres"
},
"name": {
"name": "Navn",
"description": "Nytt navn for bonusen"
},
"points": {
"name": "Poeng",
"description": "Ny poengverdi"
},
"description": {
"name": "Beskrivelse",
"description": "Ny beskrivelse"
},
"icon": {
"name": "Ikon",
"description": "Nytt ikon"
},
"assigned_to": {
"name": "Tildelt til",
"description": "Ny liste over barn-ID-er"
}
}
},
"remove_bonus": {
"name": "Fjern bonus",
"description": "Fjern en bonusdefinisjon",
"fields": {
"bonus_id": {
"name": "Bonus-ID",
"description": "ID-en til bonusen som skal fjernes"
}
}
},
"apply_bonus": {
"name": "Bruk bonus",
"description": "Bruk en bonus på et barn og tildel poeng",
"fields": {
"bonus_id": {
"name": "Bonus-ID",
"description": "ID-en til bonusen som skal brukes"
},
"child_id": {
"name": "Barn-ID",
"description": "ID-en til barnet bonusen skal brukes på"
}
}
},
"add_chore": {
"name": "Legg til oppgave",
"description": "Opprett en ny oppgave dynamisk, typisk fra en automasjon eller et skript",
"fields": {
"name": {
"name": "Navn",
"description": "Navnet på oppgaven"
},
"description": {
"name": "Beskrivelse",
"description": "Valgfri beskrivelse av oppgaven"
},
"points": {
"name": "Poeng",
"description": "Poeng som tildeles når denne oppgaven fullføres"
},
"assigned_to": {
"name": "Tildelt",
"description": "Liste over barne-ID-er oppgaven skal tildeles. La stå tom for å tildele alle barn"
},
"time_category": {
"name": "Tidskategori",
"description": "Når oppgaven skal fullføres: morning, afternoon, evening, night eller anytime"
},
"one_shot": {
"name": "Engangsoppgave",
"description": "Hvis sann er dette en engangsoppgave som utløper etter fullføring eller ved dagens slutt"
},
"requires_approval": {
"name": "Krever godkjenning",
"description": "Hvis sann må en forelder godkjenne fullføringen før poeng tildeles"
}
}
},
"skip_chore": {
"name": "Skip Chore",
"description": "Advance today's rotation past the current assignee. Ephemeral — tomorrow's rotation resumes as normal.",
"fields": {
"chore_id": {
"name": "Chore ID",
"description": "The ID of the chore to skip"
}
}
},
"set_chore_manual_start": {
"name": "Set Manual Start Child",
"description": "Set which child starts a rotating chore. For alternating mode this reorders the pool; for random/balanced it only overrides today's assignee.",
"fields": {
"chore_id": {
"name": "Chore ID",
"description": "The ID of the chore to update"
},
"child_id": {
"name": "Child ID",
"description": "The ID of the child who should start the rotation"
}
}
},
"add_task_group": {
"name": "Add Task Group",
"description": "Create a task group (sticky = same child, spread = different children).",
"fields": {
"name": {
"name": "Name",
"description": "Display name for the group"
},
"policy": {
"name": "Policy",
"description": "sticky (same child) or spread (different children)"
},
"chore_ids": {
"name": "Chore IDs",
"description": "List of chore IDs in the group. For sticky, the first chore is the leader."
}
}
},
"update_task_group": {
"name": "Update Task Group",
"description": "Update a task group's name, policy, or membership.",
"fields": {
"group_id": {
"name": "Group ID",
"description": "The ID of the group to update"
},
"name": {
"name": "Name",
"description": "New display name"
},
"policy": {
"name": "Policy",
"description": "New policy (sticky or spread)"
},
"chore_ids": {
"name": "Chore IDs",
"description": "Replacement chore ID list. For sticky, the first chore is the leader."
}
}
},
"remove_task_group": {
"name": "Remove Task Group",
"description": "Delete a task group. Member chores are not affected.",
"fields": {
"group_id": {
"name": "Group ID",
"description": "The ID of the group to delete"
}
}
},
"start_timed_task": {
"name": "Start tidsbasert oppgave",
"description": "Start eller gjenoppta timeren på en tidsbasert oppgave",
"fields": {
"chore_id": {
"name": "Oppgave-ID",
"description": "ID-en til den tidsbaserte oppgaven"
},
"child_id": {
"name": "Barn-ID",
"description": "ID-en til barnet som starter timeren"
}
}
},
"pause_timed_task": {
"name": "Sett tidsbasert oppgave på pause",
"description": "Sett timeren på en tidsbasert oppgave på pause",
"fields": {
"chore_id": {
"name": "Oppgave-ID",
"description": "ID-en til den tidsbaserte oppgaven"
},
"child_id": {
"name": "Barn-ID",
"description": "ID-en til barnet som pauser timeren"
}
}
},
"stop_timed_task": {
"name": "Stopp tidsbasert oppgave",
"description": "Stopp timeren på en tidsbasert oppgave og send inn til godkjenning",
"fields": {
"chore_id": {
"name": "Oppgave-ID",
"description": "ID-en til den tidsbaserte oppgaven"
},
"child_id": {
"name": "Barn-ID",
"description": "ID-en til barnet som stopper timeren"
}
}
},
"add_badge": {
"name": "Legg til merke",
"description": "Opprett et egendefinert merke.",
"fields": {
"name": {
"name": "Navn",
"description": "Visningsnavnet til merket"
},
"description": {
"name": "Beskrivelse",
"description": "Beskrivelse av hvordan merket oppnås"
},
"icon": {
"name": "Ikon",
"description": "Ikon for merket (f.eks. mdi:beach)"
},
"tier": {
"name": "Nivå",
"description": "Merkets nivå: bronse, sølv, gull eller platina"
},
"point_bonus": {
"name": "Poengbonus",
"description": "Poeng som krediteres barnet når merket oppnås"
},
"criteria": {
"name": "Kriterier",
"description": "Liste med metric / operator / value-oppføringer. Tom = kun manuell tildeling."
},
"assigned_to": {
"name": "Tildelt til",
"description": "Liste med barn-ID-er (tom = alle)"
},
"notify_on_earn": {
"name": "Varsle ved oppnåelse",
"description": "Send et varsel når merket oppnås"
}
}
},
"update_badge": {
"name": "Oppdater merke",
"description": "Oppdater et eksisterende merke. For innebygde merker kan kun point_bonus, tier, assigned_to, enabled og notify_on_earn redigeres.",
"fields": {
"badge_id": {
"name": "Merke-ID",
"description": "ID-en til merket som skal oppdateres"
},
"name": {
"name": "Navn",
"description": "Nytt visningsnavn for merket"
},
"description": {
"name": "Beskrivelse",
"description": "Ny beskrivelse av merket"
},
"icon": {
"name": "Ikon",
"description": "Nytt ikon for merket"
},
"tier": {
"name": "Nivå",
"description": "Merkets nivå: bronse, sølv, gull eller platina"
},
"point_bonus": {
"name": "Poengbonus",
"description": "Poeng som krediteres barnet når merket oppnås"
},
"criteria": {
"name": "Kriterier",
"description": "Liste med metric / operator / value-oppføringer. Tom = kun manuell tildeling."
},
"assigned_to": {
"name": "Tildelt til",
"description": "Liste med barn-ID-er (tom = alle)"
},
"enabled": {
"name": "Aktivert",
"description": "Om merket er aktivt"
},
"notify_on_earn": {
"name": "Varsle ved oppnåelse",
"description": "Send et varsel når merket oppnås"
}
}
},
"remove_badge": {
"name": "Fjern merke",
"description": "Fjern et egendefinert merke. Innebygde merker kan ikke fjernes.",
"fields": {
"badge_id": {
"name": "Merke-ID",
"description": "ID-en til merket som skal fjernes"
}
}
},
"award_badge_manually": {
"name": "Tildel merke manuelt",
"description": "Tildel et merke manuelt til et barn.",
"fields": {
"badge_id": {
"name": "Merke-ID",
"description": "ID-en til merket som skal tildeles"
},
"child_id": {
"name": "Barn-ID",
"description": "ID-en til barnet som mottar merket"
}
}
},
"revoke_badge": {
"name": "Tilbakekall merke",
"description": "Tilbakekall et tildelt merke. Hvis en poengbonus ble kreditert, blir den reversert.",
"fields": {
"awarded_badge_id": {
"name": "Tildelt merke-ID",
"description": "ID-en til det tildelte merket som skal tilbakekalles"
}
}
},
"rebuild_badges": {
"name": "Gjenoppbygg merker",
"description": "Reevaluer alle merker for alle barn i stillhet (ingen varsler, ingen poengbonuser)."
},
"apply_mandatory_penalty": {
"name": "Bruk obligatorisk straff",
"description": "Trekk fra de konfigurerte strafepoengene for en glemt obligatorisk oppgave og fjern gjennomgangselementet.",
"fields": {
"miss_id": {
"name": "Forsømmelse-ID",
"description": "ID-en til gjennomgangselementet for den glemte obligatoriske oppgaven"
}
}
},
"choose_avatar": {
"name": "Velg avatar",
"description": "Et barn bytter til en avatar de har låst opp.",
"fields": {
"child_id": {
"name": "Barne-ID",
"description": "ID-en til barnet"
},
"icon": {
"name": "Avatar-ikon",
"description": "mdi-ikonet til en opplåst katalog-avatar (f.eks. mdi:rocket-launch)."
}
}
},
"dismiss_mandatory_chore": {
"name": "Avvis obligatorisk oppgave",
"description": "Fjern gjennomgangselementet for en glemt obligatorisk oppgave uten straff.",
"fields": {
"miss_id": {
"name": "Forsømmelse-ID",
"description": "ID-en til gjennomgangselementet for den glemte obligatoriske oppgaven"
}
}
},
"gift_points": {
"name": "Gi bort poeng",
"description": "Overfør brukbare poeng fra ett barn til et annet (foreldrestyrt).",
"fields": {
"from_child_id": {
"name": "Fra barne-ID",
"description": "ID-en til barnet som sender poeng"
},
"to_child_id": {
"name": "Til barne-ID",
"description": "ID-en til barnet som mottar poeng"
},
"points": {
"name": "Poeng",
"description": "Antall poeng som skal overføres"
}
}
},
"postpone_mandatory_chore": {
"name": "Utsett obligatorisk oppgave",
"description": "Gi en glemt obligatorisk oppgave en ny periode (eller i morgen) uten straff, og fjern gjennomgangselementet.",
"fields": {
"miss_id": {
"name": "Forsømmelse-ID",
"description": "ID-en til gjennomgangselementet for den glemte obligatoriske oppgaven"
}
}
},
"request_swap": {
"name": "Be om oppgavebytte",
"description": "Et barn ber om å overta dagens rotasjonstildeling av en oppgave (venter på foreldregodkjenning).",
"fields": {
"chore_id": {
"name": "Oppgave-ID",
"description": "ID-en til oppgaven som skal overtas"
},
"requester_id": {
"name": "Barne-ID for forespørsel",
"description": "ID-en til barnet som ber om byttet"
}
}
},
"test_notification": {
"name": "Testvarsel",
"description": "Send et eksempelvarsel av den angitte typen til de aktiverte mottakerne (ignorerer hovedbryteren slik at rutingen kan verifiseres).",
"fields": {
"type_id": {
"name": "Varseltype",
"description": "ID for varseltype, f.eks. pending_chore_approval, badge_earned, streak_milestone"
}
}
},
"record_allowance_payout": {
"name": "Registrer lommepenge-utbetaling",
"description": "Trekk fra poeng og registrer en reell lommepenge-utbetaling i loggen (fast omregningskurs; foreldrestyrt).",
"fields": {
"child_id": {
"name": "Barne-ID",
"description": "ID-en til barnet som mottar utbetalingen"
},
"points": {
"name": "Poeng",
"description": "Poeng som skal regnes om til penger og trekkes fra"
}
}
}
},
"entity": {
"sensor": {
"child_stats": {
"unit_of_measurement": "oppgaver"
},
"child_badges": {
"unit_of_measurement": "merker"
}
},
"number": {
"weekend_multiplier": {
"name": "Helgemultiplikator"
},
"perfect_week_bonus": {
"name": "Bonus for perfekt uke"
}
},
"select": {
"streak_reset_mode": {
"name": "Tilbakestillingsmodus for rekke"
},
"card_design": {
"name": "Kortdesign"
}
}
}
}
@@ -0,0 +1,884 @@
{
"config": {
"step": {
"user": {
"title": "Velkomen til TaskMate!",
"description": "Set opp familieoppgåvesystemet ditt. Du kan tilpasse poengvalutaen borna skal tene.",
"data": {
"points_name": "Poengvalutanamn (t.d. Stjerner, Myntar, Kroner)",
"points_icon": "Poengikon"
}
}
},
"error": {
"name_required": "Namn er påkravd"
},
"abort": {
"already_configured": "TaskMate er allereie konfigurert"
}
},
"selector": {
"chore_action": {
"options": {
"save": "Lagre endringar",
"re_enable": "Reaktiver oppgåve",
"disable": "Deaktiver oppgåve",
"delete": "Slett denne oppgåva"
}
},
"reward_action": {
"options": {
"save": "Lagre endringar",
"delete": "Slett løning"
}
},
"time_category": {
"options": {
"morning": "Morgon",
"afternoon": "Ettermiddag",
"evening": "Kveld",
"night": "Natt",
"anytime": "Når som helst"
}
},
"schedule_mode": {
"options": {
"specific_days": "Bestemte vekedagar",
"recurring": "Gjentakande (kvar N dag/veke/månad)",
"one_shot": "Eingongsoppgåve (éin dag)"
}
},
"due_days_option": {
"options": {
"monday": "Måndag",
"tuesday": "Tysdag",
"wednesday": "Onsdag",
"thursday": "Torsdag",
"friday": "Fredag",
"saturday": "Laurdag",
"sunday": "Sundag"
}
},
"recurrence": {
"options": {
"every_2_days": "Annakvar dag",
"weekly": "Vekeleg",
"every_2_weeks": "Annakvar veke",
"monthly": "Månadleg",
"every_3_months": "Kvar 3. månad",
"every_6_months": "Kvar 6. månad"
}
},
"recurrence_day_option": {
"options": {
"monday": "Måndag",
"tuesday": "Tysdag",
"wednesday": "Onsdag",
"thursday": "Torsdag",
"friday": "Fredag",
"saturday": "Laurdag",
"sunday": "Sundag",
"any_day": "Kva dag som helst"
}
},
"first_occurrence_mode": {
"options": {
"available_immediately": "Tilgjengeleg med ein gong",
"wait_for_first_occurrence": "Vent på fyrste planlagde førekomst"
}
},
"streak_reset_mode": {
"options": {
"reset": "Nullstill — rekka går til 0 ved tapt dag",
"pause": "Pause — rekka vert bevart til neste fullføring"
}
},
"visibility_operator": {
"options": {
"none": "Ingen filter — Vis oppgåva alltid",
"equals": "Lik — Vis når eininga si tilstand stemmer nøyaktig",
"not_equals": "Ikkje lik — Vis når eininga si tilstand ikkje stemmer",
"gte": "Større enn eller lik (≥) — Vis når numerisk verdi er ≥ målverdien",
"lte": "Mindre enn eller lik (≤) — Vis når numerisk verdi er ≤ målverdien",
"gt": "Større enn (>) — Vis når numerisk verdi er > målverdien",
"lt": "Mindre enn (<) — Vis når numerisk verdi er < målverdien"
}
},
"assignment_mode": {
"options": {
"everyone": "Alle — kvart tildelt barn ser oppgåva",
"alternating": "Vekslande — roter mellom dei tildelte barna, eitt per dag",
"random": "Tilfeldig — plukk eitt tildelt barn tilfeldig kvar dag",
"balanced": "Balansert — fordel dagens balanserte oppgåver jamt mellom dei tildelte barna",
"first_come": "Førstemann til mølla — første barn som fullfører den vinn; vert skjult for dei andre",
"unassigned": "Ikkje tildelt — ingen born ser denne oppgåva"
}
},
"task_group_policy": {
"options": {
"sticky": "Sticky — same barn for alle oppgåver i gruppa",
"spread": "Spread — ulike barn i gruppa i dag"
}
},
"task_group_action": {
"options": {
"save": "Lagre endringar",
"delete": "Slett gruppe"
}
}
},
"services": {
"complete_chore": {
"name": "Fullfør oppgåve",
"description": "Merk ei oppgåve som fullført av eit born",
"fields": {
"chore_id": {
"name": "Oppgåve-ID",
"description": "ID-en til oppgåva som skal fullførast"
},
"child_id": {
"name": "Born-ID",
"description": "ID-en til bornet som fullfører oppgåva"
}
}
},
"complete_bonus_subtask": {
"name": "Fullfør bonus-deloppgåve",
"description": "Fullfør ei bonus-deloppgåve etter at hovudoppgåva er fullført",
"fields": {
"chore_id": {
"name": "Oppgåve-ID",
"description": "ID-en til hovudoppgåva"
},
"bonus_subtask_id": {
"name": "Bonus-deloppgåve-ID",
"description": "ID-en til bonus-deloppgåva som skal fullførast"
},
"child_id": {
"name": "Born-ID",
"description": "ID-en til bornet som fullfører bonus-deloppgåva"
}
}
},
"approve_chore": {
"name": "Godkjenn oppgåve",
"description": "Godkjenn ei fullført oppgåve og tildel poeng",
"fields": {
"completion_id": {
"name": "Fullførings-ID",
"description": "ID-en til oppgåvefullføringa som skal godkjennast"
}
}
},
"approve_all_chores": {
"name": "Godkjenn alle gjeremål",
"description": "Godkjenn alle ventande fullførte gjeremål (eller eit bestemt utval) og tildel poeng",
"fields": {
"completion_ids": {
"name": "Fullførings-ID-ar",
"description": "Bestemte fullførings-ID-ar som skal godkjennast; utelat for å godkjenne alle ventande"
}
}
},
"reject_chore": {
"name": "Avvis oppgåve",
"description": "Avvis ei fullført oppgåve",
"fields": {
"completion_id": {
"name": "Fullførings-ID",
"description": "ID-en til oppgåvefullføringa som skal avvisast"
}
}
},
"undo_chore_approval": {
"name": "Angre godkjenning av gjeremål",
"description": "Angre ei utilsikta godkjenning av eit gjeremål — fjernar dei tildelte poenga og set fullføringa tilbake til ventar, slik at ho kan godkjennast på nytt.",
"fields": {
"completion_id": {
"name": "Fullførings-ID",
"description": "ID-en til den godkjende gjeremålsfullføringa som skal setjast tilbake til ventar"
}
}
},
"undo_transaction": {
"name": "Angre transaksjon",
"description": "Angre ein poengtransaksjon med ID-en — straffer, bonusar, manuelle justeringar (legg til/fjern) og gåver (begge sider). Automatiske transaksjonar (helg-/rekkje-/perfekt veke-bonusar, sparekrukke-rørsler, forfall, renter) kan ikkje angrast.",
"fields": {
"transaction_id": {
"name": "Transaksjons-ID",
"description": "ID-en til poengtransaksjonen som skal angrast"
}
}
},
"claim_reward": {
"name": "Hent løning",
"description": "Bornet hentar ei løning med poenga sine",
"fields": {
"reward_id": {
"name": "Lønings-ID",
"description": "ID-en til løninga som skal hentast"
},
"child_id": {
"name": "Born-ID",
"description": "ID-en til bornet som hentar løninga"
}
}
},
"approve_reward": {
"name": "Godkjenn løning",
"description": "Godkjenn eit løningskrav",
"fields": {
"claim_id": {
"name": "Krav-ID",
"description": "ID-en til løningskravet som skal godkjennast"
}
}
},
"allocate_points_to_pool": {
"name": "Tildel poeng til sparegris",
"description": "Flytt poeng frå den disponible saldoen til eit born inn i sparegrisen til ei løning (låst).",
"fields": {
"child_id": {
"name": "Born-ID",
"description": "ID-en til bornet som gjer tildelinga"
},
"reward_id": {
"name": "Lønings-ID",
"description": "ID-en til sparegrisen til løninga det skal setjast inn i"
},
"points": {
"name": "Poeng",
"description": "Tal poeng å tildele (avgrensa av kapasiteten til sparegrisen og disponibel saldo)"
}
}
},
"add_points": {
"name": "Legg til poeng",
"description": "Legg til bonuspoeng til eit born",
"fields": {
"child_id": {
"name": "Born-ID",
"description": "ID-en til bornet som skal få poeng"
},
"points": {
"name": "Poeng",
"description": "Tal poeng å leggje til"
},
"reason": {
"name": "Grunn",
"description": "Valfri grunn for bonusen"
}
}
},
"remove_points": {
"name": "Fjern poeng",
"description": "Fjern poeng frå eit born (straff)",
"fields": {
"child_id": {
"name": "Born-ID",
"description": "ID-en til bornet som skal miste poeng"
},
"points": {
"name": "Poeng",
"description": "Tal poeng å fjerne"
},
"reason": {
"name": "Grunn",
"description": "Valfri grunn for straffen"
}
}
},
"reject_reward": {
"name": "Avvis løning",
"description": "Avvis eit løningskrav",
"fields": {
"claim_id": {
"name": "Krav-ID",
"description": "ID-en til løningskravet som skal avvisast"
}
}
},
"preview_sound": {
"name": "Førehandsvise lyd",
"description": "Førehandsvise ein fullføringslydeffekt i nettlesaren",
"fields": {
"sound": {
"name": "Lyd",
"description": "Lyden som skal førehandsvisast"
}
}
},
"set_chore_order": {
"name": "Set oppgåverekkjefølgje",
"description": "Set visingsrekkjefølgja for oppgåver for eit born",
"fields": {
"child_id": {
"name": "Born-ID",
"description": "ID-en til bornet å sette oppgåverekkjefølgje for"
},
"chore_order": {
"name": "Oppgåverekkjefølgje",
"description": "Ordna liste over oppgåve-ID-ar"
}
}
},
"add_penalty": {
"name": "Legg til straff",
"description": "Opprett ein ny straffedefinisjon",
"fields": {
"name": {
"name": "Namn",
"description": "Namnet på straffen"
},
"points": {
"name": "Poeng",
"description": "Tal poeng å trekkje når ho vert brukt"
},
"description": {
"name": "Omtale",
"description": "Valfri omtale av straffen"
},
"icon": {
"name": "Ikon",
"description": "MDI-ikon for straffen"
},
"assigned_to": {
"name": "Tildelt til",
"description": "Liste over born-ID-ar denne straffen gjeld for"
}
}
},
"update_penalty": {
"name": "Oppdater straff",
"description": "Oppdater ein eksisterande straffedefinisjon",
"fields": {
"penalty_id": {
"name": "Straffe-ID",
"description": "ID-en til straffen som skal oppdaterast"
},
"name": {
"name": "Namn",
"description": "Nytt namn for straffen"
},
"points": {
"name": "Poeng",
"description": "Ny poengverdi"
},
"description": {
"name": "Omtale",
"description": "Ny omtale"
},
"icon": {
"name": "Ikon",
"description": "Nytt ikon"
},
"assigned_to": {
"name": "Tildelt til",
"description": "Ny liste over born-ID-ar"
}
}
},
"remove_penalty": {
"name": "Fjern straff",
"description": "Fjern ein straffedefinisjon",
"fields": {
"penalty_id": {
"name": "Straffe-ID",
"description": "ID-en til straffen som skal fjernast"
}
}
},
"apply_penalty": {
"name": "Bruk straff",
"description": "Bruk ein straff på eit born og trekk poeng",
"fields": {
"penalty_id": {
"name": "Straffe-ID",
"description": "ID-en til straffen som skal brukast"
},
"child_id": {
"name": "Born-ID",
"description": "ID-en til bornet straffen skal brukast på"
}
}
},
"add_bonus": {
"name": "Legg til bonus",
"description": "Opprett ein ny bonusdefinisjon",
"fields": {
"name": {
"name": "Namn",
"description": "Namnet på bonusen"
},
"points": {
"name": "Poeng",
"description": "Kor mange poeng som vert tildelt når bonusen vert brukt"
},
"description": {
"name": "Omtale",
"description": "Valfri omtale av bonusen"
},
"icon": {
"name": "Ikon",
"description": "MDI-ikon for bonusen"
},
"assigned_to": {
"name": "Tildelt til",
"description": "Liste over born-ID-ar bonusen gjeld for"
}
}
},
"update_bonus": {
"name": "Oppdater bonus",
"description": "Oppdater ein eksisterande bonusdefinisjon",
"fields": {
"bonus_id": {
"name": "Bonus-ID",
"description": "ID-en til bonusen som skal oppdaterast"
},
"name": {
"name": "Namn",
"description": "Nytt namn for bonusen"
},
"points": {
"name": "Poeng",
"description": "Ny poengverdi"
},
"description": {
"name": "Omtale",
"description": "Ny omtale"
},
"icon": {
"name": "Ikon",
"description": "Nytt ikon"
},
"assigned_to": {
"name": "Tildelt til",
"description": "Ny liste over born-ID-ar"
}
}
},
"remove_bonus": {
"name": "Fjern bonus",
"description": "Fjern ein bonusdefinisjon",
"fields": {
"bonus_id": {
"name": "Bonus-ID",
"description": "ID-en til bonusen som skal fjernast"
}
}
},
"apply_bonus": {
"name": "Bruk bonus",
"description": "Bruk ein bonus på eit born og tildel poeng",
"fields": {
"bonus_id": {
"name": "Bonus-ID",
"description": "ID-en til bonusen som skal brukast"
},
"child_id": {
"name": "Born-ID",
"description": "ID-en til bornet bonusen skal brukast på"
}
}
},
"add_chore": {
"name": "Legg til oppgåve",
"description": "Opprett ei ny oppgåve dynamisk, vanlegvis frå ein automasjon eller eit skript",
"fields": {
"name": {
"name": "Namn",
"description": "Namnet på oppgåva"
},
"description": {
"name": "Skildring",
"description": "Valfri skildring av oppgåva"
},
"points": {
"name": "Poeng",
"description": "Poeng som vert tildelte når oppgåva er fullført"
},
"assigned_to": {
"name": "Tildelt",
"description": "Liste over born-ID-ar oppgåva skal tildelast. Lat stå tom for å tildele alle born"
},
"time_category": {
"name": "Tidskategori",
"description": "Når oppgåva skal fullførast: morning, afternoon, evening, night eller anytime"
},
"one_shot": {
"name": "Eingongsoppgåve",
"description": "Om sann er dette ei eingongsoppgåve som går ut etter fullføring eller ved dagens slutt"
},
"requires_approval": {
"name": "Krev godkjenning",
"description": "Om sann må ein forelder godkjenne fullføringa før poeng vert tildelte"
}
}
},
"skip_chore": {
"name": "Skip Chore",
"description": "Advance today's rotation past the current assignee. Ephemeral — tomorrow's rotation resumes as normal.",
"fields": {
"chore_id": {
"name": "Chore ID",
"description": "The ID of the chore to skip"
}
}
},
"set_chore_manual_start": {
"name": "Set Manual Start Child",
"description": "Set which child starts a rotating chore. For alternating mode this reorders the pool; for random/balanced it only overrides today's assignee.",
"fields": {
"chore_id": {
"name": "Chore ID",
"description": "The ID of the chore to update"
},
"child_id": {
"name": "Child ID",
"description": "The ID of the child who should start the rotation"
}
}
},
"add_task_group": {
"name": "Add Task Group",
"description": "Create a task group (sticky = same child, spread = different children).",
"fields": {
"name": {
"name": "Name",
"description": "Display name for the group"
},
"policy": {
"name": "Policy",
"description": "sticky (same child) or spread (different children)"
},
"chore_ids": {
"name": "Chore IDs",
"description": "List of chore IDs in the group. For sticky, the first chore is the leader."
}
}
},
"update_task_group": {
"name": "Update Task Group",
"description": "Update a task group's name, policy, or membership.",
"fields": {
"group_id": {
"name": "Group ID",
"description": "The ID of the group to update"
},
"name": {
"name": "Name",
"description": "New display name"
},
"policy": {
"name": "Policy",
"description": "New policy (sticky or spread)"
},
"chore_ids": {
"name": "Chore IDs",
"description": "Replacement chore ID list. For sticky, the first chore is the leader."
}
}
},
"remove_task_group": {
"name": "Remove Task Group",
"description": "Delete a task group. Member chores are not affected.",
"fields": {
"group_id": {
"name": "Group ID",
"description": "The ID of the group to delete"
}
}
},
"start_timed_task": {
"name": "Start tidsstyrt oppgåve",
"description": "Start eller hald fram timeren på ei tidsstyrt oppgåve",
"fields": {
"chore_id": {
"name": "Oppgåve-ID",
"description": "ID-en til den tidsstyrte oppgåva"
},
"child_id": {
"name": "Born-ID",
"description": "ID-en til bornet som startar timeren"
}
}
},
"pause_timed_task": {
"name": "Set tidsstyrt oppgåve på pause",
"description": "Set timeren på ei tidsstyrt oppgåve på pause",
"fields": {
"chore_id": {
"name": "Oppgåve-ID",
"description": "ID-en til den tidsstyrte oppgåva"
},
"child_id": {
"name": "Born-ID",
"description": "ID-en til bornet som pausar timeren"
}
}
},
"stop_timed_task": {
"name": "Stopp tidsstyrt oppgåve",
"description": "Stopp timeren på ei tidsstyrt oppgåve og send inn til godkjenning",
"fields": {
"chore_id": {
"name": "Oppgåve-ID",
"description": "ID-en til den tidsstyrte oppgåva"
},
"child_id": {
"name": "Born-ID",
"description": "ID-en til bornet som stoppar timeren"
}
}
},
"add_badge": {
"name": "Legg til merke",
"description": "Opprett eit eigendefinert merke.",
"fields": {
"name": {
"name": "Namn",
"description": "Visingsnamnet til merket"
},
"description": {
"name": "Skildring",
"description": "Skildring av korleis merket vert oppnådd"
},
"icon": {
"name": "Ikon",
"description": "Ikon for merket (t.d. mdi:beach)"
},
"tier": {
"name": "Nivå",
"description": "Nivået til merket: bronse, sølv, gull eller platina"
},
"point_bonus": {
"name": "Poengbonus",
"description": "Poeng som vert krediterte bornet når merket vert oppnådd"
},
"criteria": {
"name": "Kriterium",
"description": "Liste med metric / operator / value-oppføringar. Tom = berre manuell tildeling."
},
"assigned_to": {
"name": "Tildelt til",
"description": "Liste med born-ID-ar (tom = alle)"
},
"notify_on_earn": {
"name": "Varsle ved oppnåing",
"description": "Send eit varsel når merket vert oppnådd"
}
}
},
"update_badge": {
"name": "Oppdater merke",
"description": "Oppdater eit eksisterande merke. For innebygde merke kan berre point_bonus, tier, assigned_to, enabled og notify_on_earn redigerast.",
"fields": {
"badge_id": {
"name": "Merke-ID",
"description": "ID-en til merket som skal oppdaterast"
},
"name": {
"name": "Namn",
"description": "Nytt visingsnamn for merket"
},
"description": {
"name": "Skildring",
"description": "Ny skildring av merket"
},
"icon": {
"name": "Ikon",
"description": "Nytt ikon for merket"
},
"tier": {
"name": "Nivå",
"description": "Nivået til merket: bronse, sølv, gull eller platina"
},
"point_bonus": {
"name": "Poengbonus",
"description": "Poeng som vert krediterte bornet når merket vert oppnådd"
},
"criteria": {
"name": "Kriterium",
"description": "Liste med metric / operator / value-oppføringar. Tom = berre manuell tildeling."
},
"assigned_to": {
"name": "Tildelt til",
"description": "Liste med born-ID-ar (tom = alle)"
},
"enabled": {
"name": "Aktivert",
"description": "Om merket er aktivt"
},
"notify_on_earn": {
"name": "Varsle ved oppnåing",
"description": "Send eit varsel når merket vert oppnådd"
}
}
},
"remove_badge": {
"name": "Fjern merke",
"description": "Fjern eit eigendefinert merke. Innebygde merke kan ikkje fjernast.",
"fields": {
"badge_id": {
"name": "Merke-ID",
"description": "ID-en til merket som skal fjernast"
}
}
},
"award_badge_manually": {
"name": "Tildel merke manuelt",
"description": "Tildel eit merke manuelt til eit born.",
"fields": {
"badge_id": {
"name": "Merke-ID",
"description": "ID-en til merket som skal tildelast"
},
"child_id": {
"name": "Born-ID",
"description": "ID-en til bornet som tek imot merket"
}
}
},
"revoke_badge": {
"name": "Tilbakekall merke",
"description": "Tilbakekall eit tildelt merke. Om ein poengbonus vart kreditert, vert han reversert.",
"fields": {
"awarded_badge_id": {
"name": "Tildelt merke-ID",
"description": "ID-en til det tildelte merket som skal tilbakekallast"
}
}
},
"rebuild_badges": {
"name": "Bygg opp att merke",
"description": "Reevaluer alle merke for alle born i det stille (ingen varsel, ingen poengbonusar)."
},
"apply_mandatory_penalty": {
"name": "Bruk obligatorisk straff",
"description": "Trekk frå dei konfigurerte straffepoenga for ei gløymd obligatorisk oppgåve og fjern gjennomgangselementet.",
"fields": {
"miss_id": {
"name": "Forsøming-ID",
"description": "ID-en til gjennomgangselementet for den gløymde obligatoriske oppgåva"
}
}
},
"choose_avatar": {
"name": "Vel avatar",
"description": "Eit barn byter til ein avatar dei har låst opp.",
"fields": {
"child_id": {
"name": "Barne-ID",
"description": "ID-en til barnet"
},
"icon": {
"name": "Avatar-ikon",
"description": "mdi-ikonet til ein opplåst katalog-avatar (t.d. mdi:rocket-launch)."
}
}
},
"dismiss_mandatory_chore": {
"name": "Avvis obligatorisk oppgåve",
"description": "Fjern gjennomgangselementet for ei gløymd obligatorisk oppgåve utan straff.",
"fields": {
"miss_id": {
"name": "Forsøming-ID",
"description": "ID-en til gjennomgangselementet for den gløymde obligatoriske oppgåva"
}
}
},
"gift_points": {
"name": "Gi bort poeng",
"description": "Overfør brukbare poeng frå eitt barn til eit anna (foreldrestyrt).",
"fields": {
"from_child_id": {
"name": "Frå barne-ID",
"description": "ID-en til barnet som sender poeng"
},
"to_child_id": {
"name": "Til barne-ID",
"description": "ID-en til barnet som mottek poeng"
},
"points": {
"name": "Poeng",
"description": "Talet på poeng som skal overførast"
}
}
},
"postpone_mandatory_chore": {
"name": "Utsett obligatorisk oppgåve",
"description": "Gi ei gløymd obligatorisk oppgåve ein ny periode (eller i morgon) utan straff, og fjern gjennomgangselementet.",
"fields": {
"miss_id": {
"name": "Forsøming-ID",
"description": "ID-en til gjennomgangselementet for den gløymde obligatoriske oppgåva"
}
}
},
"request_swap": {
"name": "Be om oppgåvebyte",
"description": "Eit barn ber om å overta dagens rotasjonstildeling av ei oppgåve (ventar på foreldregodkjenning).",
"fields": {
"chore_id": {
"name": "Oppgåve-ID",
"description": "ID-en til oppgåva som skal overtakast"
},
"requester_id": {
"name": "Barne-ID for førespurnad",
"description": "ID-en til barnet som ber om bytet"
}
}
},
"test_notification": {
"name": "Testvarsel",
"description": "Send eit eksempelvarsel av den angitte typen til dei aktiverte mottakarane (ignorerer hovudbrytaren slik at rutinga kan verifiserast).",
"fields": {
"type_id": {
"name": "Varseltype",
"description": "ID for varseltype, t.d. pending_chore_approval, badge_earned, streak_milestone"
}
}
},
"record_allowance_payout": {
"name": "Registrer lommepenge-utbetaling",
"description": "Trekk frå poeng og registrer ei reell lommepenge-utbetaling i loggen (fast omrekningskurs; foreldrestyrt).",
"fields": {
"child_id": {
"name": "Barne-ID",
"description": "ID-en til barnet som mottek utbetalinga"
},
"points": {
"name": "Poeng",
"description": "Poeng som skal reknast om til pengar og trekkjast frå"
}
}
}
},
"entity": {
"sensor": {
"child_stats": {
"unit_of_measurement": "oppgåver"
},
"child_badges": {
"unit_of_measurement": "merke"
}
},
"number": {
"weekend_multiplier": {
"name": "Helgemultiplikator"
},
"perfect_week_bonus": {
"name": "Bonus for perfekt veke"
}
},
"select": {
"streak_reset_mode": {
"name": "Tilbakestillingsmodus for rekkje"
},
"card_design": {
"name": "Kortdesign"
}
}
}
}
@@ -0,0 +1,884 @@
{
"config": {
"step": {
"user": {
"title": "Bem-vindo ao TaskMate!",
"description": "Configure o seu sistema de gestão de tarefas familiar. Pode personalizar a moeda de pontos que as crianças vão ganhar.",
"data": {
"points_name": "Nome da moeda de pontos (ex: Estrelas, Moedas, Pontos)",
"points_icon": "Ícone dos pontos"
}
}
},
"error": {
"name_required": "O nome é obrigatório"
},
"abort": {
"already_configured": "O TaskMate já está configurado"
}
},
"selector": {
"chore_action": {
"options": {
"save": "Guardar alterações",
"re_enable": "Reativar tarefa",
"disable": "Desativar tarefa",
"delete": "Eliminar esta tarefa"
}
},
"reward_action": {
"options": {
"save": "Guardar alterações",
"delete": "Eliminar recompensa"
}
},
"time_category": {
"options": {
"morning": "Manhã",
"afternoon": "Tarde",
"evening": "Fim da tarde",
"night": "Noite",
"anytime": "Qualquer hora"
}
},
"schedule_mode": {
"options": {
"specific_days": "Dias específicos da semana",
"recurring": "Recorrente (a cada N dias/semanas/meses)",
"one_shot": "Tarefa única (um dia)"
}
},
"due_days_option": {
"options": {
"monday": "Segunda-feira",
"tuesday": "Terça-feira",
"wednesday": "Quarta-feira",
"thursday": "Quinta-feira",
"friday": "Sexta-feira",
"saturday": "Sábado",
"sunday": "Domingo"
}
},
"recurrence": {
"options": {
"every_2_days": "A cada 2 dias",
"weekly": "Semanal",
"every_2_weeks": "A cada 2 semanas",
"monthly": "Mensal",
"every_3_months": "A cada 3 meses",
"every_6_months": "A cada 6 meses"
}
},
"recurrence_day_option": {
"options": {
"monday": "Segunda-feira",
"tuesday": "Terça-feira",
"wednesday": "Quarta-feira",
"thursday": "Quinta-feira",
"friday": "Sexta-feira",
"saturday": "Sábado",
"sunday": "Domingo",
"any_day": "Qualquer dia"
}
},
"first_occurrence_mode": {
"options": {
"available_immediately": "Disponível imediatamente",
"wait_for_first_occurrence": "Aguardar primeira ocorrência agendada"
}
},
"streak_reset_mode": {
"options": {
"reset": "Reiniciar — sequência volta a 0 num dia falhado",
"pause": "Pausar — sequência preservada até próxima conclusão"
}
},
"visibility_operator": {
"options": {
"none": "Sem filtro — Sempre mostrar a tarefa",
"equals": "Igual — Mostrar quando o estado da entidade corresponde exatamente",
"not_equals": "Não igual — Mostrar quando o estado da entidade não corresponde",
"gte": "Maior ou igual (≥) — Mostrar quando valor numérico é ≥ alvo",
"lte": "Menor ou igual (≤) — Mostrar quando valor numérico é ≤ alvo",
"gt": "Maior que (>) — Mostrar quando valor numérico é > alvo",
"lt": "Menor que (<) — Mostrar quando valor numérico é < alvo"
}
},
"assignment_mode": {
"options": {
"everyone": "Todos — todas as crianças atribuídas veem a tarefa",
"alternating": "Alternado — alterna entre as crianças atribuídas, uma por dia",
"random": "Aleatório — escolhe aleatoriamente uma criança atribuída por dia",
"balanced": "Equilibrado — divide as tarefas equilibradas do dia igualmente entre as crianças atribuídas",
"first_come": "Quem chega primeiro — a primeira criança a concluir ganha; some para as demais",
"unassigned": "Não atribuído — nenhuma criança vê esta tarefa"
}
},
"task_group_policy": {
"options": {
"sticky": "Sticky — mesma criança para todas as tarefas do grupo",
"spread": "Spread — crianças diferentes no grupo hoje"
}
},
"task_group_action": {
"options": {
"save": "Salvar alterações",
"delete": "Excluir grupo"
}
}
},
"services": {
"complete_chore": {
"name": "Concluir tarefa",
"description": "Marcar uma tarefa como concluída por uma criança",
"fields": {
"chore_id": {
"name": "ID da tarefa",
"description": "O ID da tarefa a concluir"
},
"child_id": {
"name": "ID da criança",
"description": "O ID da criança que conclui a tarefa"
}
}
},
"complete_bonus_subtask": {
"name": "Concluir subtarefa bônus",
"description": "Concluir uma subtarefa bônus depois que a tarefa principal for concluída",
"fields": {
"chore_id": {
"name": "ID da tarefa",
"description": "O ID da tarefa principal"
},
"bonus_subtask_id": {
"name": "ID da subtarefa bônus",
"description": "O ID da subtarefa bônus a concluir"
},
"child_id": {
"name": "ID da criança",
"description": "O ID da criança que conclui a subtarefa bônus"
}
}
},
"approve_chore": {
"name": "Aprovar tarefa",
"description": "Aprovar uma tarefa concluída e atribuir pontos",
"fields": {
"completion_id": {
"name": "ID de conclusão",
"description": "O ID da conclusão da tarefa a aprovar"
}
}
},
"approve_all_chores": {
"name": "Aprovar todas as tarefas",
"description": "Aprovar todas as conclusões de tarefas pendentes (ou um conjunto específico) e atribuir pontos",
"fields": {
"completion_ids": {
"name": "IDs de conclusão",
"description": "IDs de conclusão específicos para aprovar; omita para aprovar todas as pendentes"
}
}
},
"reject_chore": {
"name": "Rejeitar tarefa",
"description": "Rejeitar uma tarefa concluída",
"fields": {
"completion_id": {
"name": "ID de conclusão",
"description": "O ID da conclusão da tarefa a rejeitar"
}
}
},
"undo_chore_approval": {
"name": "Desfazer aprovação de tarefa",
"description": "Desfazer a aprovação acidental de uma tarefa — remove os pontos concedidos e retorna a conclusão para pendente para que possa ser aprovada novamente.",
"fields": {
"completion_id": {
"name": "ID de conclusão",
"description": "O ID da conclusão de tarefa aprovada a retornar para pendente"
}
}
},
"undo_transaction": {
"name": "Desfazer transação",
"description": "Desfazer uma transação de pontos pelo seu ID — penalidades, bônus, ajustes manuais (adicionar/remover) e presentes (ambos os lados). Transações automáticas (bônus de fim de semana/sequência/semana perfeita, movimentações do cofrinho, desvalorização, juros) não podem ser desfeitas.",
"fields": {
"transaction_id": {
"name": "ID da transação",
"description": "O ID da transação de pontos a reverter"
}
}
},
"claim_reward": {
"name": "Reclamar recompensa",
"description": "A criança reclama uma recompensa usando os seus pontos",
"fields": {
"reward_id": {
"name": "ID da recompensa",
"description": "O ID da recompensa a reclamar"
},
"child_id": {
"name": "ID da criança",
"description": "O ID da criança que reclama a recompensa"
}
}
},
"approve_reward": {
"name": "Aprovar recompensa",
"description": "Aprovar um pedido de recompensa",
"fields": {
"claim_id": {
"name": "ID do pedido",
"description": "O ID do pedido de recompensa a aprovar"
}
}
},
"allocate_points_to_pool": {
"name": "Alocar pontos ao cofrinho",
"description": "Mover pontos do saldo disponível de uma criança para o cofrinho de uma recompensa (bloqueado).",
"fields": {
"child_id": {
"name": "ID da criança",
"description": "O ID da criança que faz a alocação"
},
"reward_id": {
"name": "ID da recompensa",
"description": "O ID do cofrinho da recompensa onde depositar"
},
"points": {
"name": "Pontos",
"description": "Número de pontos a alocar (limitado pela capacidade do cofrinho e pelo saldo disponível)"
}
}
},
"add_points": {
"name": "Adicionar pontos",
"description": "Adicionar pontos de bónus a uma criança",
"fields": {
"child_id": {
"name": "ID da criança",
"description": "O ID da criança a quem adicionar pontos"
},
"points": {
"name": "Pontos",
"description": "O número de pontos a adicionar"
},
"reason": {
"name": "Motivo",
"description": "Motivo opcional para o bónus"
}
}
},
"remove_points": {
"name": "Remover pontos",
"description": "Remover pontos de uma criança (penalização)",
"fields": {
"child_id": {
"name": "ID da criança",
"description": "O ID da criança a quem remover pontos"
},
"points": {
"name": "Pontos",
"description": "O número de pontos a remover"
},
"reason": {
"name": "Motivo",
"description": "Motivo opcional para a penalização"
}
}
},
"reject_reward": {
"name": "Rejeitar recompensa",
"description": "Rejeitar um pedido de recompensa",
"fields": {
"claim_id": {
"name": "ID do pedido",
"description": "O ID do pedido de recompensa a rejeitar"
}
}
},
"preview_sound": {
"name": "Pré-visualizar som",
"description": "Pré-visualizar um efeito sonoro de conclusão no navegador",
"fields": {
"sound": {
"name": "Som",
"description": "O som a pré-visualizar"
}
}
},
"set_chore_order": {
"name": "Definir ordem das tarefas",
"description": "Definir a ordem de apresentação das tarefas para uma criança",
"fields": {
"child_id": {
"name": "ID da criança",
"description": "O ID da criança para definir a ordem das tarefas"
},
"chore_order": {
"name": "Ordem das tarefas",
"description": "Lista ordenada de IDs de tarefas"
}
}
},
"add_penalty": {
"name": "Adicionar penalização",
"description": "Criar uma nova definição de penalização",
"fields": {
"name": {
"name": "Nome",
"description": "O nome da penalização"
},
"points": {
"name": "Pontos",
"description": "O número de pontos a deduzir quando aplicada"
},
"description": {
"name": "Descrição",
"description": "Descrição opcional da penalização"
},
"icon": {
"name": "Ícone",
"description": "Ícone MDI para a penalização"
},
"assigned_to": {
"name": "Atribuída a",
"description": "Lista de IDs de crianças a quem esta penalização se aplica"
}
}
},
"update_penalty": {
"name": "Atualizar penalização",
"description": "Atualizar uma definição de penalização existente",
"fields": {
"penalty_id": {
"name": "ID da penalização",
"description": "O ID da penalização a atualizar"
},
"name": {
"name": "Nome",
"description": "Novo nome para a penalização"
},
"points": {
"name": "Pontos",
"description": "Novo valor de pontos"
},
"description": {
"name": "Descrição",
"description": "Nova descrição"
},
"icon": {
"name": "Ícone",
"description": "Novo ícone"
},
"assigned_to": {
"name": "Atribuída a",
"description": "Nova lista de IDs de crianças"
}
}
},
"remove_penalty": {
"name": "Remover penalização",
"description": "Remover uma definição de penalização",
"fields": {
"penalty_id": {
"name": "ID da penalização",
"description": "O ID da penalização a remover"
}
}
},
"apply_penalty": {
"name": "Aplicar penalização",
"description": "Aplicar uma penalização a uma criança, deduzindo pontos",
"fields": {
"penalty_id": {
"name": "ID da penalização",
"description": "O ID da penalização a aplicar"
},
"child_id": {
"name": "ID da criança",
"description": "O ID da criança a quem aplicar a penalização"
}
}
},
"add_bonus": {
"name": "Adicionar bônus",
"description": "Criar uma nova definição de bônus",
"fields": {
"name": {
"name": "Nome",
"description": "O nome do bônus"
},
"points": {
"name": "Pontos",
"description": "O número de pontos a conceder quando aplicado"
},
"description": {
"name": "Descrição",
"description": "Descrição opcional do bônus"
},
"icon": {
"name": "Ícone",
"description": "Ícone MDI para o bônus"
},
"assigned_to": {
"name": "Atribuído a",
"description": "Lista de IDs de crianças a que este bônus se aplica"
}
}
},
"update_bonus": {
"name": "Atualizar bônus",
"description": "Atualizar uma definição de bônus existente",
"fields": {
"bonus_id": {
"name": "ID do bônus",
"description": "O ID do bônus a atualizar"
},
"name": {
"name": "Nome",
"description": "Novo nome para o bônus"
},
"points": {
"name": "Pontos",
"description": "Novo valor de pontos"
},
"description": {
"name": "Descrição",
"description": "Nova descrição"
},
"icon": {
"name": "Ícone",
"description": "Novo ícone"
},
"assigned_to": {
"name": "Atribuído a",
"description": "Nova lista de IDs de crianças"
}
}
},
"remove_bonus": {
"name": "Remover bônus",
"description": "Remover uma definição de bônus",
"fields": {
"bonus_id": {
"name": "ID do bônus",
"description": "O ID do bônus a remover"
}
}
},
"apply_bonus": {
"name": "Aplicar bônus",
"description": "Aplicar um bônus a uma criança, concedendo pontos",
"fields": {
"bonus_id": {
"name": "ID do bônus",
"description": "O ID do bônus a aplicar"
},
"child_id": {
"name": "ID da criança",
"description": "O ID da criança a quem aplicar o bônus"
}
}
},
"add_chore": {
"name": "Adicionar tarefa",
"description": "Criar uma nova tarefa dinamicamente, normalmente a partir de uma automação ou script",
"fields": {
"name": {
"name": "Nome",
"description": "O nome da tarefa"
},
"description": {
"name": "Descrição",
"description": "Descrição opcional da tarefa"
},
"points": {
"name": "Pontos",
"description": "Pontos concedidos quando esta tarefa é concluída"
},
"assigned_to": {
"name": "Atribuída a",
"description": "Lista de IDs de crianças para atribuir esta tarefa. Deixe em branco para atribuir a todas as crianças"
},
"time_category": {
"name": "Categoria de tempo",
"description": "Quando esta tarefa deve ser concluída: morning, afternoon, evening, night ou anytime"
},
"one_shot": {
"name": "Única",
"description": "Se verdadeiro, é uma tarefa única que expira após a conclusão ou no final do dia"
},
"requires_approval": {
"name": "Requer aprovação",
"description": "Se verdadeiro, um pai/mãe deve aprovar a conclusão antes de os pontos serem concedidos"
}
}
},
"skip_chore": {
"name": "Skip Chore",
"description": "Advance today's rotation past the current assignee. Ephemeral — tomorrow's rotation resumes as normal.",
"fields": {
"chore_id": {
"name": "Chore ID",
"description": "The ID of the chore to skip"
}
}
},
"set_chore_manual_start": {
"name": "Set Manual Start Child",
"description": "Set which child starts a rotating chore. For alternating mode this reorders the pool; for random/balanced it only overrides today's assignee.",
"fields": {
"chore_id": {
"name": "Chore ID",
"description": "The ID of the chore to update"
},
"child_id": {
"name": "Child ID",
"description": "The ID of the child who should start the rotation"
}
}
},
"add_task_group": {
"name": "Add Task Group",
"description": "Create a task group (sticky = same child, spread = different children).",
"fields": {
"name": {
"name": "Name",
"description": "Display name for the group"
},
"policy": {
"name": "Policy",
"description": "sticky (same child) or spread (different children)"
},
"chore_ids": {
"name": "Chore IDs",
"description": "List of chore IDs in the group. For sticky, the first chore is the leader."
}
}
},
"update_task_group": {
"name": "Update Task Group",
"description": "Update a task group's name, policy, or membership.",
"fields": {
"group_id": {
"name": "Group ID",
"description": "The ID of the group to update"
},
"name": {
"name": "Name",
"description": "New display name"
},
"policy": {
"name": "Policy",
"description": "New policy (sticky or spread)"
},
"chore_ids": {
"name": "Chore IDs",
"description": "Replacement chore ID list. For sticky, the first chore is the leader."
}
}
},
"remove_task_group": {
"name": "Remove Task Group",
"description": "Delete a task group. Member chores are not affected.",
"fields": {
"group_id": {
"name": "Group ID",
"description": "The ID of the group to delete"
}
}
},
"start_timed_task": {
"name": "Iniciar tarefa cronometrada",
"description": "Iniciar ou retomar o cronômetro de uma tarefa cronometrada",
"fields": {
"chore_id": {
"name": "ID da tarefa",
"description": "O ID da tarefa cronometrada"
},
"child_id": {
"name": "ID da criança",
"description": "O ID da criança que inicia o cronômetro"
}
}
},
"pause_timed_task": {
"name": "Pausar tarefa cronometrada",
"description": "Pausar o cronômetro de uma tarefa cronometrada em andamento",
"fields": {
"chore_id": {
"name": "ID da tarefa",
"description": "O ID da tarefa cronometrada"
},
"child_id": {
"name": "ID da criança",
"description": "O ID da criança que pausa o cronômetro"
}
}
},
"stop_timed_task": {
"name": "Parar tarefa cronometrada",
"description": "Parar o cronômetro de uma tarefa cronometrada e enviar para aprovação",
"fields": {
"chore_id": {
"name": "ID da tarefa",
"description": "O ID da tarefa cronometrada"
},
"child_id": {
"name": "ID da criança",
"description": "O ID da criança que para o cronômetro"
}
}
},
"add_badge": {
"name": "Adicionar conquista",
"description": "Criar uma conquista personalizada.",
"fields": {
"name": {
"name": "Nome",
"description": "O nome de exibição da conquista"
},
"description": {
"name": "Descrição",
"description": "Descrição de como a conquista é ganha"
},
"icon": {
"name": "Ícone",
"description": "Ícone da conquista (ex.: mdi:beach)"
},
"tier": {
"name": "Nível",
"description": "Nível da conquista: bronze, prata, ouro ou platina"
},
"point_bonus": {
"name": "Bônus de pontos",
"description": "Pontos creditados à criança quando a conquista é ganha"
},
"criteria": {
"name": "Critérios",
"description": "Lista de dicionários metric / operator / value. Vazio = apenas atribuição manual."
},
"assigned_to": {
"name": "Atribuída a",
"description": "Lista de IDs de crianças (vazio = todas)"
},
"notify_on_earn": {
"name": "Notificar ao ganhar",
"description": "Enviar uma notificação quando a conquista é ganha"
}
}
},
"update_badge": {
"name": "Atualizar conquista",
"description": "Atualizar uma conquista existente. Para as integradas, apenas point_bonus, tier, assigned_to, enabled e notify_on_earn são editáveis.",
"fields": {
"badge_id": {
"name": "ID da conquista",
"description": "O ID da conquista a atualizar"
},
"name": {
"name": "Nome",
"description": "Novo nome de exibição da conquista"
},
"description": {
"name": "Descrição",
"description": "Nova descrição da conquista"
},
"icon": {
"name": "Ícone",
"description": "Novo ícone da conquista"
},
"tier": {
"name": "Nível",
"description": "Nível da conquista: bronze, prata, ouro ou platina"
},
"point_bonus": {
"name": "Bônus de pontos",
"description": "Pontos creditados à criança quando a conquista é ganha"
},
"criteria": {
"name": "Critérios",
"description": "Lista de dicionários metric / operator / value. Vazio = apenas atribuição manual."
},
"assigned_to": {
"name": "Atribuída a",
"description": "Lista de IDs de crianças (vazio = todas)"
},
"enabled": {
"name": "Ativada",
"description": "Se a conquista está ativa"
},
"notify_on_earn": {
"name": "Notificar ao ganhar",
"description": "Enviar uma notificação quando a conquista é ganha"
}
}
},
"remove_badge": {
"name": "Remover conquista",
"description": "Remover uma conquista personalizada. As integradas não podem ser removidas.",
"fields": {
"badge_id": {
"name": "ID da conquista",
"description": "O ID da conquista a remover"
}
}
},
"award_badge_manually": {
"name": "Atribuir conquista manualmente",
"description": "Atribuir manualmente uma conquista a uma criança.",
"fields": {
"badge_id": {
"name": "ID da conquista",
"description": "O ID da conquista a atribuir"
},
"child_id": {
"name": "ID da criança",
"description": "O ID da criança que recebe a conquista"
}
}
},
"revoke_badge": {
"name": "Revogar conquista",
"description": "Revogar uma conquista atribuída. Se um bônus de pontos foi creditado, ele é revertido.",
"fields": {
"awarded_badge_id": {
"name": "ID da conquista atribuída",
"description": "O ID da conquista atribuída a revogar"
}
}
},
"rebuild_badges": {
"name": "Reconstruir conquistas",
"description": "Reavaliar todas as conquistas de todas as crianças silenciosamente (sem notificações nem bônus de pontos)."
},
"apply_mandatory_penalty": {
"name": "Aplicar penalidade obrigatória",
"description": "Deduz os pontos de penalidade configurados por uma tarefa obrigatória perdida e remove o item de revisão.",
"fields": {
"miss_id": {
"name": "ID da falha",
"description": "O ID do item de revisão da tarefa obrigatória perdida"
}
}
},
"choose_avatar": {
"name": "Escolher avatar",
"description": "Uma criança muda para um avatar que desbloqueou.",
"fields": {
"child_id": {
"name": "ID da criança",
"description": "O ID da criança"
},
"icon": {
"name": "Ícone do avatar",
"description": "O ícone mdi de um avatar do catálogo desbloqueado (ex.: mdi:rocket-launch)."
}
}
},
"dismiss_mandatory_chore": {
"name": "Dispensar tarefa obrigatória",
"description": "Remove o item de revisão de uma tarefa obrigatória perdida sem penalidade.",
"fields": {
"miss_id": {
"name": "ID da falha",
"description": "O ID do item de revisão da tarefa obrigatória perdida"
}
}
},
"gift_points": {
"name": "Presentear pontos",
"description": "Transfere pontos disponíveis de uma criança para outra (controlado pelos pais).",
"fields": {
"from_child_id": {
"name": "ID da criança de origem",
"description": "O ID da criança que envia os pontos"
},
"to_child_id": {
"name": "ID da criança de destino",
"description": "O ID da criança que recebe os pontos"
},
"points": {
"name": "Pontos",
"description": "O número de pontos a transferir"
}
}
},
"postpone_mandatory_chore": {
"name": "Adiar tarefa obrigatória",
"description": "Dá a uma tarefa obrigatória perdida outro período (ou amanhã) sem penalidade e remove o item de revisão.",
"fields": {
"miss_id": {
"name": "ID da falha",
"description": "O ID do item de revisão da tarefa obrigatória perdida"
}
}
},
"request_swap": {
"name": "Solicitar troca de tarefa",
"description": "Uma criança pede para assumir a atribuição de rodízio de hoje de uma tarefa (sujeito a aprovação dos pais).",
"fields": {
"chore_id": {
"name": "ID da tarefa",
"description": "O ID da tarefa a assumir"
},
"requester_id": {
"name": "ID da criança solicitante",
"description": "O ID da criança que pede a troca"
}
}
},
"test_notification": {
"name": "Notificação de teste",
"description": "Envia uma notificação de exemplo do tipo indicado aos destinatários ativados (ignora o interruptor principal para que o roteamento possa ser verificado).",
"fields": {
"type_id": {
"name": "Tipo de notificação",
"description": "ID do tipo de notificação, ex.: pending_chore_approval, badge_earned, streak_milestone"
}
}
},
"record_allowance_payout": {
"name": "Registrar pagamento de mesada",
"description": "Deduzir pontos e registrar um pagamento de mesada real no histórico (taxa de conversão fixa; controlado pelos pais).",
"fields": {
"child_id": {
"name": "ID da criança",
"description": "O ID da criança que recebe o pagamento"
},
"points": {
"name": "Pontos",
"description": "Pontos a converter em dinheiro e deduzir"
}
}
}
},
"entity": {
"sensor": {
"child_stats": {
"unit_of_measurement": "tarefas"
},
"child_badges": {
"unit_of_measurement": "medalhas"
}
},
"number": {
"weekend_multiplier": {
"name": "Multiplicador de fim de semana"
},
"perfect_week_bonus": {
"name": "Bônus de semana perfeita"
}
},
"select": {
"streak_reset_mode": {
"name": "Modo de redefinição de sequência"
},
"card_design": {
"name": "Design do cartão"
}
}
}
}
@@ -0,0 +1,884 @@
{
"config": {
"step": {
"user": {
"title": "Bem-vindo ao TaskMate!",
"description": "Configure o seu sistema de gestão de tarefas familiar. Pode personalizar a moeda de pontos que as crianças vão ganhar.",
"data": {
"points_name": "Nome da moeda de pontos (ex: Estrelas, Moedas, Pontos)",
"points_icon": "Ícone dos pontos"
}
}
},
"error": {
"name_required": "O nome é obrigatório"
},
"abort": {
"already_configured": "O TaskMate já está configurado"
}
},
"selector": {
"chore_action": {
"options": {
"save": "Guardar alterações",
"re_enable": "Reativar tarefa",
"disable": "Desativar tarefa",
"delete": "Eliminar esta tarefa"
}
},
"reward_action": {
"options": {
"save": "Guardar alterações",
"delete": "Eliminar recompensa"
}
},
"time_category": {
"options": {
"morning": "Manhã",
"afternoon": "Tarde",
"evening": "Fim da tarde",
"night": "Noite",
"anytime": "Qualquer hora"
}
},
"schedule_mode": {
"options": {
"specific_days": "Dias específicos da semana",
"recurring": "Recorrente (a cada N dias/semanas/meses)",
"one_shot": "Tarefa única (um dia)"
}
},
"due_days_option": {
"options": {
"monday": "Segunda-feira",
"tuesday": "Terça-feira",
"wednesday": "Quarta-feira",
"thursday": "Quinta-feira",
"friday": "Sexta-feira",
"saturday": "Sábado",
"sunday": "Domingo"
}
},
"recurrence": {
"options": {
"every_2_days": "A cada 2 dias",
"weekly": "Semanal",
"every_2_weeks": "A cada 2 semanas",
"monthly": "Mensal",
"every_3_months": "A cada 3 meses",
"every_6_months": "A cada 6 meses"
}
},
"recurrence_day_option": {
"options": {
"monday": "Segunda-feira",
"tuesday": "Terça-feira",
"wednesday": "Quarta-feira",
"thursday": "Quinta-feira",
"friday": "Sexta-feira",
"saturday": "Sábado",
"sunday": "Domingo",
"any_day": "Qualquer dia"
}
},
"first_occurrence_mode": {
"options": {
"available_immediately": "Disponível imediatamente",
"wait_for_first_occurrence": "Aguardar primeira ocorrência agendada"
}
},
"streak_reset_mode": {
"options": {
"reset": "Reiniciar — sequência volta a 0 num dia falhado",
"pause": "Pausar — sequência preservada até próxima conclusão"
}
},
"visibility_operator": {
"options": {
"none": "Sem filtro — Mostrar sempre a tarefa",
"equals": "Igual — Mostrar quando o estado da entidade corresponde exatamente",
"not_equals": "Não igual — Mostrar quando o estado da entidade não corresponde",
"gte": "Maior ou igual (≥) — Mostrar quando valor numérico é ≥ alvo",
"lte": "Menor ou igual (≤) — Mostrar quando valor numérico é ≤ alvo",
"gt": "Maior que (>) — Mostrar quando valor numérico é > alvo",
"lt": "Menor que (<) — Mostrar quando valor numérico é < alvo"
}
},
"assignment_mode": {
"options": {
"everyone": "Todos — todas as crianças atribuídas veem a tarefa",
"alternating": "Alternado — alterna entre as crianças atribuídas, uma por dia",
"random": "Aleatório — escolhe aleatoriamente uma criança atribuída por dia",
"balanced": "Equilibrado — divide as tarefas equilibradas do dia igualmente entre as crianças atribuídas",
"first_come": "Quem chega primeiro — a primeira criança a concluir ganha; desaparece para as restantes",
"unassigned": "Não atribuído — nenhuma criança vê esta tarefa"
}
},
"task_group_policy": {
"options": {
"sticky": "Sticky — mesma criança para todas as tarefas do grupo",
"spread": "Spread — crianças diferentes no grupo hoje"
}
},
"task_group_action": {
"options": {
"save": "Guardar alterações",
"delete": "Eliminar grupo"
}
}
},
"services": {
"complete_chore": {
"name": "Concluir tarefa",
"description": "Marcar uma tarefa como concluída por uma criança",
"fields": {
"chore_id": {
"name": "ID da tarefa",
"description": "O ID da tarefa a concluir"
},
"child_id": {
"name": "ID da criança",
"description": "O ID da criança que conclui a tarefa"
}
}
},
"complete_bonus_subtask": {
"name": "Concluir subtarefa bónus",
"description": "Concluir uma subtarefa bónus depois de a tarefa principal estar concluída",
"fields": {
"chore_id": {
"name": "ID da tarefa",
"description": "O ID da tarefa principal"
},
"bonus_subtask_id": {
"name": "ID da subtarefa bónus",
"description": "O ID da subtarefa bónus a concluir"
},
"child_id": {
"name": "ID da criança",
"description": "O ID da criança que conclui a subtarefa bónus"
}
}
},
"approve_chore": {
"name": "Aprovar tarefa",
"description": "Aprovar uma tarefa concluída e atribuir pontos",
"fields": {
"completion_id": {
"name": "ID de conclusão",
"description": "O ID da conclusão da tarefa a aprovar"
}
}
},
"approve_all_chores": {
"name": "Aprovar todas as tarefas",
"description": "Aprovar todas as conclusões de tarefas pendentes (ou um conjunto específico) e atribuir pontos",
"fields": {
"completion_ids": {
"name": "IDs de conclusão",
"description": "IDs de conclusão específicos para aprovar; omita para aprovar todas as pendentes"
}
}
},
"reject_chore": {
"name": "Rejeitar tarefa",
"description": "Rejeitar uma tarefa concluída",
"fields": {
"completion_id": {
"name": "ID de conclusão",
"description": "O ID da conclusão da tarefa a rejeitar"
}
}
},
"undo_chore_approval": {
"name": "Anular aprovação de tarefa",
"description": "Anular a aprovação acidental de uma tarefa — remove os pontos atribuídos e devolve a conclusão a pendente para que possa ser aprovada novamente.",
"fields": {
"completion_id": {
"name": "ID de conclusão",
"description": "O ID da conclusão de tarefa aprovada a repor como pendente"
}
}
},
"undo_transaction": {
"name": "Anular transação",
"description": "Anular uma transação de pontos pelo seu ID — penalizações, bónus, ajustes manuais (adicionar/remover) e presentes (ambas as partes). As transações automáticas (bónus de fim de semana/sequência/semana perfeita, movimentos do mealheiro, desvalorização, juros) não podem ser anuladas.",
"fields": {
"transaction_id": {
"name": "ID da transação",
"description": "O ID da transação de pontos a reverter"
}
}
},
"claim_reward": {
"name": "Reclamar recompensa",
"description": "A criança reclama uma recompensa usando os seus pontos",
"fields": {
"reward_id": {
"name": "ID da recompensa",
"description": "O ID da recompensa a reclamar"
},
"child_id": {
"name": "ID da criança",
"description": "O ID da criança que reclama a recompensa"
}
}
},
"approve_reward": {
"name": "Aprovar recompensa",
"description": "Aprovar um pedido de recompensa",
"fields": {
"claim_id": {
"name": "ID do pedido",
"description": "O ID do pedido de recompensa a aprovar"
}
}
},
"allocate_points_to_pool": {
"name": "Alocar pontos ao mealheiro",
"description": "Mover pontos do saldo disponível de uma criança para o mealheiro de uma recompensa (bloqueado).",
"fields": {
"child_id": {
"name": "ID da criança",
"description": "O ID da criança que faz a alocação"
},
"reward_id": {
"name": "ID da recompensa",
"description": "O ID do mealheiro da recompensa onde depositar"
},
"points": {
"name": "Pontos",
"description": "Número de pontos a alocar (limitado pela capacidade do mealheiro e pelo saldo disponível)"
}
}
},
"add_points": {
"name": "Adicionar pontos",
"description": "Adicionar pontos de bónus a uma criança",
"fields": {
"child_id": {
"name": "ID da criança",
"description": "O ID da criança a quem adicionar pontos"
},
"points": {
"name": "Pontos",
"description": "O número de pontos a adicionar"
},
"reason": {
"name": "Motivo",
"description": "Motivo opcional para o bónus"
}
}
},
"remove_points": {
"name": "Remover pontos",
"description": "Remover pontos de uma criança (penalização)",
"fields": {
"child_id": {
"name": "ID da criança",
"description": "O ID da criança a quem remover pontos"
},
"points": {
"name": "Pontos",
"description": "O número de pontos a remover"
},
"reason": {
"name": "Motivo",
"description": "Motivo opcional para a penalização"
}
}
},
"reject_reward": {
"name": "Rejeitar recompensa",
"description": "Rejeitar um pedido de recompensa",
"fields": {
"claim_id": {
"name": "ID do pedido",
"description": "O ID do pedido de recompensa a rejeitar"
}
}
},
"preview_sound": {
"name": "Pré-visualizar som",
"description": "Pré-visualizar um efeito sonoro de conclusão no navegador",
"fields": {
"sound": {
"name": "Som",
"description": "O som a pré-visualizar"
}
}
},
"set_chore_order": {
"name": "Definir ordem das tarefas",
"description": "Definir a ordem de apresentação das tarefas para uma criança",
"fields": {
"child_id": {
"name": "ID da criança",
"description": "O ID da criança para definir a ordem das tarefas"
},
"chore_order": {
"name": "Ordem das tarefas",
"description": "Lista ordenada de IDs de tarefas"
}
}
},
"add_penalty": {
"name": "Adicionar penalização",
"description": "Criar uma nova definição de penalização",
"fields": {
"name": {
"name": "Nome",
"description": "O nome da penalização"
},
"points": {
"name": "Pontos",
"description": "O número de pontos a deduzir quando aplicada"
},
"description": {
"name": "Descrição",
"description": "Descrição opcional da penalização"
},
"icon": {
"name": "Ícone",
"description": "Ícone MDI para a penalização"
},
"assigned_to": {
"name": "Atribuída a",
"description": "Lista de IDs de crianças a quem esta penalização se aplica"
}
}
},
"update_penalty": {
"name": "Atualizar penalização",
"description": "Atualizar uma definição de penalização existente",
"fields": {
"penalty_id": {
"name": "ID da penalização",
"description": "O ID da penalização a atualizar"
},
"name": {
"name": "Nome",
"description": "Novo nome para a penalização"
},
"points": {
"name": "Pontos",
"description": "Novo valor de pontos"
},
"description": {
"name": "Descrição",
"description": "Nova descrição"
},
"icon": {
"name": "Ícone",
"description": "Novo ícone"
},
"assigned_to": {
"name": "Atribuída a",
"description": "Nova lista de IDs de crianças"
}
}
},
"remove_penalty": {
"name": "Remover penalização",
"description": "Remover uma definição de penalização",
"fields": {
"penalty_id": {
"name": "ID da penalização",
"description": "O ID da penalização a remover"
}
}
},
"apply_penalty": {
"name": "Aplicar penalização",
"description": "Aplicar uma penalização a uma criança, deduzindo pontos",
"fields": {
"penalty_id": {
"name": "ID da penalização",
"description": "O ID da penalização a aplicar"
},
"child_id": {
"name": "ID da criança",
"description": "O ID da criança a quem aplicar a penalização"
}
}
},
"add_bonus": {
"name": "Adicionar bónus",
"description": "Criar uma nova definição de bónus",
"fields": {
"name": {
"name": "Nome",
"description": "O nome do bónus"
},
"points": {
"name": "Pontos",
"description": "O número de pontos a atribuir quando aplicado"
},
"description": {
"name": "Descrição",
"description": "Descrição opcional do bónus"
},
"icon": {
"name": "Ícone",
"description": "Ícone MDI para o bónus"
},
"assigned_to": {
"name": "Atribuído a",
"description": "Lista de IDs de crianças a que este bónus se aplica"
}
}
},
"update_bonus": {
"name": "Atualizar bónus",
"description": "Atualizar uma definição de bónus existente",
"fields": {
"bonus_id": {
"name": "ID do bónus",
"description": "O ID do bónus a atualizar"
},
"name": {
"name": "Nome",
"description": "Novo nome para o bónus"
},
"points": {
"name": "Pontos",
"description": "Novo valor de pontos"
},
"description": {
"name": "Descrição",
"description": "Nova descrição"
},
"icon": {
"name": "Ícone",
"description": "Novo ícone"
},
"assigned_to": {
"name": "Atribuído a",
"description": "Nova lista de IDs de crianças"
}
}
},
"remove_bonus": {
"name": "Remover bónus",
"description": "Remover uma definição de bónus",
"fields": {
"bonus_id": {
"name": "ID do bónus",
"description": "O ID do bónus a remover"
}
}
},
"apply_bonus": {
"name": "Aplicar bónus",
"description": "Aplicar um bónus a uma criança, atribuindo pontos",
"fields": {
"bonus_id": {
"name": "ID do bónus",
"description": "O ID do bónus a aplicar"
},
"child_id": {
"name": "ID da criança",
"description": "O ID da criança a quem aplicar o bónus"
}
}
},
"add_chore": {
"name": "Adicionar tarefa",
"description": "Criar uma nova tarefa dinamicamente, normalmente a partir de uma automação ou script",
"fields": {
"name": {
"name": "Nome",
"description": "O nome da tarefa"
},
"description": {
"name": "Descrição",
"description": "Descrição opcional da tarefa"
},
"points": {
"name": "Pontos",
"description": "Pontos atribuídos quando esta tarefa é concluída"
},
"assigned_to": {
"name": "Atribuída a",
"description": "Lista de IDs de crianças a quem atribuir esta tarefa. Deixe vazio para atribuir a todas as crianças"
},
"time_category": {
"name": "Categoria de tempo",
"description": "Quando esta tarefa deve ser concluída: morning, afternoon, evening, night ou anytime"
},
"one_shot": {
"name": "Única",
"description": "Se verdadeiro, é uma tarefa única que expira após a conclusão ou no final do dia"
},
"requires_approval": {
"name": "Requer aprovação",
"description": "Se verdadeiro, um pai/mãe deve aprovar a conclusão antes de os pontos serem atribuídos"
}
}
},
"skip_chore": {
"name": "Skip Chore",
"description": "Advance today's rotation past the current assignee. Ephemeral — tomorrow's rotation resumes as normal.",
"fields": {
"chore_id": {
"name": "Chore ID",
"description": "The ID of the chore to skip"
}
}
},
"set_chore_manual_start": {
"name": "Set Manual Start Child",
"description": "Set which child starts a rotating chore. For alternating mode this reorders the pool; for random/balanced it only overrides today's assignee.",
"fields": {
"chore_id": {
"name": "Chore ID",
"description": "The ID of the chore to update"
},
"child_id": {
"name": "Child ID",
"description": "The ID of the child who should start the rotation"
}
}
},
"add_task_group": {
"name": "Add Task Group",
"description": "Create a task group (sticky = same child, spread = different children).",
"fields": {
"name": {
"name": "Name",
"description": "Display name for the group"
},
"policy": {
"name": "Policy",
"description": "sticky (same child) or spread (different children)"
},
"chore_ids": {
"name": "Chore IDs",
"description": "List of chore IDs in the group. For sticky, the first chore is the leader."
}
}
},
"update_task_group": {
"name": "Update Task Group",
"description": "Update a task group's name, policy, or membership.",
"fields": {
"group_id": {
"name": "Group ID",
"description": "The ID of the group to update"
},
"name": {
"name": "Name",
"description": "New display name"
},
"policy": {
"name": "Policy",
"description": "New policy (sticky or spread)"
},
"chore_ids": {
"name": "Chore IDs",
"description": "Replacement chore ID list. For sticky, the first chore is the leader."
}
}
},
"remove_task_group": {
"name": "Remove Task Group",
"description": "Delete a task group. Member chores are not affected.",
"fields": {
"group_id": {
"name": "Group ID",
"description": "The ID of the group to delete"
}
}
},
"start_timed_task": {
"name": "Iniciar tarefa temporizada",
"description": "Iniciar ou retomar o temporizador de uma tarefa temporizada",
"fields": {
"chore_id": {
"name": "ID da tarefa",
"description": "O ID da tarefa temporizada"
},
"child_id": {
"name": "ID da criança",
"description": "O ID da criança que inicia o temporizador"
}
}
},
"pause_timed_task": {
"name": "Pausar tarefa temporizada",
"description": "Pausar o temporizador de uma tarefa temporizada em curso",
"fields": {
"chore_id": {
"name": "ID da tarefa",
"description": "O ID da tarefa temporizada"
},
"child_id": {
"name": "ID da criança",
"description": "O ID da criança que pausa o temporizador"
}
}
},
"stop_timed_task": {
"name": "Parar tarefa temporizada",
"description": "Parar o temporizador de uma tarefa temporizada e submeter para aprovação",
"fields": {
"chore_id": {
"name": "ID da tarefa",
"description": "O ID da tarefa temporizada"
},
"child_id": {
"name": "ID da criança",
"description": "O ID da criança que para o temporizador"
}
}
},
"add_badge": {
"name": "Adicionar conquista",
"description": "Criar uma conquista personalizada.",
"fields": {
"name": {
"name": "Nome",
"description": "O nome de apresentação da conquista"
},
"description": {
"name": "Descrição",
"description": "Descrição de como a conquista é ganha"
},
"icon": {
"name": "Ícone",
"description": "Ícone da conquista (ex.: mdi:beach)"
},
"tier": {
"name": "Nível",
"description": "Nível da conquista: bronze, prata, ouro ou platina"
},
"point_bonus": {
"name": "Bónus de pontos",
"description": "Pontos creditados à criança quando a conquista é ganha"
},
"criteria": {
"name": "Critérios",
"description": "Lista de dicionários metric / operator / value. Vazio = apenas atribuição manual."
},
"assigned_to": {
"name": "Atribuída a",
"description": "Lista de IDs de crianças (vazio = todas)"
},
"notify_on_earn": {
"name": "Notificar ao ganhar",
"description": "Enviar uma notificação quando a conquista é ganha"
}
}
},
"update_badge": {
"name": "Atualizar conquista",
"description": "Atualizar uma conquista existente. Para as integradas, apenas point_bonus, tier, assigned_to, enabled e notify_on_earn são editáveis.",
"fields": {
"badge_id": {
"name": "ID da conquista",
"description": "O ID da conquista a atualizar"
},
"name": {
"name": "Nome",
"description": "Novo nome de apresentação da conquista"
},
"description": {
"name": "Descrição",
"description": "Nova descrição da conquista"
},
"icon": {
"name": "Ícone",
"description": "Novo ícone da conquista"
},
"tier": {
"name": "Nível",
"description": "Nível da conquista: bronze, prata, ouro ou platina"
},
"point_bonus": {
"name": "Bónus de pontos",
"description": "Pontos creditados à criança quando a conquista é ganha"
},
"criteria": {
"name": "Critérios",
"description": "Lista de dicionários metric / operator / value. Vazio = apenas atribuição manual."
},
"assigned_to": {
"name": "Atribuída a",
"description": "Lista de IDs de crianças (vazio = todas)"
},
"enabled": {
"name": "Ativada",
"description": "Se a conquista está ativa"
},
"notify_on_earn": {
"name": "Notificar ao ganhar",
"description": "Enviar uma notificação quando a conquista é ganha"
}
}
},
"remove_badge": {
"name": "Remover conquista",
"description": "Remover uma conquista personalizada. As integradas não podem ser removidas.",
"fields": {
"badge_id": {
"name": "ID da conquista",
"description": "O ID da conquista a remover"
}
}
},
"award_badge_manually": {
"name": "Atribuir conquista manualmente",
"description": "Atribuir manualmente uma conquista a uma criança.",
"fields": {
"badge_id": {
"name": "ID da conquista",
"description": "O ID da conquista a atribuir"
},
"child_id": {
"name": "ID da criança",
"description": "O ID da criança que recebe a conquista"
}
}
},
"revoke_badge": {
"name": "Revogar conquista",
"description": "Revogar uma conquista atribuída. Se um bónus de pontos foi creditado, é revertido.",
"fields": {
"awarded_badge_id": {
"name": "ID da conquista atribuída",
"description": "O ID da conquista atribuída a revogar"
}
}
},
"rebuild_badges": {
"name": "Reconstruir conquistas",
"description": "Reavaliar todas as conquistas de todas as crianças silenciosamente (sem notificações nem bónus de pontos)."
},
"apply_mandatory_penalty": {
"name": "Aplicar penalização obrigatória",
"description": "Deduz os pontos de penalização configurados por uma tarefa obrigatória falhada e remove o item de revisão.",
"fields": {
"miss_id": {
"name": "ID da falha",
"description": "O ID do item de revisão da tarefa obrigatória falhada"
}
}
},
"choose_avatar": {
"name": "Escolher avatar",
"description": "Uma criança muda para um avatar que desbloqueou.",
"fields": {
"child_id": {
"name": "ID da criança",
"description": "O ID da criança"
},
"icon": {
"name": "Ícone do avatar",
"description": "O ícone mdi de um avatar do catálogo desbloqueado (ex.: mdi:rocket-launch)."
}
}
},
"dismiss_mandatory_chore": {
"name": "Dispensar tarefa obrigatória",
"description": "Remove o item de revisão de uma tarefa obrigatória falhada sem penalização.",
"fields": {
"miss_id": {
"name": "ID da falha",
"description": "O ID do item de revisão da tarefa obrigatória falhada"
}
}
},
"gift_points": {
"name": "Oferecer pontos",
"description": "Transfere pontos disponíveis de uma criança para outra (controlado pelos pais).",
"fields": {
"from_child_id": {
"name": "ID da criança de origem",
"description": "O ID da criança que envia os pontos"
},
"to_child_id": {
"name": "ID da criança de destino",
"description": "O ID da criança que recebe os pontos"
},
"points": {
"name": "Pontos",
"description": "O número de pontos a transferir"
}
}
},
"postpone_mandatory_chore": {
"name": "Adiar tarefa obrigatória",
"description": "Dá a uma tarefa obrigatória falhada outro período (ou amanhã) sem penalização e remove o item de revisão.",
"fields": {
"miss_id": {
"name": "ID da falha",
"description": "O ID do item de revisão da tarefa obrigatória falhada"
}
}
},
"request_swap": {
"name": "Pedir troca de tarefa",
"description": "Uma criança pede para assumir a atribuição de rotação de hoje de uma tarefa (sujeito a aprovação dos pais).",
"fields": {
"chore_id": {
"name": "ID da tarefa",
"description": "O ID da tarefa a assumir"
},
"requester_id": {
"name": "ID da criança solicitante",
"description": "O ID da criança que pede a troca"
}
}
},
"test_notification": {
"name": "Notificação de teste",
"description": "Envia uma notificação de exemplo do tipo indicado aos destinatários ativados (ignora o interruptor principal para que o encaminhamento possa ser verificado).",
"fields": {
"type_id": {
"name": "Tipo de notificação",
"description": "ID do tipo de notificação, p. ex. pending_chore_approval, badge_earned, streak_milestone"
}
}
},
"record_allowance_payout": {
"name": "Registar pagamento de mesada",
"description": "Deduzir pontos e registar um pagamento de mesada real no histórico (taxa de conversão fixa; controlado pelos pais).",
"fields": {
"child_id": {
"name": "ID da criança",
"description": "O ID da criança que recebe o pagamento"
},
"points": {
"name": "Pontos",
"description": "Pontos a converter em dinheiro e a deduzir"
}
}
}
},
"entity": {
"sensor": {
"child_stats": {
"unit_of_measurement": "tarefas"
},
"child_badges": {
"unit_of_measurement": "medalhas"
}
},
"number": {
"weekend_multiplier": {
"name": "Multiplicador de fim de semana"
},
"perfect_week_bonus": {
"name": "Bónus de semana perfeita"
}
},
"select": {
"streak_reset_mode": {
"name": "Modo de reposição de sequência"
},
"card_design": {
"name": "Design do cartão"
}
}
}
}
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More