185 files

This commit is contained in:
Home Assistant Version Control
2026-08-19 11:38:13 +00:00
parent 5f9330abd7
commit a718af6423
185 changed files with 16868 additions and 3088 deletions
+5 -24
View File
@@ -61,6 +61,8 @@ from .const import (
ATTR_REASON,
ATTR_REWARD_ID,
ATTR_SOUND,
BADGE_TIERS,
COMPLETION_SOUND_OPTIONS,
CONF_TASK_GROUP_CHORE_IDS,
CONF_TASK_GROUP_ID,
CONF_TASK_GROUP_NAME,
@@ -1363,28 +1365,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
_safe(handle_preview_sound),
schema=vol.Schema(
{
vol.Required(ATTR_SOUND): vol.In(
[
"none",
"coin",
"levelup",
"fanfare",
"chime",
"powerup",
"undo",
"fart1",
"fart2",
"fart3",
"fart4",
"fart5",
"fart6",
"fart7",
"fart8",
"fart9",
"fart10",
"fart_random",
]
),
vol.Required(ATTR_SOUND): vol.In(COMPLETION_SOUND_OPTIONS),
}
),
)
@@ -1583,7 +1564,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
vol.Required(ATTR_BADGE_NAME): cv.string,
vol.Optional(ATTR_BADGE_DESCRIPTION, default=""): cv.string,
vol.Optional(ATTR_BADGE_ICON, default="mdi:trophy"): cv.string,
vol.Optional(ATTR_BADGE_TIER, default="bronze"): vol.In(["bronze", "silver", "gold", "platinum"]),
vol.Optional(ATTR_BADGE_TIER, default="bronze"): vol.In(BADGE_TIERS),
vol.Optional(ATTR_BADGE_POINT_BONUS, default=0): vol.Coerce(int),
vol.Optional(ATTR_BADGE_CRITERIA, default=[]): list,
vol.Optional(ATTR_BADGE_COMBINATOR, default="AND"): cv.string,
@@ -1603,7 +1584,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
vol.Optional(ATTR_BADGE_NAME): cv.string,
vol.Optional(ATTR_BADGE_DESCRIPTION): cv.string,
vol.Optional(ATTR_BADGE_ICON): cv.string,
vol.Optional(ATTR_BADGE_TIER): vol.In(["bronze", "silver", "gold", "platinum"]),
vol.Optional(ATTR_BADGE_TIER): vol.In(BADGE_TIERS),
vol.Optional(ATTR_BADGE_POINT_BONUS): vol.Coerce(int),
vol.Optional(ATTR_BADGE_CRITERIA): list,
vol.Optional(ATTR_BADGE_COMBINATOR): cv.string,
+2 -6
View File
@@ -11,6 +11,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import TaskMateCoordinator
from .entity import taskmate_device_info
async def async_setup_entry(
@@ -43,12 +44,7 @@ class TaskMateBaseBinarySensor(CoordinatorEntity, BinarySensorEntity):
@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",
)
return taskmate_device_info(self._entry.entry_id)
class HasPendingApprovalsBinarySensor(TaskMateBaseBinarySensor):
+3 -8
View File
@@ -13,6 +13,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import TaskMateCoordinator
from .entity import taskmate_device_info
from .models import Child, Chore, Reward
_LOGGER = logging.getLogger(__name__)
@@ -42,8 +43,7 @@ async def async_setup_entry(
entities.append(CompleteChoreButton(coordinator, entry, child, chore))
# Reward claim buttons
for reward in rewards:
entities.append(ClaimRewardButton(coordinator, entry, child, reward))
entities.extend(ClaimRewardButton(coordinator, entry, child, reward) for reward in rewards)
# Track which entity combos already exist
tracked_combos: set[str] = set()
@@ -103,12 +103,7 @@ class TaskMateBaseButton(CoordinatorEntity, ButtonEntity):
@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",
)
return taskmate_device_info(self._entry.entry_id)
class CompleteChoreButton(TaskMateBaseButton):
+2 -6
View File
@@ -31,6 +31,7 @@ from homeassistant.util import dt as dt_util
from .const import DOMAIN
from .coordinator import TaskMateCoordinator
from .entity import taskmate_device_info
from .models import Child, Chore
_LOGGER = logging.getLogger(__name__)
@@ -127,12 +128,7 @@ class TaskMateCalendar(CoordinatorEntity, CalendarEntity):
@property
def device_info(self) -> DeviceInfo:
return DeviceInfo(
identifiers={(DOMAIN, self._entry.entry_id)},
name="TaskMate",
manufacturer="TaskMate",
model="Family Chore Manager",
)
return taskmate_device_info(self._entry.entry_id)
@property
def _child(self) -> Child | None:
+14 -183
View File
@@ -4,49 +4,15 @@ 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
@@ -59,56 +25,28 @@ MAX_CALENDAR_PROJECTION_DAYS: Final = 90
# "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"]
# Nominal length of each Mode-B recurrence period, in days. Used for report
# expectations and recurrence math (not for the shorter availability window
# in coord_chores.is_chore_available_for_child, which is deliberately capped).
RECURRENCE_PERIOD_DAYS: Final = {
"every_2_days": 2,
"weekly": 7,
"every_2_weeks": 14,
"monthly": 30,
"every_3_months": 91,
"every_6_months": 182,
}
# Badge tiers, lowest to highest.
BADGE_TIERS: Final = ["bronze", "silver", "gold", "platinum"]
# Time categories for chores
TIME_CATEGORIES: Final = [
@@ -140,101 +78,6 @@ DEFAULT_TIME_PERIODS: Final = [
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"]
@@ -331,12 +174,6 @@ 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
@@ -361,12 +198,6 @@ COMPLETION_SOUND_OPTIONS: Final = [
"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
+60 -43
View File
@@ -389,6 +389,15 @@ class ChoresMixin:
approved. If None/empty, every currently-unapproved completion is approved.
Reuses async_approve_chore so all award/badge/quest/celebration side-effects
match single approval exactly.
Each approval is told not to refresh (#794). ``async_approve_chore``
normally ends in a full coordinator rebuild plus a state write for every
TaskMate entity, which at ~0.13s a time made a 60-chore Approve All block
the websocket call for ~8s with nothing pushed to the frontend meanwhile —
the panel and the approvals card sit unchanged for that whole window, so
the button reads as broken. The batch refreshes once at the end instead.
Safe because the per-approval side-effects (badges, quests, challenges,
the all-done check) all read `storage` directly, never `self.data`.
"""
pending = {c.id for c in self.storage.get_completions() if not c.approved}
if completion_ids:
@@ -398,8 +407,10 @@ class ChoresMixin:
targets = [c.id for c in self.storage.get_completions() if not c.approved]
count = 0
for cid in targets:
await self.async_approve_chore(cid)
await self.async_approve_chore(cid, refresh=False)
count += 1
if count:
await self.async_refresh()
return count
async def async_add_chores_bulk(
@@ -716,32 +727,37 @@ class ChoresMixin:
# assignee. Parents (as_parent) may complete on behalf of any pool
# member — e.g. ticking it off for the off-rotation child. first_come
# keeps its competitive semantics (every pool member may race).
if not as_parent and assignment_mode != "first_come":
if child_id not in self._compute_active_children(chore):
_LOGGER.debug(
"complete_chore no-op: '%s' not assigned to %s today",
chore.name,
child.name,
)
return None
if (
not as_parent
and assignment_mode != "first_come"
and child_id not in self._compute_active_children(chore)
):
_LOGGER.debug(
"complete_chore no-op: '%s' not assigned to %s today",
chore.name,
child.name,
)
return None
# Check recurrence window for Mode B chores
if getattr(chore, "schedule_mode", "specific_days") == "recurring":
if not self.is_chore_available_for_child(chore, child_id):
_LOGGER.debug(
"complete_chore no-op: '%s' not available yet (recurrence window)",
chore.name,
)
return None
if getattr(chore, "schedule_mode", "specific_days") == "recurring" and not self.is_chore_available_for_child(
chore, child_id
):
_LOGGER.debug(
"complete_chore no-op: '%s' not available yet (recurrence window)",
chore.name,
)
return None
# Check availability for one-shot chores
if getattr(chore, "schedule_mode", "specific_days") == "one_shot":
if not self.is_chore_available_for_child(chore, child_id):
_LOGGER.debug(
"complete_chore no-op: '%s' not available (one-shot done or expired)",
chore.name,
)
return None
if getattr(chore, "schedule_mode", "specific_days") == "one_shot" and not self.is_chore_available_for_child(
chore, child_id
):
_LOGGER.debug(
"complete_chore no-op: '%s' not available (one-shot done or expired)",
chore.name,
)
return None
# Check daily limit (only count parent completions, not bonus sub-tasks)
all_completions = self.storage.get_completions()
@@ -876,10 +892,7 @@ class ChoresMixin:
# Determine child pool — empty assigned_to means all children
assigned = getattr(chore, "assigned_to", []) or []
if assigned:
child_ids = list(assigned)
else:
child_ids = [c.id for c in self.storage.get_children()]
child_ids = list(assigned) if assigned else [c.id for c in self.storage.get_children()]
if not child_ids:
raise ValueError(f"Chore '{chore.name}' has no children to suppress")
@@ -903,14 +916,15 @@ class ChoresMixin:
# Clear today's cached current assignee for rotation-mode chores so
# the "Current" column / child-stats card stops pointing at the
# original child. The pointer recomputes at the next midnight refresh.
if getattr(chore, "assignment_mode", "everyone") != "everyone":
if getattr(chore, "assignment_current_child_id", "") or getattr(chore, "assignment_swap_date", ""):
chore.assignment_current_child_id = ""
# Drop the swap override too, or it would re-point the column
# at the swapped-to child on the next assignment pass.
chore.assignment_swap_child_id = ""
chore.assignment_swap_date = ""
self.storage.update_chore(chore)
if getattr(chore, "assignment_mode", "everyone") != "everyone" and (
getattr(chore, "assignment_current_child_id", "") or getattr(chore, "assignment_swap_date", "")
):
chore.assignment_current_child_id = ""
# Drop the swap override too, or it would re-point the column
# at the swapped-to child on the next assignment pass.
chore.assignment_swap_child_id = ""
chore.assignment_swap_date = ""
self.storage.update_chore(chore)
await self.storage.async_save()
await self.async_refresh()
@@ -988,8 +1002,13 @@ class ChoresMixin:
await self.async_refresh()
return completion
async def async_approve_chore(self, completion_id: str) -> None:
"""Approve a chore completion."""
async def async_approve_chore(self, completion_id: str, refresh: bool = True) -> None:
"""Approve a chore completion.
``refresh=False`` is used by ``async_approve_chores_bulk`` so a batch
pays for one coordinator rebuild rather than one per completion (#794).
The caller must refresh once it is done.
"""
completions = self.storage.get_completions()
for completion in completions:
if completion.id == completion_id:
@@ -1055,7 +1074,8 @@ class ChoresMixin:
self.storage.update_chore(chore)
await self.storage.async_save()
await self.async_refresh()
if refresh:
await self.async_refresh()
# Trigger badge evaluation after approval awards points/chore count/streak
if getattr(self, "badges", None):
@@ -1290,11 +1310,8 @@ class ChoresMixin:
def _check_one_shot_fully_disabled(self, chore) -> None:
"""Check if a one-shot chore should be fully disabled (all children done)."""
if chore.assigned_to:
target_children = set(chore.assigned_to)
else:
# assigned_to=[] means all children
target_children = {c.id for c in self.storage.get_children()}
# assigned_to=[] means all children
target_children = set(chore.assigned_to) or {c.id for c in self.storage.get_children()}
if target_children and target_children.issubset(set(chore.disabled_for)):
chore.enabled = False
@@ -12,6 +12,7 @@ once this module is in place. They call self.notifications.fire(...).
from __future__ import annotations
import contextlib
import logging
from dataclasses import dataclass
from typing import Any
@@ -539,18 +540,20 @@ class NotificationCoordinator:
# Scheduler — time-gated callbacks
# ------------------------------------------------------------------
def cancel_schedules(self) -> None:
"""Cancel all registered time triggers (idempotent; also used on unload)."""
for unsub in self._scheduled_unsubs:
with contextlib.suppress(Exception):
unsub()
self._scheduled_unsubs = []
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 = []
self.cancel_schedules()
# Bedtime — per-child time
cfg = self.storage.get_notification_config("bedtime_reminder")
+6 -3
View File
@@ -778,9 +778,12 @@ class PointsMixin:
# #563: when enabled, the streak only advances once EVERY chore due that
# day is done. The in-flight completion (chore_id) is counted as done
# since it may not be persisted yet at this point in the flow.
if advance_streak and self._setting_enabled("streak_requires_all_chores"):
if not self._all_due_chores_done(child.id, effective_date, include_rotation=True, extra_done=chore_id):
advance_streak = False
if (
advance_streak
and self._setting_enabled("streak_requires_all_chores")
and not self._all_due_chores_done(child.id, effective_date, include_rotation=True, extra_done=chore_id)
):
advance_streak = False
if advance_streak:
streak_mode = self.storage.get_setting("streak_reset_mode", "reset")
streak_paused = getattr(child, "streak_paused", False)
+2 -3
View File
@@ -40,9 +40,8 @@ class TimedMixin:
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)")
if chore.timed_max_daily_minutes > 0 and 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)
+10 -33
View File
@@ -228,10 +228,11 @@ class TaskMateCoordinator(
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
return (
child is not None
and getattr(child, "pause_streak_when_unavailable", False)
and not self._is_child_available(child.id)
)
# ── Backup / restore ─────────────────────────────────────────────────
EXPORT_VERSION = 1
@@ -655,21 +656,11 @@ class TaskMateCoordinator(
async def async_shutdown(self) -> None:
"""Shutdown the coordinator and clean up listeners."""
self.cancel_unlock_timers()
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.notifications.cancel_schedules()
for attr in ("_unsub_midnight", "_unsub_prune", "_unsub_availability", "_unsub_surprise", "_unsub_weekly"):
if unsub := getattr(self, attr):
unsub()
setattr(self, attr, None)
self.disarm_mandatory_schedules()
# Flush any pending debounced save so an entry unload/reload can't drop
# the last mutation (PERF-3).
@@ -859,17 +850,3 @@ class TaskMateCoordinator(
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()
+1 -1
View File
@@ -17,5 +17,5 @@
"iot_class": "calculated",
"issue_tracker": "https://github.com/tempus2016/taskmate/issues",
"requirements": [],
"version": "5.1.1"
"version": "5.2.0"
}
+2 -2
View File
@@ -195,7 +195,7 @@ class Child:
best_streak=data.get("best_streak", 0),
pending_rewards=list(data.get("pending_rewards", [])),
chore_order=list(data.get("chore_order", [])),
last_completion_date=data.get("last_completion_date", None),
last_completion_date=data.get("last_completion_date"),
streak_paused=data.get("streak_paused", False),
streak_milestones_achieved=list(data.get("streak_milestones_achieved", [])),
awarded_perfect_weeks=list(data.get("awarded_perfect_weeks", [])),
@@ -207,7 +207,7 @@ class Child:
pause_streak_when_unavailable=data.get("pause_streak_when_unavailable", False),
career_score=data.get("career_score", 0),
total_penalties_received=data.get("total_penalties_received", 0),
notify_service=data.get("notify_service", None),
notify_service=data.get("notify_service"),
linked_user_id=data.get("linked_user_id", ""),
quiet_hours_start=data.get("quiet_hours_start", ""),
quiet_hours_end=data.get("quiet_hours_end", ""),
+2 -6
View File
@@ -16,6 +16,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import TaskMateCoordinator
from .entity import taskmate_device_info
# (setting_key, translation_key, min, max, step, default, icon)
_NUMBERS = [
@@ -50,12 +51,7 @@ class TaskMateSettingNumber(CoordinatorEntity, NumberEntity):
@property
def device_info(self) -> DeviceInfo:
return DeviceInfo(
identifiers={(DOMAIN, self._entry.entry_id)},
name="TaskMate",
manufacturer="TaskMate",
model="Family Chore Manager",
)
return taskmate_device_info(self._entry.entry_id)
@property
def native_value(self) -> float:
+10 -7
View File
@@ -10,6 +10,7 @@ served (auth-gated) at ``/api/taskmate/photo/<name>``.
from __future__ import annotations
import contextlib
import logging
import re
import time
@@ -158,21 +159,23 @@ async def async_delete_photo(hass, photo_url: str) -> None:
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)
def matching_files_bytes(directory: Path, filename_re: re.Pattern[str]) -> int:
"""Sum sizes of files in ``directory`` matching ``filename_re`` (0 if absent)."""
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:
if p.is_file() and filename_re.match(p.name):
with contextlib.suppress(OSError): # pragma: no cover - defensive
total += p.stat().st_size
except OSError: # pragma: no cover - defensive
pass
return total
def total_photos_bytes(hass) -> int:
"""Sum of all stored evidence-photo file sizes (0 if the dir is absent)."""
return matching_files_bytes(photos_path(hass), FILENAME_RE)
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).
+2 -6
View File
@@ -11,6 +11,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import TaskMateCoordinator
from .entity import taskmate_device_info
# (setting_key, translation_key, options, default, icon)
_SELECTS = [
@@ -48,12 +49,7 @@ class TaskMateSettingSelect(CoordinatorEntity, SelectEntity):
@property
def device_info(self) -> DeviceInfo:
return DeviceInfo(
identifiers={(DOMAIN, self._entry.entry_id)},
name="TaskMate",
manufacturer="TaskMate",
model="Family Chore Manager",
)
return taskmate_device_info(self._entry.entry_id)
@property
def current_option(self) -> str:
+7 -13
View File
@@ -19,6 +19,7 @@ from homeassistant.util import dt as dt_util
from . import images, photos
from .const import DOMAIN
from .coordinator import TaskMateCoordinator
from .entity import taskmate_device_info
from .models import Child
_LOGGER = logging.getLogger(__name__)
@@ -453,7 +454,7 @@ def _build_rewards_list(common: dict) -> list[dict]:
out = []
for r in rewards:
assigned = r.assigned_to if isinstance(r.assigned_to, list) and r.assigned_to else [c.id for c in children]
calculated_costs = {child_id: r.cost for child_id in assigned}
calculated_costs = dict.fromkeys(assigned, r.cost)
reward_pool_allocations = {cid: pool_by_child_reward.get(cid, {}).get(r.id, 0) for cid in assigned}
jackpot_pool_total = pool_total_by_reward.get(r.id, 0) if getattr(r, "is_jackpot", False) else None
quantity = getattr(r, "quantity", None)
@@ -736,12 +737,7 @@ class TaskMateBaseSensor(CoordinatorEntity, SensorEntity):
@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",
)
return taskmate_device_info(self._entry.entry_id)
class _CachedAttrsSensor(TaskMateBaseSensor):
@@ -1143,12 +1139,10 @@ class ChildStatsSensor(TaskMateBaseSensor):
if not (child.id in c.assigned_to or not c.assigned_to):
return False
mode = getattr(c, "assignment_mode", "everyone")
if mode not in ("everyone", "first_come"):
if getattr(c, "assignment_current_child_id", "") != child.id:
return False
if mode != "everyone":
if self.coordinator._is_rotation_done_today(c):
return False
if mode not in ("everyone", "first_come") and getattr(c, "assignment_current_child_id", "") != child.id:
return False
if mode != "everyone" and self.coordinator._is_rotation_done_today(c):
return False
return True
assigned_chores = [c for c in chores if _included(c)]
-4
View File
@@ -378,10 +378,6 @@ class TaskMateStorage:
if chore_id in order:
order.remove(chore_id)
def get_chore_display_order(self) -> list[str]:
"""Get the global chore display order."""
return list(self._data.get("chore_display_order", []))
def set_chore_display_order(self, order: list[str]) -> None:
"""Set the global chore display order."""
self._data["chore_display_order"] = list(order)
+39 -253
View File
@@ -35,6 +35,22 @@ TEMPLATE_CHORE_FIELDS = (
_WEEKDAYS = ["monday", "tuesday", "wednesday", "thursday", "friday"]
_WEEKENDS = ["saturday", "sunday"]
def _chore(name: str, points: int, time_category: str, due_days: list[str] = _WEEKDAYS) -> dict:
"""One built-in template chore; all built-ins share the same fixed defaults."""
return {
"name": name,
"points": points,
"time_category": time_category,
"schedule_mode": "specific_days",
"due_days": list(due_days),
"requires_approval": False,
"assignment_mode": "everyone",
"daily_limit": 1,
"completion_sound": "coin",
}
BUILT_IN_TEMPLATES: list[dict] = [
{
"id": "morning_routine",
@@ -42,50 +58,10 @@ BUILT_IN_TEMPLATES: list[dict] = [
"icon": "mdi:weather-sunny",
"builtin": True,
"chores": [
{
"name": "Make bed",
"points": 2,
"time_category": "morning",
"schedule_mode": "specific_days",
"due_days": list(_WEEKDAYS),
"requires_approval": False,
"assignment_mode": "everyone",
"daily_limit": 1,
"completion_sound": "coin",
},
{
"name": "Brush teeth",
"points": 1,
"time_category": "morning",
"schedule_mode": "specific_days",
"due_days": list(_WEEKDAYS),
"requires_approval": False,
"assignment_mode": "everyone",
"daily_limit": 1,
"completion_sound": "coin",
},
{
"name": "Get dressed",
"points": 1,
"time_category": "morning",
"schedule_mode": "specific_days",
"due_days": list(_WEEKDAYS),
"requires_approval": False,
"assignment_mode": "everyone",
"daily_limit": 1,
"completion_sound": "coin",
},
{
"name": "Pack school bag",
"points": 2,
"time_category": "morning",
"schedule_mode": "specific_days",
"due_days": list(_WEEKDAYS),
"requires_approval": False,
"assignment_mode": "everyone",
"daily_limit": 1,
"completion_sound": "coin",
},
_chore("Make bed", 2, "morning"),
_chore("Brush teeth", 1, "morning"),
_chore("Get dressed", 1, "morning"),
_chore("Pack school bag", 2, "morning"),
],
},
{
@@ -94,50 +70,10 @@ BUILT_IN_TEMPLATES: list[dict] = [
"icon": "mdi:weather-night",
"builtin": True,
"chores": [
{
"name": "Brush teeth",
"points": 1,
"time_category": "evening",
"schedule_mode": "specific_days",
"due_days": list(_WEEKDAYS),
"requires_approval": False,
"assignment_mode": "everyone",
"daily_limit": 1,
"completion_sound": "coin",
},
{
"name": "Put on pyjamas",
"points": 1,
"time_category": "evening",
"schedule_mode": "specific_days",
"due_days": list(_WEEKDAYS),
"requires_approval": False,
"assignment_mode": "everyone",
"daily_limit": 1,
"completion_sound": "coin",
},
{
"name": "Tidy room",
"points": 2,
"time_category": "evening",
"schedule_mode": "specific_days",
"due_days": list(_WEEKDAYS),
"requires_approval": False,
"assignment_mode": "everyone",
"daily_limit": 1,
"completion_sound": "coin",
},
{
"name": "Set out clothes for tomorrow",
"points": 1,
"time_category": "evening",
"schedule_mode": "specific_days",
"due_days": list(_WEEKDAYS),
"requires_approval": False,
"assignment_mode": "everyone",
"daily_limit": 1,
"completion_sound": "coin",
},
_chore("Brush teeth", 1, "evening"),
_chore("Put on pyjamas", 1, "evening"),
_chore("Tidy room", 2, "evening"),
_chore("Set out clothes for tomorrow", 1, "evening"),
],
},
{
@@ -146,50 +82,10 @@ BUILT_IN_TEMPLATES: list[dict] = [
"icon": "mdi:silverware-fork-knife",
"builtin": True,
"chores": [
{
"name": "Set table",
"points": 2,
"time_category": "evening",
"schedule_mode": "specific_days",
"due_days": list(_WEEKDAYS),
"requires_approval": False,
"assignment_mode": "everyone",
"daily_limit": 1,
"completion_sound": "coin",
},
{
"name": "Clear plates",
"points": 2,
"time_category": "evening",
"schedule_mode": "specific_days",
"due_days": list(_WEEKDAYS),
"requires_approval": False,
"assignment_mode": "everyone",
"daily_limit": 1,
"completion_sound": "coin",
},
{
"name": "Load dishwasher",
"points": 3,
"time_category": "evening",
"schedule_mode": "specific_days",
"due_days": list(_WEEKDAYS),
"requires_approval": False,
"assignment_mode": "everyone",
"daily_limit": 1,
"completion_sound": "coin",
},
{
"name": "Wipe counters",
"points": 2,
"time_category": "evening",
"schedule_mode": "specific_days",
"due_days": list(_WEEKDAYS),
"requires_approval": False,
"assignment_mode": "everyone",
"daily_limit": 1,
"completion_sound": "coin",
},
_chore("Set table", 2, "evening"),
_chore("Clear plates", 2, "evening"),
_chore("Load dishwasher", 3, "evening"),
_chore("Wipe counters", 2, "evening"),
],
},
{
@@ -198,50 +94,10 @@ BUILT_IN_TEMPLATES: list[dict] = [
"icon": "mdi:broom",
"builtin": True,
"chores": [
{
"name": "Tidy bedroom",
"points": 3,
"time_category": "anytime",
"schedule_mode": "specific_days",
"due_days": list(_WEEKENDS),
"requires_approval": False,
"assignment_mode": "everyone",
"daily_limit": 1,
"completion_sound": "coin",
},
{
"name": "Hoover",
"points": 4,
"time_category": "anytime",
"schedule_mode": "specific_days",
"due_days": list(_WEEKENDS),
"requires_approval": False,
"assignment_mode": "everyone",
"daily_limit": 1,
"completion_sound": "coin",
},
{
"name": "Help with laundry",
"points": 3,
"time_category": "anytime",
"schedule_mode": "specific_days",
"due_days": list(_WEEKENDS),
"requires_approval": False,
"assignment_mode": "everyone",
"daily_limit": 1,
"completion_sound": "coin",
},
{
"name": "Take bins out",
"points": 2,
"time_category": "anytime",
"schedule_mode": "specific_days",
"due_days": list(_WEEKENDS),
"requires_approval": False,
"assignment_mode": "everyone",
"daily_limit": 1,
"completion_sound": "coin",
},
_chore("Tidy bedroom", 3, "anytime", _WEEKENDS),
_chore("Hoover", 4, "anytime", _WEEKENDS),
_chore("Help with laundry", 3, "anytime", _WEEKENDS),
_chore("Take bins out", 2, "anytime", _WEEKENDS),
],
},
{
@@ -250,50 +106,10 @@ BUILT_IN_TEMPLATES: list[dict] = [
"icon": "mdi:paw",
"builtin": True,
"chores": [
{
"name": "Feed pet",
"points": 2,
"time_category": "morning",
"schedule_mode": "specific_days",
"due_days": list(_WEEKDAYS),
"requires_approval": False,
"assignment_mode": "everyone",
"daily_limit": 1,
"completion_sound": "coin",
},
{
"name": "Fill water bowl",
"points": 1,
"time_category": "morning",
"schedule_mode": "specific_days",
"due_days": list(_WEEKDAYS),
"requires_approval": False,
"assignment_mode": "everyone",
"daily_limit": 1,
"completion_sound": "coin",
},
{
"name": "Walk dog",
"points": 3,
"time_category": "afternoon",
"schedule_mode": "specific_days",
"due_days": list(_WEEKDAYS),
"requires_approval": False,
"assignment_mode": "everyone",
"daily_limit": 1,
"completion_sound": "coin",
},
{
"name": "Clean litter tray",
"points": 3,
"time_category": "anytime",
"schedule_mode": "specific_days",
"due_days": list(_WEEKDAYS),
"requires_approval": False,
"assignment_mode": "everyone",
"daily_limit": 1,
"completion_sound": "coin",
},
_chore("Feed pet", 2, "morning"),
_chore("Fill water bowl", 1, "morning"),
_chore("Walk dog", 3, "afternoon"),
_chore("Clean litter tray", 3, "anytime"),
],
},
{
@@ -302,39 +118,9 @@ BUILT_IN_TEMPLATES: list[dict] = [
"icon": "mdi:book-open-variant",
"builtin": True,
"chores": [
{
"name": "Do homework",
"points": 3,
"time_category": "afternoon",
"schedule_mode": "specific_days",
"due_days": list(_WEEKDAYS),
"requires_approval": False,
"assignment_mode": "everyone",
"daily_limit": 1,
"completion_sound": "coin",
},
{
"name": "Read for 20 minutes",
"points": 2,
"time_category": "anytime",
"schedule_mode": "specific_days",
"due_days": list(_WEEKDAYS),
"requires_approval": False,
"assignment_mode": "everyone",
"daily_limit": 1,
"completion_sound": "coin",
},
{
"name": "Practice instrument",
"points": 2,
"time_category": "anytime",
"schedule_mode": "specific_days",
"due_days": list(_WEEKDAYS),
"requires_approval": False,
"assignment_mode": "everyone",
"daily_limit": 1,
"completion_sound": "coin",
},
_chore("Do homework", 3, "afternoon"),
_chore("Read for 20 minutes", 2, "anytime"),
_chore("Practice instrument", 2, "anytime"),
],
},
]
+2 -6
View File
@@ -22,6 +22,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import TaskMateCoordinator
from .entity import taskmate_device_info
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None:
@@ -57,12 +58,7 @@ class TaskMateChildTodoList(CoordinatorEntity, TodoListEntity):
@property
def device_info(self) -> DeviceInfo:
return DeviceInfo(
identifiers={(DOMAIN, self._entry.entry_id)},
name="TaskMate",
manufacturer="TaskMate",
model="Family Chore Manager",
)
return taskmate_device_info(self._entry.entry_id)
@property
def todo_items(self) -> list[TodoItem]:
+9 -10
View File
@@ -59,10 +59,13 @@ from homeassistant.core import HomeAssistant
from . import images, photos
from .const import (
ASSIGNMENT_MODES,
DEFAULT_NOTIFICATION_NAV_URL,
DEFAULT_TIME_PERIODS,
DIFFICULTY_TIERS,
DOMAIN,
MAX_TIME_PERIODS,
SCHEDULE_MODES,
TIME_CATEGORY_ICONS,
)
from .coordinator import TaskMateCoordinator
@@ -598,8 +601,8 @@ def _chore_payload_schema(*, require_name: bool):
vol.Optional("completion_sound"): str,
vol.Optional("icon"): str,
vol.Optional("image_url"): _image_url_or_blank,
vol.Optional("difficulty"): vol.In(["easy", "medium", "hard"]),
vol.Optional("schedule_mode"): vol.In(["specific_days", "recurring", "one_shot"]),
vol.Optional("difficulty"): vol.In(DIFFICULTY_TIERS),
vol.Optional("schedule_mode"): vol.In(SCHEDULE_MODES),
vol.Optional("due_days"): [str],
vol.Optional("recurrence"): str,
vol.Optional("recurrence_day"): str,
@@ -624,9 +627,7 @@ def _chore_payload_schema(*, require_name: bool):
vol.Optional("mandatory"): bool,
vol.Optional("mandatory_penalty_points"): vol.All(int, vol.Range(min=0)),
vol.Optional("require_photo"): bool,
vol.Optional("assignment_mode"): vol.In(
["everyone", "alternating", "random", "balanced", "first_come", "unassigned"]
),
vol.Optional("assignment_mode"): vol.In(ASSIGNMENT_MODES),
vol.Optional("assignment_rotation_anchor"): str,
vol.Optional("require_availability"): bool,
vol.Optional("publish_calendar_entities"): [str],
@@ -1967,7 +1968,7 @@ async def _ws_set_global_chore_order(hass, connection, msg, coordinator):
vol.Optional("assigned_to"): [str],
vol.Optional("requires_approval"): bool,
vol.Optional("time_category"): str,
vol.Optional("schedule_mode"): vol.In(["specific_days", "recurring", "one_shot"]),
vol.Optional("schedule_mode"): vol.In(SCHEDULE_MODES),
vol.Optional("due_days"): [str],
vol.Optional("daily_limit"): vol.All(int, vol.Range(min=1)),
vol.Optional("completion_sound"): str,
@@ -2035,15 +2036,13 @@ async def _ws_templates_get(hass, connection, msg, coordinator):
vol.Optional("time_category"): str,
vol.Optional("daily_limit"): vol.All(int, vol.Range(min=1)),
vol.Optional("completion_sound"): str,
vol.Optional("schedule_mode"): vol.In(["specific_days", "recurring", "one_shot"]),
vol.Optional("schedule_mode"): vol.In(SCHEDULE_MODES),
vol.Optional("due_days"): [str],
vol.Optional("recurrence"): str,
vol.Optional("recurrence_day"): str,
vol.Optional("recurrence_start"): str,
vol.Optional("first_occurrence_mode"): str,
vol.Optional("assignment_mode"): vol.In(
["everyone", "alternating", "random", "balanced", "first_come", "unassigned"]
),
vol.Optional("assignment_mode"): vol.In(ASSIGNMENT_MODES),
vol.Optional("require_availability"): bool,
vol.Optional("visibility_entity"): str,
vol.Optional("visibility_state"): str,
+10 -1
View File
@@ -69,6 +69,7 @@
"approvals.all_caught_up": "Alles aufgeholt!",
"approvals.approve": "Genehmigen",
"approvals.approve_all": "Alle genehmigen",
"approvals.approve_all_busy": "Wird genehmigt…",
"approvals.approve_all_confirm": "{count} ausstehende Aufgaben genehmigen?",
"approvals.card_description": "Eine Karte zum Verwalten ausstehender Aufgabengenehmigungen für TaskMate",
"approvals.card_name": "TaskMate-Genehmigungen",
@@ -229,6 +230,12 @@
"child.editor.child": "Kind",
"child.editor.child_helper": "Für welches Kind ist diese Karte?",
"child.editor.chores_not_due_today": "Aufgaben, die heute nicht fällig sind",
"child.editor.dependency_mode": "Aufgaben, die auf andere warten",
"child.editor.dependency_hide": "Ausblenden – erst nach Freischaltung zeigen",
"child.editor.dependency_dim": "Gedimmt – ausgegraut anzeigen, nicht interaktiv",
"child.editor.dependency_show": "Anzeigen – normal zeigen (weiterhin nicht antippbar)",
"child.editor.dependency_helper": "Was angezeigt wird, wenn eine Aufgabe von einer anderen abhängt, die heute noch nicht genehmigt ist",
"child.blocked_by_dependency": "Zuerst {chores} erledigen",
"child.editor.due_days_dim": "Gedimmt – ausgegraut anzeigen, nicht interaktiv",
"child.editor.due_days_helper": "Gilt für Aufgaben, bei denen due_days festgelegt ist, wenn der heutige Tag nicht geplant ist",
"child.editor.due_days_hide": "Ausblenden – nicht anzeigen",
@@ -241,7 +248,7 @@
"child.editor.recurrence_dim": "Gedimmt – ausgegraut anzeigen, nicht interaktiv",
"child.editor.recurrence_helper": "Was angezeigt werden soll, wenn eine wiederkehrende Aufgabe abgeschlossen wurde und das Fenster noch nicht zurückgesetzt wurde",
"child.editor.recurrence_hide": "Ausblenden – erst wieder anzeigen, wenn es wieder verfügbar ist",
"child.editor.recurrence_show": "Anzeigen – trotzdem normal anzeigen",
"child.editor.recurrence_show": "Anzeigen – normal zeigen (weiterhin nicht antippbar)",
"child.editor.recurring_when_completed": "Wiederkehrende Aufgaben – nach Abschluss",
"child.editor.select_child": "Wählen Sie ein Kind aus...",
"child.editor.show_countdown": "Countdown zum Zurücksetzen neben „Heutige Aufgaben“ anzeigen",
@@ -420,6 +427,7 @@
"overview.pending_count": "{count} ausstehend",
"panel.activity_approve": "Genehmigen",
"panel.activity_approve_all": "Alle genehmigen",
"panel.activity_approve_all_busy": "Wird genehmigt…",
"panel.activity_approve_all_confirm": "{count} ausstehende Aufgaben genehmigen?",
"panel.activity_approve_all_done": "{count} Aufgaben genehmigt",
"panel.activity_audit_log": "Protokoll",
@@ -1738,5 +1746,6 @@
"panel.chore_image_bad_type": "Wähle ein JPEG-, PNG- oder WebP-Bild.",
"child.editor.pre_reader": "Nur-Bilder-Modus (für kleine Kinder)",
"child.editor.pre_reader_labels": "Namen im Bildmodus anzeigen",
"child.editor.pre_reader_points": "Punktzahl statt Sterne im Bildmodus anzeigen",
"common.design.accessible": "Barrierefrei"
}
@@ -69,6 +69,7 @@
"approvals.all_caught_up": "All caught up!",
"approvals.approve": "Approve",
"approvals.approve_all": "Approve all",
"approvals.approve_all_busy": "Approving…",
"approvals.approve_all_confirm": "Approve {count} pending chores?",
"approvals.card_description": "A card to manage pending chore approvals for TaskMate",
"approvals.card_name": "TaskMate Approvals",
@@ -231,6 +232,12 @@
"child.editor.child": "Child",
"child.editor.child_helper": "Which child is this card for?",
"child.editor.chores_not_due_today": "Chores Not Due Today",
"child.editor.dependency_mode": "Chores Waiting On Another",
"child.editor.dependency_hide": "Hide — don't show until unlocked",
"child.editor.dependency_dim": "Dim — show greyed out, non-interactive",
"child.editor.dependency_show": "Show — show normally (still not tappable)",
"child.editor.dependency_helper": "What to show when a chore depends on another that isn't approved yet today",
"child.blocked_by_dependency": "Do {chores} first",
"child.editor.due_days_dim": "Dim — show greyed out, non-interactive",
"child.editor.due_days_helper": "Applies to chores with due_days set when today isn't scheduled",
"child.editor.due_days_hide": "Hide — don't show",
@@ -243,7 +250,7 @@
"child.editor.recurrence_dim": "Dim — show greyed out, non-interactive",
"child.editor.recurrence_helper": "What to show when a recurring chore has been completed and the window hasn't reset yet",
"child.editor.recurrence_hide": "Hide — don't show until available again",
"child.editor.recurrence_show": "Show — show normally regardless",
"child.editor.recurrence_show": "Show — show normally (still not tappable)",
"child.editor.recurring_when_completed": "Recurring Chores — When Completed",
"child.editor.select_child": "Select a child...",
"child.editor.show_countdown": "Show reset countdown beside \"Today's Chores\"",
@@ -427,6 +434,7 @@
"overview.pending_count": "{count} pending",
"panel.activity_approve": "Approve",
"panel.activity_approve_all": "Approve all",
"panel.activity_approve_all_busy": "Approving…",
"panel.activity_approve_all_confirm": "Approve {count} pending chores?",
"panel.activity_approve_all_done": "Approved {count} chores",
"panel.activity_audit_log": "Audit log",
@@ -1738,5 +1746,6 @@
"panel.chore_image_bad_type": "Choose a JPEG, PNG or WebP image.",
"child.editor.pre_reader": "Picture-only mode (for young children)",
"child.editor.pre_reader_labels": "Show names in picture mode",
"child.editor.pre_reader_points": "Show point values instead of stars in picture mode",
"common.design.accessible": "Accessible"
}
+10 -1
View File
@@ -69,6 +69,7 @@
"approvals.all_caught_up": "All caught up!",
"approvals.approve": "Approve",
"approvals.approve_all": "Approve all",
"approvals.approve_all_busy": "Approving…",
"approvals.approve_all_confirm": "Approve {count} pending chores?",
"approvals.card_description": "A card to manage pending chore approvals for TaskMate",
"approvals.card_name": "TaskMate Approvals",
@@ -231,6 +232,12 @@
"child.editor.child": "Child",
"child.editor.child_helper": "Which child is this card for?",
"child.editor.chores_not_due_today": "Chores Not Due Today",
"child.editor.dependency_mode": "Chores Waiting On Another",
"child.editor.dependency_hide": "Hide — don't show until unlocked",
"child.editor.dependency_dim": "Dim — show greyed out, non-interactive",
"child.editor.dependency_show": "Show — show normally (still not tappable)",
"child.editor.dependency_helper": "What to show when a chore depends on another that isn't approved yet today",
"child.blocked_by_dependency": "Do {chores} first",
"child.editor.due_days_dim": "Dim — show greyed out, non-interactive",
"child.editor.due_days_helper": "Applies to chores with due_days set when today isn't scheduled",
"child.editor.due_days_hide": "Hide — don't show",
@@ -243,7 +250,7 @@
"child.editor.recurrence_dim": "Dim — show greyed out, non-interactive",
"child.editor.recurrence_helper": "What to show when a recurring chore has been completed and the window hasn't reset yet",
"child.editor.recurrence_hide": "Hide — don't show until available again",
"child.editor.recurrence_show": "Show — show normally regardless",
"child.editor.recurrence_show": "Show — show normally (still not tappable)",
"child.editor.recurring_when_completed": "Recurring Chores — When Completed",
"child.editor.select_child": "Select a child...",
"child.editor.show_countdown": "Show reset countdown beside \"Today's Chores\"",
@@ -427,6 +434,7 @@
"overview.pending_count": "{count} pending",
"panel.activity_approve": "Approve",
"panel.activity_approve_all": "Approve all",
"panel.activity_approve_all_busy": "Approving…",
"panel.activity_approve_all_confirm": "Approve {count} pending chores?",
"panel.activity_approve_all_done": "Approved {count} chores",
"panel.activity_audit_log": "Audit log",
@@ -1738,5 +1746,6 @@
"panel.chore_image_bad_type": "Choose a JPEG, PNG or WebP image.",
"child.editor.pre_reader": "Picture-only mode (for young children)",
"child.editor.pre_reader_labels": "Show names in picture mode",
"child.editor.pre_reader_points": "Show point values instead of stars in picture mode",
"common.design.accessible": "Accessible"
}
+10 -1
View File
@@ -69,6 +69,7 @@
"approvals.all_caught_up": "Tout est à jour !",
"approvals.approve": "Approuver",
"approvals.approve_all": "Tout approuver",
"approvals.approve_all_busy": "Approbation…",
"approvals.approve_all_confirm": "Approuver {count} tâches en attente ?",
"approvals.card_description": "Une carte pour gérer les approbations de tâches en attente pour TaskMate",
"approvals.card_name": "Approbations TaskMate",
@@ -229,6 +230,12 @@
"child.editor.child": "Enfant",
"child.editor.child_helper": "Pour quel enfant est cette carte ?",
"child.editor.chores_not_due_today": "Tâches non prévues aujourd'hui",
"child.editor.dependency_mode": "Tâches en attente d'une autre",
"child.editor.dependency_hide": "Masquer — ne pas afficher avant déblocage",
"child.editor.dependency_dim": "Estomper — afficher en gris, non interactif",
"child.editor.dependency_show": "Afficher — affichage normal (toujours non cliquable)",
"child.editor.dependency_helper": "Ce qu'il faut afficher quand une tâche dépend d'une autre non encore approuvée aujourd'hui",
"child.blocked_by_dependency": "Fais d'abord {chores}",
"child.editor.due_days_dim": "Estomper — afficher en gris, non interactif",
"child.editor.due_days_helper": "S'applique aux tâches avec des jours de passage définis quand aujourd'hui n'est pas programmé",
"child.editor.due_days_hide": "Masquer — ne pas afficher",
@@ -241,7 +248,7 @@
"child.editor.recurrence_dim": "Estomper — afficher en gris, non interactif",
"child.editor.recurrence_helper": "Que montrer lorsqu'une tâche récurrente a été accomplie et que la fenêtre n'a pas encore été réinitialisée",
"child.editor.recurrence_hide": "Masquer — ne pas afficher avant d'être à nouveau disponible",
"child.editor.recurrence_show": "Afficher — afficher normalement malgré tout",
"child.editor.recurrence_show": "Afficher — affichage normal (toujours non cliquable)",
"child.editor.recurring_when_completed": "Tâches récurrentes — une fois terminées",
"child.editor.select_child": "Sélectionner un enfant...",
"child.editor.show_countdown": "Afficher le compte à rebours de réinitialisation à côté de \"Tâches d'aujourd'hui\"",
@@ -420,6 +427,7 @@
"overview.pending_count": "{count} en attente",
"panel.activity_approve": "Approuver",
"panel.activity_approve_all": "Tout approuver",
"panel.activity_approve_all_busy": "Approbation…",
"panel.activity_approve_all_confirm": "Approuver {count} tâches en attente ?",
"panel.activity_approve_all_done": "{count} tâches approuvées",
"panel.activity_audit_log": "Journal d'audit",
@@ -1738,5 +1746,6 @@
"panel.chore_image_bad_type": "Choisissez une image JPEG, PNG ou WebP.",
"child.editor.pre_reader": "Mode images seules (jeunes enfants)",
"child.editor.pre_reader_labels": "Afficher les noms en mode images",
"child.editor.pre_reader_points": "Afficher les points au lieu des étoiles en mode images",
"common.design.accessible": "Accessible"
}
+10 -1
View File
@@ -69,6 +69,7 @@
"approvals.all_caught_up": "Alt er oppdatert!",
"approvals.approve": "Godkjenn",
"approvals.approve_all": "Godkjenn alle",
"approvals.approve_all_busy": "Godkjenner…",
"approvals.approve_all_confirm": "Godkjenne {count} ventende gjøremål?",
"approvals.card_description": "Et kort for å håndtere ventende oppgavegodkjenninger",
"approvals.card_name": "TaskMate Godkjenninger",
@@ -229,6 +230,12 @@
"child.editor.child": "Barn",
"child.editor.child_helper": "Hvilket barn er dette kortet for?",
"child.editor.chores_not_due_today": "Oppgaver som ikke forfaller i dag",
"child.editor.dependency_mode": "Oppgaver som venter på en annen",
"child.editor.dependency_hide": "Skjul — ikke vis før den er låst opp",
"child.editor.dependency_dim": "Dim — vis gråtonet, ikke-interaktiv",
"child.editor.dependency_show": "Vis — vis normalt (fortsatt ikke trykkbar)",
"child.editor.dependency_helper": "Hva som vises når en oppgave avhenger av en annen som ikke er godkjent i dag ennå",
"child.blocked_by_dependency": "Gjør {chores} først",
"child.editor.due_days_dim": "Dim — vis gråtonet, ikke-interaktiv",
"child.editor.due_days_helper": "Gjelder oppgaver med satte dager når i dag ikke er planlagt",
"child.editor.due_days_hide": "Skjul — ikke vis",
@@ -241,7 +248,7 @@
"child.editor.recurrence_dim": "Dim — vis gråtonet, ikke-interaktiv",
"child.editor.recurrence_helper": "Hva som vises når en gjentagende oppgave er fullført og vinduet ikke har nullstilt seg ennå",
"child.editor.recurrence_hide": "Skjul — ikke vis før tilgjengelig igjen",
"child.editor.recurrence_show": "Vis — vis normalt uansett",
"child.editor.recurrence_show": "Vis — vis normalt (fortsatt ikke trykkbar)",
"child.editor.recurring_when_completed": "Gjentagende oppgaver — når fullført",
"child.editor.select_child": "Velg et barn...",
"child.editor.show_countdown": "Vis nedtelling ved siden av «Dagens oppgaver»",
@@ -420,6 +427,7 @@
"overview.pending_count": "{count} venter",
"panel.activity_approve": "Godkjenn",
"panel.activity_approve_all": "Godkjenn alle",
"panel.activity_approve_all_busy": "Godkjenner…",
"panel.activity_approve_all_confirm": "Godkjenne {count} ventende gjøremål?",
"panel.activity_approve_all_done": "Godkjente {count} gjøremål",
"panel.activity_audit_log": "Revisjonslogg",
@@ -1738,5 +1746,6 @@
"panel.chore_image_bad_type": "Velg et JPEG-, PNG- eller WebP-bilde.",
"child.editor.pre_reader": "Kun bilder (for små barn)",
"child.editor.pre_reader_labels": "Vis navn i bildemodus",
"child.editor.pre_reader_points": "Vis poengverdier i stedet for stjerner i bildemodus",
"common.design.accessible": "Tilgjengelig"
}
+10 -1
View File
@@ -69,6 +69,7 @@
"approvals.all_caught_up": "Alt er oppdatert!",
"approvals.approve": "Godkjenn",
"approvals.approve_all": "Godkjenn alle",
"approvals.approve_all_busy": "Godkjenner…",
"approvals.approve_all_confirm": "Godkjenne {count} ventande gjeremål?",
"approvals.card_description": "Eit kort for å handtere ventande oppgåvegodkjenningar",
"approvals.card_name": "TaskMate Godkjenningar",
@@ -229,6 +230,12 @@
"child.editor.child": "Born",
"child.editor.child_helper": "Kva born er dette kortet for?",
"child.editor.chores_not_due_today": "Oppgåver som ikkje forfell i dag",
"child.editor.dependency_mode": "Oppgåver som ventar på ei anna",
"child.editor.dependency_hide": "Skjul — ikkje vis før den er låst opp",
"child.editor.dependency_dim": "Dim — vis gråtona, ikkje-interaktiv",
"child.editor.dependency_show": "Vis — vis normalt (framleis ikkje trykkbar)",
"child.editor.dependency_helper": "Kva som blir vist når ei oppgåve avheng av ei anna som ikkje er godkjend i dag enno",
"child.blocked_by_dependency": "Gjer {chores} først",
"child.editor.due_days_dim": "Dim — vis gråtona, ikkje-interaktiv",
"child.editor.due_days_helper": "Gjeld oppgåver med sette dagar når i dag ikkje er planlagt",
"child.editor.due_days_hide": "Gøym — ikkje vis",
@@ -241,7 +248,7 @@
"child.editor.recurrence_dim": "Dim — vis gråtona, ikkje-interaktiv",
"child.editor.recurrence_helper": "Kva som visast når ei gjentakande oppgåve er fullført og vindauget ikkje har nullstilt seg enno",
"child.editor.recurrence_hide": "Gøym — ikkje vis før tilgjengeleg att",
"child.editor.recurrence_show": "Vis — vis normalt uansett",
"child.editor.recurrence_show": "Vis — vis normalt (framleis ikkje trykkbar)",
"child.editor.recurring_when_completed": "Gjentakande oppgåver — når fullført",
"child.editor.select_child": "Vel eit born...",
"child.editor.show_countdown": "Vis nedteljing ved sida av «Dagens oppgåver»",
@@ -420,6 +427,7 @@
"overview.pending_count": "{count} ventar",
"panel.activity_approve": "Godkjenn",
"panel.activity_approve_all": "Godkjenn alle",
"panel.activity_approve_all_busy": "Godkjenner…",
"panel.activity_approve_all_confirm": "Godkjenne {count} ventande gjeremål?",
"panel.activity_approve_all_done": "Godkjende {count} gjeremål",
"panel.activity_audit_log": "Revisjonslogg",
@@ -1738,5 +1746,6 @@
"panel.chore_image_bad_type": "Vel eit JPEG-, PNG- eller WebP-bilete.",
"child.editor.pre_reader": "Berre bilete (for små born)",
"child.editor.pre_reader_labels": "Vis namn i biletemodus",
"child.editor.pre_reader_points": "Vis poengverdiar i staden for stjerner i biletemodus",
"common.design.accessible": "Tilgjengeleg"
}
@@ -69,6 +69,7 @@
"approvals.all_caught_up": "Está tudo em dia!",
"approvals.approve": "Aprovar",
"approvals.approve_all": "Aprovar tudo",
"approvals.approve_all_busy": "Aprovando…",
"approvals.approve_all_confirm": "Aprovar {count} tarefas pendentes?",
"approvals.card_description": "Cartão para gerir aprovações de tarefas pendentes no TaskMate",
"approvals.card_name": "Aprovações TaskMate",
@@ -229,6 +230,12 @@
"child.editor.child": "Criança",
"child.editor.child_helper": "Para qual criança é este cartão?",
"child.editor.chores_not_due_today": "Tarefas Sem Prazo Hoje",
"child.editor.dependency_mode": "Tarefas aguardando outra",
"child.editor.dependency_hide": "Ocultar — não mostrar até desbloquear",
"child.editor.dependency_dim": "Atenuar — mostrar em cinzento, sem interação",
"child.editor.dependency_show": "Mostrar — mostrar normalmente (continua sem toque)",
"child.editor.dependency_helper": "O que mostrar quando uma tarefa depende de outra ainda não aprovada hoje",
"child.blocked_by_dependency": "Faça primeiro {chores}",
"child.editor.due_days_dim": "Atenuar — mostrar em cinzento, sem interação",
"child.editor.due_days_helper": "Aplica-se a tarefas com due_days definido quando hoje não está agendado",
"child.editor.due_days_hide": "Ocultar — não mostrar",
@@ -241,7 +248,7 @@
"child.editor.recurrence_dim": "Atenuar — mostrar em cinzento, sem interação",
"child.editor.recurrence_helper": "O que mostrar quando uma tarefa recorrente foi concluída e a janela ainda não reiniciou",
"child.editor.recurrence_hide": "Ocultar — não mostrar até estar disponível novamente",
"child.editor.recurrence_show": "Mostrar — mostrar normalmente",
"child.editor.recurrence_show": "Mostrar — mostrar normalmente (continua sem toque)",
"child.editor.recurring_when_completed": "Tarefas Recorrentes — Quando Concluídas",
"child.editor.select_child": "Selecionar uma criança...",
"child.editor.show_countdown": "Mostrar contagem regressiva de reinício junto a \"Tarefas de Hoje\"",
@@ -420,6 +427,7 @@
"overview.pending_count": "{count} pendente",
"panel.activity_approve": "Aprovar",
"panel.activity_approve_all": "Aprovar tudo",
"panel.activity_approve_all_busy": "Aprovando…",
"panel.activity_approve_all_confirm": "Aprovar {count} tarefas pendentes?",
"panel.activity_approve_all_done": "{count} tarefas aprovadas",
"panel.activity_audit_log": "Registro de auditoria",
@@ -1738,5 +1746,6 @@
"panel.chore_image_bad_type": "Escolha uma imagem JPEG, PNG ou WebP.",
"child.editor.pre_reader": "Modo só imagens (crianças pequenas)",
"child.editor.pre_reader_labels": "Mostrar nomes no modo imagens",
"child.editor.pre_reader_points": "Mostrar pontos em vez de estrelas no modo imagens",
"common.design.accessible": "Acessível"
}
+10 -1
View File
@@ -69,6 +69,7 @@
"approvals.all_caught_up": "Está tudo em dia!",
"approvals.approve": "Aprovar",
"approvals.approve_all": "Aprovar tudo",
"approvals.approve_all_busy": "A aprovar…",
"approvals.approve_all_confirm": "Aprovar {count} tarefas pendentes?",
"approvals.card_description": "Cartão para gerir aprovações de tarefas pendentes no TaskMate",
"approvals.card_name": "Aprovações TaskMate",
@@ -229,6 +230,12 @@
"child.editor.child": "Criança",
"child.editor.child_helper": "Para qual criança é este cartão?",
"child.editor.chores_not_due_today": "Tarefas Sem Prazo Hoje",
"child.editor.dependency_mode": "Tarefas à espera de outra",
"child.editor.dependency_hide": "Ocultar — não mostrar até desbloquear",
"child.editor.dependency_dim": "Atenuar — mostrar em cinzento, sem interação",
"child.editor.dependency_show": "Mostrar — mostrar normalmente (continua sem toque)",
"child.editor.dependency_helper": "O que mostrar quando uma tarefa depende de outra ainda não aprovada hoje",
"child.blocked_by_dependency": "Faz primeiro {chores}",
"child.editor.due_days_dim": "Atenuar — mostrar em cinzento, sem interação",
"child.editor.due_days_helper": "Aplica-se a tarefas com due_days definido quando hoje não está agendado",
"child.editor.due_days_hide": "Ocultar — não mostrar",
@@ -241,7 +248,7 @@
"child.editor.recurrence_dim": "Atenuar — mostrar em cinzento, sem interação",
"child.editor.recurrence_helper": "O que mostrar quando uma tarefa recorrente foi concluída e a janela ainda não reiniciou",
"child.editor.recurrence_hide": "Ocultar — não mostrar até estar disponível novamente",
"child.editor.recurrence_show": "Mostrar — mostrar normalmente",
"child.editor.recurrence_show": "Mostrar — mostrar normalmente (continua sem toque)",
"child.editor.recurring_when_completed": "Tarefas Recorrentes — Quando Concluídas",
"child.editor.select_child": "Selecionar uma criança...",
"child.editor.show_countdown": "Mostrar contagem regressiva de reinício junto a \"Tarefas de Hoje\"",
@@ -425,6 +432,7 @@
"overview.pending_count": "{count} pendente",
"panel.activity_approve": "Aprovar",
"panel.activity_approve_all": "Aprovar tudo",
"panel.activity_approve_all_busy": "A aprovar…",
"panel.activity_approve_all_confirm": "Aprovar {count} tarefas pendentes?",
"panel.activity_approve_all_done": "{count} tarefas aprovadas",
"panel.activity_audit_log": "Registo de auditoria",
@@ -1738,5 +1746,6 @@
"panel.chore_image_bad_type": "Escolha uma imagem JPEG, PNG ou WebP.",
"child.editor.pre_reader": "Modo só imagens (crianças pequenas)",
"child.editor.pre_reader_labels": "Mostrar nomes no modo imagens",
"child.editor.pre_reader_points": "Mostrar pontos em vez de estrelas no modo imagens",
"common.design.accessible": "Acessível"
}
@@ -106,6 +106,9 @@ class TaskMateApprovalsCard extends LitElement {
}
.approve-all-btn {
display: inline-flex;
align-items: center;
gap: 6px;
background: rgba(255, 255, 255, 0.2);
color: white;
border: 1px solid rgba(255, 255, 255, 0.45);
@@ -118,6 +121,18 @@ class TaskMateApprovalsCard extends LitElement {
}
.approve-all-btn:hover { background: rgba(255, 255, 255, 0.3); }
.approve-all-btn:disabled { opacity: 0.6; cursor: default; }
/* Reuses the spin keyframes already defined for the loading spinner. */
.btn-spinner {
width: 12px; height: 12px;
border: 2px solid rgba(255, 255, 255, 0.45);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.7s linear infinite;
flex-shrink: 0;
}
@media (prefers-reduced-motion: reduce) {
.btn-spinner { animation: none; opacity: 0.7; }
}
.card-content { padding: 16px; }
@@ -537,7 +552,9 @@ class TaskMateApprovalsCard extends LitElement {
<div class="header-right">
${filteredCompletions.length > 0
? html`<button class="approve-all-btn" ?disabled="${this._loading['__all__']}"
@click="${() => this._handleApproveAll(filteredCompletions)}">${this._t('approvals.approve_all')}</button>`
@click="${() => this._handleApproveAll(filteredCompletions)}">${this._loading['__all__']
? html`<span class="btn-spinner"></span>${this._t('approvals.approve_all_busy')}`
: this._t('approvals.approve_all')}</button>`
: ""}
${totalPending > 0 ? html`<span class="pending-count">${totalPending}</span>` : ""}
</div>
@@ -724,7 +741,9 @@ class TaskMateApprovalsCard extends LitElement {
<span class="tt">${title}</span>
${completions.length > 0
? html`<button class="approve-all-btn" ?disabled="${this._loading['__all__']}"
@click="${() => this._handleApproveAll(completions)}">${this._t('approvals.approve_all')}</button>`
@click="${() => this._handleApproveAll(completions)}">${this._loading['__all__']
? html`<span class="btn-spinner"></span>${this._t('approvals.approve_all_busy')}`
: this._t('approvals.approve_all')}</button>`
: ""}
${count > 0 ? html`<span class="cnt">${count}</span>` : ""}
</div>`;
@@ -785,7 +804,7 @@ class TaskMateApprovalsCard extends LitElement {
<button class="btn bad" style="flex:1" ?disabled="${isLoading}" @click="${reject}">👎 ${this._t('approvals.reject')}</button>`;
}
_apPhotoDesigned(it, cls, fallback) {
_apPhotoDesigned(it, cls) {
const photoUrl = tmSafePhotoUrl(it.photo);
if (it.kind !== "completion" || !photoUrl) {
return it.kind === "claim" && it.icon
@@ -1154,8 +1173,6 @@ class TaskMateApprovalsCard extends LitElement {
}
_getDayLabel(date) {
const timezone = this._getTimezone();
// Get today's date parts in HA timezone
const now = new Date();
const todayParts = this._getDatePartsInTimezone(now);
@@ -1252,7 +1269,7 @@ class TaskMateApprovalsCard extends LitElement {
_renderApprovals(groupedByDay) {
return html`
${groupedByDay.map(
([dayKey, dayGroup]) => html`
([, dayGroup]) => html`
<div class="day-group">
<div class="day-header">${dayGroup.label}</div>
${this._renderTimeCategories(dayGroup.timeCategories)}
@@ -30,7 +30,11 @@
// Full pending-approvals lists (chore_completions / reward_claims). These
// are NOT filtered to today, so approval cards pointed at the overview
// entity can still surface a completion left pending from a previous day.
"sensor.pending_approvals",
// Named "Pending Approvals", but it carries no _attr_has_entity_name, so
// the TaskMate device name still prefixes its entity id. This entry was
// missing that prefix and so matched nothing at all, silently dropping
// chore_completions and mandatory_misses from every merge (#798).
"sensor.taskmate_pending_approvals",
];
function mergedAttributes(hass, primaryEntityId) {
@@ -697,7 +697,7 @@ class TaskMateCalendarCard extends LitElement {
style="${dim ? "" : `color:var(--tmd-${tok});background:color-mix(in srgb,var(--tmd-${tok}) 16%,transparent);border-color:transparent`}">${glyph[item.state] || glyph.due} ${item.chore.name}</span>`;
}
_calPlayroom(blocks, pointsIcon) {
_calPlayroom(blocks, _pointsIcon) {
const glyph = { approved: "✓", pending: "◌", due: "—" };
return html`
<div class="grid cal-grid">
@@ -713,7 +713,7 @@ class TaskMateCalendarCard extends LitElement {
</div>`;
}
_calConsole(blocks, pointsIcon) {
_calConsole(blocks, _pointsIcon) {
const glyph = { approved: "▮", pending: "▯", due: "▢" };
return html`
<div class="grid cal-grid-cn">
@@ -733,7 +733,7 @@ class TaskMateCalendarCard extends LitElement {
</div>`;
}
_calCleanpro(blocks, pointsIcon) {
_calCleanpro(blocks, _pointsIcon) {
const glyph = { approved: "●", pending: "○", due: "—" };
return html`
<div class="grid cal-grid-cp">
@@ -1144,6 +1144,23 @@ class TaskMateChildCard extends LitElement {
.recurrence-label ha-icon { --mdc-icon-size: 12px; }
/* Dependency-blocked (#793). Under dim the row stays readable so a child
can see what unlocks next; show leaves it looking normal, but either
way the tap is refused in the handler. */
.chore-card.dependency-blocked {
opacity: 0.5;
filter: grayscale(0.5);
}
.dependency-label {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 0.72rem;
font-weight: 600;
color: var(--secondary-text-color);
}
.dependency-label ha-icon { --mdc-icon-size: 12px; }
/* Recurring chore not yet available — dimmed/greyed */
.chore-card.recurrence-unavailable {
@@ -1272,6 +1289,11 @@ class TaskMateChildCard extends LitElement {
.pre-tile-icon ha-icon { --mdc-icon-size: 64px; color: var(--primary-color); }
.pre-tile-stars { display: flex; gap: 2px; }
.pre-tile-stars ha-icon { --mdc-icon-size: 18px; color: #f1c40f; }
.pre-tile-points {
display: flex; align-items: center; gap: 4px;
font-size: 1.15rem; font-weight: 700; color: var(--primary-text-color);
}
.pre-tile-points ha-icon { --mdc-icon-size: 20px; color: #f1c40f; }
.pre-tile-label {
font-size: 0.85rem; font-weight: 700; text-align: center;
color: var(--secondary-text-color); line-height: 1.2;
@@ -1282,6 +1304,7 @@ class TaskMateChildCard extends LitElement {
}
.pre-tile.done .pre-tile-icon ha-icon { opacity: 0.35; }
.pre-tile.done .pre-tile-stars { opacity: 0.35; }
.pre-tile.done .pre-tile-points { opacity: 0.35; }
.pre-tile-tick {
position: absolute; inset: 0;
display: grid; place-items: center;
@@ -2418,8 +2441,8 @@ class TaskMateChildCard extends LitElement {
${this._renderRoulette(child, childChores, pointsIcon)}
${this.config.pre_reader === true ? html`
<div class="pre-reader-grid">
${childChores.map((chore, index) =>
this._renderPreReaderTile(chore, child, todaysCompletions, index))}
${childChores.map((chore) =>
this._renderPreReaderTile(chore, child, pointsIcon, todaysCompletions))}
</div>
` : childChores.map((chore, index) => html`
${chore.task_type === 'timed'
@@ -2626,13 +2649,21 @@ class TaskMateChildCard extends LitElement {
const dueDaysMode = this.config.due_days_mode || "hide";
const elapsedTimeMode = this.config.elapsed_time_mode || "dim";
const dependencyMode = this.config.dependency_mode || "hide";
const recurrenceDoneMode = this.config.recurrence_done_mode || "dim";
// Annotate each chore with done-state + handlers, preserving the classic path's logic.
const rows = childChores.map((chore, i) => {
const { done, completions } = this._isChoreDone(chore, child, todaysCompletions);
const loading = !!this._loading[chore.id];
// Blocked chores only reach here under dim/show — hide is filtered
// upstream. Either way the tap must be refused (#793).
const blocked = !!chore._isDependencyBlocked && !done;
// Recurring chore still inside its cooldown. Excluded when done so the
// undo chip stays live, exactly as on the classic row.
const recLocked = !!chore._isRecurrenceLocked && !done;
const onAct = () => {
if (loading) return;
if (loading || blocked || recLocked) return;
if (done) this._handleUndo(chore, child, completions);
else this._handleComplete(chore, child);
};
@@ -2642,10 +2673,12 @@ class TaskMateChildCard extends LitElement {
const dimmed = !done && (
(notDueToday && dueDaysMode === "dim") ||
(chore._isTimeElapsed && elapsedTimeMode === "dim") ||
(blocked && dependencyMode === "dim") ||
(chore._isRecurrenceLocked && recurrenceDoneMode === "dim") ||
chore._isLockedPreview === true
);
return {
chore, child, done, loading, onAct, index: i, dimmed,
chore, child, done, loading, onAct, index: i, dimmed, blocked, recLocked,
tone: this._designTone(i),
glyph: this._choreGlyph(chore),
points: chore.effective_points ?? chore.points,
@@ -2683,15 +2716,15 @@ class TaskMateChildCard extends LitElement {
const body = this.config.pre_reader === true
? html`
<div class="pre-reader-grid">
${childChores.map((chore, index) =>
this._renderPreReaderTile(chore, child, todaysCompletions, index))}
${childChores.map((chore) =>
this._renderPreReaderTile(chore, child, pointsIcon, todaysCompletions))}
</div>`
: design === "playroom" ? this._designPlayroom(child, rows, remaining, tone) :
design === "console" ? this._designConsole(child, rows, remaining, tone) :
this._designCleanpro(child, rows, remaining, tone);
return html`<ha-card class="tmd" style="--hd:${hd}">
${this._designHeaderFull(child, design, remaining, rows.length, tone, pendingPoints, pointsIcon)}
${this._designHeaderFull(child, design, remaining, rows.length, tone, pendingPoints)}
<div class="tmd-bd">
${attrs.vacation_active ? html`
<div class="tmd-vacation">
@@ -2742,7 +2775,7 @@ class TaskMateChildCard extends LitElement {
}
/** Designed header: avatar/title, remaining pill, pending-points chip. */
_designHeaderFull(child, design, remaining, total, tone, pendingPoints, pointsIcon) {
_designHeaderFull(child, design, remaining, total, tone, pendingPoints) {
const title = design === "console"
? `${(child.name || "").toUpperCase()} · ${this._t("child.todays_chores").toUpperCase()}`
: this._t("points_display.single_title", { name: child.name });
@@ -2775,11 +2808,21 @@ class TaskMateChildCard extends LitElement {
/** Shared meta tags (mandatory / photo / description) for a designed chore row. */
_designChoreMeta(r) {
const showDesc = this.config.show_description === true && r.chore.description;
// A blocked chore says what unlocks it here too — the classic row carries
// the same label, and a dimmed row with no reason is just confusing (#793).
const depNames = r.blocked ? (r.chore._dependencyNames || []) : [];
// Same reason label the classic row carries, so a dimmed designed row is
// not just mysteriously grey (#803).
const recLabel = r.recLocked
? (r.chore.recurrence ? r.chore.recurrence.replace(/_/g, " ") : this._t("child.recurring"))
: "";
return html`
${r.mandatory || r.photo ? html`
${r.mandatory || r.photo || depNames.length || recLabel ? html`
<div class="tmd-meta">
${r.mandatory ? html`<span class="tmd-tag mandatory">⚠ ${this._t("child.mandatory")}</span>` : ""}
${r.photo ? html`<span class="tmd-tag photo">📷 ${this._t("child.photo_needed")}</span>` : ""}
${depNames.length ? html`<span class="tmd-tag">🔒 ${this._t("child.blocked_by_dependency", { chores: depNames.join(", ") })}</span>` : ""}
${recLabel ? html`<span class="tmd-tag">🕒 ${recLabel}</span>` : ""}
</div>` : ""}
${showDesc ? html`<div class="tmd-desc">${r.chore.description}</div>` : ""}`;
}
@@ -2795,7 +2838,7 @@ class TaskMateChildCard extends LitElement {
_designDoneBtn(r, label, cls) {
return html`<button class="btn ${cls || ""}"
?disabled=${r.loading}
?disabled=${r.loading || r.blocked || r.recLocked}
@click=${(e) => { e.stopPropagation(); r.onAct(); }}>${label}</button>`;
}
@@ -2820,7 +2863,7 @@ class TaskMateChildCard extends LitElement {
return this._renderBonusSubtasks(r.chore, r.child, r.pointsIcon, r.todaysCompletions);
}
_designPlayroom(child, rows, remaining, tone) {
_designPlayroom(child, rows, _remaining, _tone) {
if (rows.length === 0) return html`<div class="tmd-empty">${this._t("child.all_done")}</div>`;
return html`<div class="tmd-chores">
${rows.map(r => r.timed ? this._designTimed(r) : html`
@@ -2840,7 +2883,7 @@ class TaskMateChildCard extends LitElement {
</div>`;
}
_designConsole(child, rows, remaining, tone) {
_designConsole(child, rows, _remaining, _tone) {
if (rows.length === 0) return html`<div class="tmd-empty">${this._t("child.all_done")}</div>`;
return html`<div class="grid">
${rows.map(r => r.timed ? this._designTimed(r) : html`
@@ -2862,7 +2905,7 @@ class TaskMateChildCard extends LitElement {
</div>`;
}
_designCleanpro(child, rows, remaining, tone) {
_designCleanpro(child, rows, _remaining, _tone) {
if (rows.length === 0) return html`<div class="tmd-empty">${this._t("child.all_done")}</div>`;
return html`<div class="tmd-checklist">
${rows.map(r => r.timed ? this._designTimed(r) : html`
@@ -2951,11 +2994,8 @@ class TaskMateChildCard extends LitElement {
_filterAndSortChores(chores, child) {
const childId = String(child.id || "");
const childName = child.name;
const choreOrder = child.chore_order || [];
// Debug logging to diagnose assignment filtering issues
// Get today's day of week from sensor (set by backend) or compute client-side
const entity = this.hass?.states?.[this.config.entity];
const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) || entity?.attributes || {};
@@ -2965,6 +3005,18 @@ class TaskMateChildCard extends LitElement {
const dueDaysMode = this.config.due_days_mode || 'hide';
const showDueDaysOnly = this.config.show_due_days_only !== false;
const elapsedTimeMode = this.config.elapsed_time_mode || 'dim';
// Hide by default: the backend simply reports a dependency-blocked chore
// as unavailable, so hiding is what matches it (#793).
const dependencyMode = this.config.dependency_mode || 'hide';
const recurrenceDoneMode = this.config.recurrence_done_mode || 'dim';
const allCompletionsToday = attrs.todays_completions || [];
// Availability comes from the backend's own matrix (#803). The card cannot
// work a recurrence window out for itself: last_completed is not exposed,
// `recurrence` is omitted from the payload when it is the default weekly,
// and the maths is calendar-month aware. A missing matrix (older backend,
// or a sensor that hasn't published yet) has to read as available, or the
// card would blank itself.
const availability = attrs.chore_availability || {};
// First, filter chores for this child and time category
const filteredChores = chores.filter(chore => {
@@ -3112,6 +3164,42 @@ class TaskMateChildCard extends LitElement {
chore._isDueToday = isDueToday;
chore._hasDueDays = hasDueDays;
// Chore dependencies (#793). Mirrors is_chore_available_for_child():
// a prerequisite counts as satisfied only when this same child has an
// approved, non-bonus completion of it today. todays_completions is
// already scoped to today, so the date check is implicit. A looser rule
// here would unlock a chore the backend then refuses to complete.
const dependsOn = Array.isArray(chore.depends_on) ? chore.depends_on : [];
chore._isDependencyBlocked = dependsOn.length > 0 && !dependsOn.every(
(depId) => allCompletionsToday.some(
(comp) => comp.chore_id === depId
&& String(comp.child_id) === childId
&& comp.approved
&& !comp.bonus_subtask_id
)
);
chore._dependencyNames = chore._isDependencyBlocked
? dependsOn
.map((depId) => (chores.find((c) => c.id === depId) || {}).name)
.filter(Boolean)
: [];
if (chore._isDependencyBlocked && dependencyMode === 'hide') return false;
// Recurring chores in their cooldown window (#803). Both of these are
// read by the render path, which until now found them undefined.
chore._isRecurring = (chore.schedule_mode || 'specific_days') === 'recurring';
const availableNow = (availability[chore.id] || {})[childId] !== false;
// Completing a recurring chore makes it unavailable at once, so hiding on
// availability alone would vanish it the instant it is ticked — no done
// state, no way to undo. The mode is about the days *after* that.
const completedToday = allCompletionsToday.some(
(comp) => comp.chore_id === chore.id
&& (String(comp.child_id) === childId || comp.child_id === '__parent__')
&& !comp.bonus_subtask_id
);
chore._isRecurrenceLocked = chore._isRecurring && !availableNow && !completedToday;
if (chore._isRecurrenceLocked && recurrenceDoneMode === 'hide') return false;
// Mark whether this chore's time period has elapsed (grace-aware).
chore._isTimeElapsed = this._isTimePeriodElapsed(chore);
@@ -3503,8 +3591,12 @@ class TaskMateChildCard extends LitElement {
* Pre-reader mode (#683): a picture-only tile for children who can't read
* yet. No chore name, no numbers — a big icon, a row of stars for the
* points, and a huge tick when it's done.
*
* `pre_reader_points` (#795) swaps the stars for the value itself, for
* families whose children can already read a number and who found the
* rounded star count contradicted the balance on the rest of the dashboard.
*/
_renderPreReaderTile(chore, child, todaysCompletions = [], choreIndex = 0) {
_renderPreReaderTile(chore, child, pointsIcon, todaysCompletions = []) {
const dailyLimit = chore.daily_limit || 1;
// Counted the same way the standard row does: pending completions hold a
// place too, and a parent completing on behalf lands under "__parent__".
@@ -3515,11 +3607,15 @@ class TaskMateChildCard extends LitElement {
);
const isDone = childCompletionsToday.length >= dailyLimit;
const isLoading = this._loading[chore.id];
const available = chore._isAvailableForChild !== false && !chore._isLockedPreview;
const available = chore._isAvailableForChild !== false && !chore._isLockedPreview
&& !chore._isDependencyBlocked;
// Stars, not digits: a 4-year-old can count pictures, not read "+3".
// The count is deliberately lossy — it rounds and clamps to 1..5 — which
// is why showing the number instead is opt-in rather than a second row.
const points = chore.effective_points ?? chore.points ?? 0;
const stars = Math.max(1, Math.min(5, Math.round(points / 2) || 1));
const showPoints = this.config.pre_reader_points === true;
const v = window.__taskmate_chore_visual(chore);
const icon = (v.kind === "icon" ? v.icon : "")
@@ -3542,9 +3638,15 @@ class TaskMateChildCard extends LitElement {
: html`<ha-icon icon="${icon}"></ha-icon>`}
</div>
${isDone ? html`<div class="pre-tile-tick"><ha-icon icon="mdi:check-bold"></ha-icon></div>` : ''}
<div class="pre-tile-stars">
${Array.from({ length: stars }, () => html`<ha-icon icon="mdi:star"></ha-icon>`)}
</div>
${showPoints
? html`
<div class="pre-tile-points">
<ha-icon icon="${pointsIcon}"></ha-icon>+${points}
</div>`
: html`
<div class="pre-tile-stars">
${Array.from({ length: stars }, () => html`<ha-icon icon="mdi:star"></ha-icon>`)}
</div>`}
${this.config.pre_reader_labels === true
? html`<div class="pre-tile-label">${chore.name}</div>` : ''}
</button>
@@ -3592,8 +3694,6 @@ class TaskMateChildCard extends LitElement {
// This is defensive - we'd rather show "completed" incorrectly than allow double-completions
// The optimistic completion will be cleaned up once we verify the server state reflects it
if (hasOptimisticCompletion) {
// Calculate how many optimistic completions we've tracked that aren't yet in the data
const optimisticCount = optimisticData.count || 1;
// Get timestamps from actual completions for this chore/child today
const actualTimestamps = childCompletionsToday.map(comp =>
@@ -3616,8 +3716,6 @@ class TaskMateChildCard extends LitElement {
const isCompletedForToday = completionsToday >= dailyLimit;
// Check if the most recent completion is pending approval
const hasPendingCompletion = childCompletionsToday.some((comp) => !comp.approved) || hasOptimisticCompletion;
// Chore number (1-indexed for display) and color class (cycle through 8 colors)
const choreNumber = choreIndex + 1;
@@ -3626,11 +3724,21 @@ class TaskMateChildCard extends LitElement {
// Click handler for the entire row
const notDueToday = chore._hasDueDays && !chore._isDueToday && this.config.due_days_mode === 'dim';
const recurrenceDoneMode = this.config.recurrence_done_mode || 'dim';
const notAvailableRecurrence = chore._isRecurring && !chore._isAvailableForChild && recurrenceDoneMode === 'dim';
// The tap is refused whenever the chore is locked: async_complete_chore
// no-ops a recurring chore inside its window, so a clickable row under
// `show` would just swallow the tap (#805). Only the dim STYLING is
// mode-gated. Same split dependency_mode uses.
const recurrenceLocked = !!chore._isRecurrenceLocked && !isCompletedForToday;
const notAvailableRecurrence = recurrenceLocked && recurrenceDoneMode === 'dim';
// Elapsed: only dim incomplete chores — completed ones keep their green "done" style
const elapsedTimeMode = this.config.elapsed_time_mode || 'dim';
const timeElapsed = chore._isTimeElapsed && !isCompletedForToday && elapsedTimeMode === 'dim';
const isLockedPreview = !!chore._isLockedPreview && !isCompletedForToday;
// Under dim/show the blocked chore is still on screen, so the tap has to be
// refused here — the backend would reject the completion anyway (#793).
// Only `dim` greys it out; `show` is meant to look untouched.
const depBlocked = !!chore._isDependencyBlocked && !isCompletedForToday;
const depDimmed = depBlocked && (this.config.dependency_mode || 'hide') === 'dim';
const periodStartHour = isLockedPreview
? (this._getPeriodHours(chore.time_category)?.[0] ?? null)
: null;
@@ -3640,8 +3748,9 @@ class TaskMateChildCard extends LitElement {
const handleRowClick = () => {
if (isLoading) return;
if (notDueToday) return; // Dim mode — not interactive
if (notAvailableRecurrence) return; // Recurrence window not open — not interactive
if (recurrenceLocked) return; // Recurrence window not open — not interactive
if (isLockedPreview) return; // Next-period preview — not yet claimable
if (depBlocked) return; // Prerequisite chore not approved yet
if (timeElapsed) return; // Time period passed — not interactive
if (isCompletedForToday) {
this._handleUndo(chore, child, childCompletionsToday);
@@ -3649,7 +3758,7 @@ class TaskMateChildCard extends LitElement {
this._handleComplete(chore, child);
}
};
const isInteractive = !(isLoading || notDueToday || notAvailableRecurrence || isLockedPreview || timeElapsed);
const isInteractive = !(isLoading || notDueToday || recurrenceLocked || isLockedPreview || depBlocked || timeElapsed);
const handleRowKeyDown = (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
@@ -3659,6 +3768,8 @@ class TaskMateChildCard extends LitElement {
const titleText = isLockedPreview
? (lockedUntilLabel || this._t('child.chore_locked_until_generic'))
: depBlocked
? this._t('child.blocked_by_dependency', { chores: (chore._dependencyNames || []).join(', ') })
: notDueToday
? this._t('child.not_scheduled_for_today')
: timeElapsed
@@ -3669,7 +3780,7 @@ class TaskMateChildCard extends LitElement {
return html`
<div
class="chore-card ${chore.mandatory ? "mandatory" : ""} ${isLoading ? "loading" : ""} ${isCelebrating ? "celebrating" : ""} ${isCompletedForToday ? "completed" : ""} ${notDueToday ? "not-due-today" : ""} ${notAvailableRecurrence ? "recurrence-unavailable" : ""} ${timeElapsed ? "time-elapsed" : ""} ${isLockedPreview ? "chore-locked" : ""}"
class="chore-card ${chore.mandatory ? "mandatory" : ""} ${isLoading ? "loading" : ""} ${isCelebrating ? "celebrating" : ""} ${isCompletedForToday ? "completed" : ""} ${notDueToday ? "not-due-today" : ""} ${notAvailableRecurrence ? "recurrence-unavailable" : ""} ${timeElapsed ? "time-elapsed" : ""} ${isLockedPreview ? "chore-locked" : ""} ${depDimmed ? "dependency-blocked" : ""}"
role="button"
tabindex="${isInteractive ? '0' : '-1'}"
aria-disabled="${isInteractive ? 'false' : 'true'}"
@@ -3689,7 +3800,13 @@ class TaskMateChildCard extends LitElement {
${this.config.show_description && chore.description ? html`
<div class="chore-description">${chore.description}</div>
` : ''}
${chore._isRecurring && !chore._isAvailableForChild ? html`
${depBlocked && (chore._dependencyNames || []).length ? html`
<div class="dependency-label">
<ha-icon icon="mdi:lock-outline"></ha-icon>
${this._t('child.blocked_by_dependency', { chores: chore._dependencyNames.join(', ') })}
</div>
` : ''}
${chore._isRecurrenceLocked ? html`
<div class="recurrence-label">
<ha-icon icon="mdi:clock-outline"></ha-icon>
${chore.recurrence ? chore.recurrence.replace(/_/g, ' ') : this._t('child.recurring')}
@@ -3728,6 +3845,9 @@ class TaskMateChildCard extends LitElement {
const colorClass = `color-${choreIndex % 8}`;
const choreNumber = choreIndex + 1;
// A blocked timed chore must not be startable under dim/show (#793).
// Pause/stop stay live so a session already running can still be ended.
const depBlocked = !!chore._isDependencyBlocked;
// Calculate elapsed seconds
let elapsed = session ? (session.total_seconds_today || 0) : 0;
@@ -3759,6 +3879,7 @@ class TaskMateChildCard extends LitElement {
const nearCap = maxMin > 0 && capPct >= 85;
const handleStart = (e) => {
if (depBlocked) { e.stopPropagation(); return; }
e.stopPropagation();
if (isLoading) return;
this._loading = { ...this._loading, [`timed_${chore.id}`]: true };
@@ -3829,7 +3950,7 @@ class TaskMateChildCard extends LitElement {
<div class="timer-actions">
${state === 'idle' ? html`
<button class="timer-btn start" @click="${handleStart}" ?disabled="${isLoading}">
<button class="timer-btn start" @click="${handleStart}" ?disabled="${isLoading || depBlocked}">
<span class="btn-icon">&#9654;</span>
${this._t('child.start') || 'START'}
</button>
@@ -3845,7 +3966,7 @@ class TaskMateChildCard extends LitElement {
</button>
` : ''}
${state === 'paused' ? html`
<button class="timer-btn resume" @click="${handleStart}" ?disabled="${isLoading}">
<button class="timer-btn resume" @click="${handleStart}" ?disabled="${isLoading || depBlocked}">
<span class="btn-icon">&#9654;</span>
${this._t('child.resume') || 'RESUME'}
</button>
@@ -4708,6 +4829,19 @@ class TaskMateChildCardEditor extends LitElement {
},
},
},
{
name: 'dependency_mode',
selector: {
select: {
options: [
{ value: 'hide', label: this._t('child.editor.dependency_hide') },
{ value: 'dim', label: this._t('child.editor.dependency_dim') },
{ value: 'show', label: this._t('child.editor.dependency_show') },
],
mode: 'dropdown',
},
},
},
{
name: 'elapsed_time_mode',
selector: {
@@ -4725,6 +4859,7 @@ class TaskMateChildCardEditor extends LitElement {
{ name: 'show_description', selector: { boolean: {} } },
{ name: 'pre_reader', selector: { boolean: {} } },
{ name: 'pre_reader_labels', selector: { boolean: {} } },
{ name: 'pre_reader_points', selector: { boolean: {} } },
{ name: 'debug', selector: { boolean: {} } },
];
}
@@ -4737,11 +4872,13 @@ class TaskMateChildCardEditor extends LitElement {
card_design: this._t('common.design.field_label'),
due_days_mode: this._t('child.editor.chores_not_due_today'),
recurrence_done_mode: this._t('child.editor.recurring_when_completed'),
dependency_mode: this._t('child.editor.dependency_mode'),
elapsed_time_mode: this._t('child.editor.missed_time_chores'),
show_countdown: this._t('child.editor.show_countdown'),
show_description: this._t('child.editor.show_description'),
pre_reader: this._t('child.editor.pre_reader'),
pre_reader_labels: this._t('child.editor.pre_reader_labels'),
pre_reader_points: this._t('child.editor.pre_reader_points'),
debug: this._t('child.editor.show_debug'),
};
return labels[entry.name] ?? entry.name;
@@ -4754,6 +4891,7 @@ class TaskMateChildCardEditor extends LitElement {
time_category: this._t('child.editor.time_category_helper'),
due_days_mode: this._t('child.editor.due_days_helper'),
recurrence_done_mode: this._t('child.editor.recurrence_helper'),
dependency_mode: this._t('child.editor.dependency_helper'),
elapsed_time_mode: this._t('child.editor.elapsed_helper'),
};
return helpers[entry.name] ?? '';
@@ -4769,11 +4907,13 @@ class TaskMateChildCardEditor extends LitElement {
card_design: this.config.card_design || 'global',
due_days_mode: this.config.due_days_mode || 'hide',
recurrence_done_mode: this.config.recurrence_done_mode || 'dim',
dependency_mode: this.config.dependency_mode || 'hide',
elapsed_time_mode: this.config.elapsed_time_mode || 'dim',
show_countdown: this.config.show_countdown !== false,
show_description: this.config.show_description === true,
pre_reader: this.config.pre_reader === true,
pre_reader_labels: this.config.pre_reader_labels === true,
pre_reader_points: this.config.pre_reader_points === true,
debug: this.config.debug === true,
};
@@ -276,7 +276,6 @@ class TaskMateGraphCard extends LitElement {
const tz = this.hass?.config?.time_zone || Intl.DateTimeFormat().resolvedOptions().timeZone;
const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) || entity.attributes || {};
let children = attrs.children || [];
const pointsIcon = attrs.points_icon || "mdi:star";
const pointsName = attrs.points_name || this._t('common.points');
const days = Math.max(3, Math.min(90, this.config.days || 14));
@@ -919,7 +919,6 @@ export function createIncentiveCard(P) {
_renderDesignedRow(b, design) {
const isEditing = this._editingBonus?.id === b.id;
const tone = this._designTone(0);
const accent = P.designAccent;
const pv = P.dpts(b.points);
@@ -348,7 +348,7 @@ class TaskMateLeaderboardCard extends LitElement {
// Solo mode
if (children.length === 1) {
return this._renderSolo(sorted[0], weeklyPoints, pointsIcon, pointsName, periodLabel);
return this._renderSolo(sorted[0], weeklyPoints, pointsIcon, pointsName);
}
return html`
@@ -464,8 +464,7 @@ class TaskMateLeaderboardCard extends LitElement {
`;
}
_renderSolo(child, weeklyPoints, pointsIcon, pointsName, periodLabel) {
const entity = this.hass.states[this.config.entity];
_renderSolo(child, weeklyPoints, pointsIcon, pointsName) {
const totalChores = child.total_chores_completed || 0;
const bestStreak = child.best_streak || 0;
const weekly = weeklyPoints[child.id] || 0;
@@ -631,7 +631,7 @@ class TaskMateOverviewCard extends LitElement {
</div>`;
}
_ovKid(design, k, isParent, pointsIcon) {
_ovKid(design, k, isParent, _pointsIcon) {
const cls = design === "console" ? "cn" : design === "cleanpro" ? "cp" : "";
const nameUpper = design === "console" ? `${k.child.name.toUpperCase()} · ${k.pct}%` : k.child.name;
const isOpen = !!this._expanded[k.child.id];
@@ -652,7 +652,7 @@ class TaskMateOverviewCard extends LitElement {
}
/** Admin "complete on behalf" panel — same handler as the classic tile. */
_ovBehalf(k, pointsIcon) {
_ovBehalf(k, _pointsIcon) {
return html`
<div class="ov-behalf" @click="${(e) => e.stopPropagation()}">
<div class="ov-behalf-hdr">${this._t('common.complete_on_behalf_heading')} · ${k.child.name}</div>
@@ -693,7 +693,7 @@ class TaskMateOverviewCard extends LitElement {
</div>`;
}
_renderChildTile(child, chores, completions, pointsIcon, pointsName) {
_renderChildTile(child, chores, completions, pointsIcon, _pointsName) {
// Avatar now included directly in children array from the overview sensor
const avatar = child.avatar || "mdi:account-circle";
@@ -18,24 +18,6 @@ const PANEL_VERSION = (() => {
return el ? sanitize(new URLSearchParams(el.src.split("?")[1] || "").get("v")) : "?";
})();
const TABS = [
{ id: "children", lk: "panel.tab_children" },
{ id: "activity", lk: "panel.tab_activity" },
{ id: "insights", lk: "panel.tab_insights" },
{ id: "chores", lk: "panel.tab_chores" },
{ id: "rewards", lk: "panel.tab_rewards" },
{ id: "penalties", lk: "panel.tab_penalties" },
{ id: "bonuses", lk: "panel.tab_bonuses" },
{ id: "groups", lk: "panel.tab_groups" },
{ id: "quests", lk: "panel.tab_quests" },
{ id: "challenges", lk: "panel.tab_challenges" },
{ id: "badges", lk: "panel.tab_badges" },
{ id: "templates", lk: "panel.tab_templates" },
{ id: "notifications", lk: "panel.tab_notifications" },
{ id: "audit", lk: "panel.tab_audit" },
{ id: "settings", lk: "panel.tab_settings", label: "⚙" },
];
const BADGE_METRICS = [
{ v: "total_points", lk: "badge.metric_total_points" },
{ v: "total_chores", lk: "badge.metric_total_chores" },
@@ -1181,12 +1163,29 @@ class TaskMatePanel extends HTMLElement {
async _doApproveAll() {
const pending = this._state?.pending_completions || [];
if (pending.length === 0) return;
if (this._approvingAll) return;
if (!confirm(this._t("panel.activity_approve_all_confirm", { count: pending.length }))) return;
const { ok, err } = await this._callWS({
type: "taskmate/approve_all_chores",
completion_ids: pending.map(c => c.id),
});
if (!ok) { this._showToast("err", this._t("panel.toast_approve_failed", { error: err })); return; }
// Acknowledge the tap straight away (#799). The batch is fast since #794,
// but not instant, and a button that looks untouched is indistinguishable
// from one that did nothing — which is how #794 came to be reported.
this._approvingAll = true;
this._render();
let result;
try {
result = await this._callWS({
type: "taskmate/approve_all_chores",
completion_ids: pending.map(c => c.id),
});
} finally {
// Cleared the moment the call settles, whatever it did — a button stuck
// spinning is worse than no busy state at all.
this._approvingAll = false;
}
if (!result.ok) {
this._showToast("err", this._t("panel.toast_approve_failed", { error: result.err }));
this._render();
return;
}
await this._fetchState();
this._showToast("ok", this._t("panel.activity_approve_all_done", { count: pending.length }));
}
@@ -1531,6 +1530,10 @@ class TaskMatePanel extends HTMLElement {
image_url: d.image_url || "",
points: Number(d.points) || 0,
assigned_to: d.assigned_to || [],
// The dialog tracks this via toggle-depends, but it was never sent, so a
// dependency could neither be saved nor cleared — _ws_update_chore only
// applies fields present in the message (#793).
depends_on: d.depends_on || [],
requires_approval: !!d.requires_approval,
time_category: d.time_category || "anytime",
claim_allowance_minutes: Math.max(0, Number(d.claim_allowance_minutes) || 0),
@@ -3120,7 +3123,9 @@ class TaskMatePanel extends HTMLElement {
? `<span class="tm-pill tm-pill-warn">${pendingCompletions.length + pendingClaims.length + pendingSwaps.length}</span>`
: `<span class="tm-pill tm-pill-success">${this._t("panel.activity_pending_all_clear")}</span>`}
${pendingCompletions.length > 0
? `<button type="button" class="tm-btn tm-btn-raised tm-btn-sm" data-act="approve-all-chores" style="margin-left:auto">${this._t("panel.activity_approve_all")}</button>`
? `<button type="button" class="tm-btn tm-btn-raised tm-btn-sm" data-act="approve-all-chores" style="margin-left:auto"${this._approvingAll ? " disabled" : ""}>${this._approvingAll
? `<span class="tm-btn-spinner"></span>${this._t("panel.activity_approve_all_busy")}`
: this._t("panel.activity_approve_all")}</button>`
: ""}
</h3>
${pendingCompletions.length === 0 && pendingClaims.length === 0 && pendingSwaps.length === 0 ? `
@@ -3434,7 +3439,7 @@ class TaskMatePanel extends HTMLElement {
const assigned = (q.assigned_to && q.assigned_to.length)
? children.filter(c => q.assigned_to.includes(c.id)).map(c => c.name)
: [this._t("panel.quest_all_children")];
const stepNames = steps.map((sid, i) => {
const stepNames = steps.map((sid) => {
const name = choreById[sid] ? choreById[sid].name : this._t("panel.quest_missing_chore");
return `<li>${this._esc(name)}</li>`;
}).join("");
@@ -6554,6 +6559,24 @@ class TaskMatePanel extends HTMLElement {
background: var(--tm-accent-soft);
border-color: var(--tm-accent);
}
.tm-btn:disabled {
opacity: 0.6;
cursor: default;
pointer-events: none;
}
@keyframes tm-spin { to { transform: rotate(360deg); } }
.tm-btn-spinner {
width: 12px; height: 12px;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
animation: tm-spin 0.7s linear infinite;
flex-shrink: 0;
}
@media (prefers-reduced-motion: reduce) {
/* Still reads as busy — the ring just doesn't rotate. */
.tm-btn-spinner { animation: none; opacity: 0.7; }
}
.tm-btn-raised {
background: var(--tm-accent);
color: var(--text-primary-color, #fff);
@@ -755,7 +755,7 @@ class TaskMateParentDashboardCard extends LitElement {
_dEmpty(msg) { return html`<div class="tmd-empty">${msg}</div>`; }
_dOverview(design, children, chores, completions, attrs, pointsIcon) {
_dOverview(design, children, chores, completions, attrs, _pointsIcon) {
if (!children.length) return this._dEmpty(this._t('dashboard.empty_no_children'));
return html`${children.map((child, i) => {
const tone = this._designTone(i);
@@ -805,7 +805,7 @@ class TaskMateParentDashboardCard extends LitElement {
})}`;
}
_dApprovals(design, pending, children, chores, pointsIcon) {
_dApprovals(design, pending, children, chores, _pointsIcon) {
if (!pending.length) return this._dEmpty(this._t('dashboard.empty_approvals'));
const childMap = {}; children.forEach(c => { childMap[c.id] = c; });
const choreMap = {}; chores.forEach(c => { choreMap[c.id] = c; });
@@ -841,7 +841,7 @@ class TaskMateParentDashboardCard extends LitElement {
})}`;
}
_dClaims(design, claims, pointsIcon) {
_dClaims(design, claims, _pointsIcon) {
if (!claims.length) return this._dEmpty(this._t('dashboard.empty_claims'));
return html`${claims.map((claim, i) => {
const tone = this._designTone(i);
@@ -924,7 +924,7 @@ class TaskMateParentDashboardCard extends LitElement {
})}`;
}
_renderOverview(children, chores, completions, pointsIcon, pointsName) {
_renderOverview(children, chores, completions, pointsIcon, _pointsName) {
if (!children.length) return html`<div class="empty-section"><ha-icon icon="mdi:account-group"></ha-icon><span>${this._t('dashboard.empty_no_children')}</span></div>`;
const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config?.entity)) || {};
@@ -984,7 +984,7 @@ class TaskMatePointsCard extends LitElement {
}
_renderDialog() {
const { child, action, pointsIcon, pointsName } = this._dialog;
const { child, action, pointsName } = this._dialog;
const isAdd = action === "add";
const isLoading = this._loading[`dialog_${child.id}`];
@@ -856,7 +856,6 @@ class TaskMateReorderCard extends LitElement {
const chores = attrs.chores || [];
const childChores = this._getChoresForChild(chores, child.id);
const pointsIcon = attrs.points_icon || "mdi:star";
const header = html`
<div class="tmd-hd" style="--ac:var(--tmd-c1)">
@@ -913,7 +912,7 @@ class TaskMateReorderCard extends LitElement {
</div>
<div class="d-list">
${allCategoryChores.map((chore, index) =>
this._renderDesignedChoreItem(chore, index, allCategoryChores.length, category, pointsIcon)
this._renderDesignedChoreItem(chore, index, allCategoryChores.length, category)
)}
</div>`;
})}
@@ -922,7 +921,7 @@ class TaskMateReorderCard extends LitElement {
`;
}
_renderDesignedChoreItem(chore, index, total, category, pointsIcon) {
_renderDesignedChoreItem(chore, index, total, category) {
const isFirst = index === 0;
const isLast = index === total - 1;
const v = window.__taskmate_chore_visual(chore);
@@ -699,7 +699,7 @@ class TaskMateRewardProgressCard extends LitElement {
_renderDesigned(design) {
const hd = _safeColor(this.config.header_color, '#00897b');
const wrap = (body, icon) => html`
const wrap = (body) => html`
<ha-card class="tmd" style="--hd:${hd}">
<div class="tmd-hd">
<span class="ic">🎯</span>
@@ -1147,8 +1147,8 @@ class TaskMateRewardsCard extends LitElement {
if (a.is_jackpot && !b.is_jackpot) return -1;
if (!a.is_jackpot && b.is_jackpot) return 1;
// Within same type, sort by calculated cost ascending
const aCost = this._getDisplayCost(a, children);
const bCost = this._getDisplayCost(b, children);
const aCost = this._getDisplayCost(a);
const bCost = this._getDisplayCost(b);
return aCost - bCost;
});
@@ -1304,7 +1304,7 @@ class TaskMateRewardsCard extends LitElement {
// pool, so jackpots never show the single-child claim button.
const enablePoolMode = cardForcesPool || reward.pool_enabled === true || isJackpot;
// All costs are static — just use reward.cost
const displayCost = this._getDisplayCost(reward, children);
const displayCost = this._getDisplayCost(reward);
// Get relevant children for this reward
const relevantChildren = assignedTo.length === 0
@@ -1329,7 +1329,6 @@ class TaskMateRewardsCard extends LitElement {
const sharePerChild = relevantChildren.length > 0
? Math.round(displayCost / relevantChildren.length)
: displayCost;
relevantChildren.forEach((child, index) => {
// In pool mode, show allocated points only; in wallet mode show wallet points
const points = enablePoolMode
@@ -1703,7 +1702,7 @@ class TaskMateRewardsCard extends LitElement {
* For non-jackpot rewards, each child has their own calculated cost.
* For jackpot rewards, all children share the same cost.
*/
_getDisplayCost(reward, children) {
_getDisplayCost(reward) {
// All reward costs are static — just return reward.cost
// calculated_costs is still provided by the sensor for jackpot display purposes
const calculatedCosts = reward.calculated_costs || {};
@@ -1740,9 +1739,6 @@ class TaskMateRewardsCard extends LitElement {
};
});
// Calculate total percentage for display
const totalPercentage = cost > 0 ? Math.min((totalStars / cost) * 100, 100) : 0;
return html`
<div class="progress-section">
<div class="progress-bar-container">
@@ -1803,7 +1799,7 @@ class TaskMateRewardsCard extends LitElement {
// shared jar and it's redeemed when full. Combined points only work via the
// pool, so jackpots never show the single-child claim button.
const enablePoolMode = cardForcesPool || reward.pool_enabled === true || isJackpot;
const displayCost = this._getDisplayCost(reward, children);
const displayCost = this._getDisplayCost(reward);
const relevantChildren = assignedTo.length === 0
? children
@@ -1821,9 +1817,6 @@ class TaskMateRewardsCard extends LitElement {
const childContributions = [];
if (isJackpot) {
const sharePerChild = relevantChildren.length > 0
? Math.round(displayCost / relevantChildren.length)
: displayCost;
relevantChildren.forEach((child, index) => {
const points = enablePoolMode
? (poolAllocations[child.id] || 0)
@@ -1906,7 +1899,7 @@ class TaskMateRewardsCard extends LitElement {
if (!entity) {
return html`<ha-card class="tmd" style="--hd:${hd}">
${this._designHeader(0, hd)}
${this._designHeader(0)}
<div class="tmd-bd"><div class="tmd-empty">${this._t('common.entity_not_found', { entity: this.config.entity })}</div></div>
</ha-card>`;
}
@@ -1928,7 +1921,7 @@ class TaskMateRewardsCard extends LitElement {
const sortedRewards = [...rewards].sort((a, b) => {
if (a.is_jackpot && !b.is_jackpot) return -1;
if (!a.is_jackpot && b.is_jackpot) return 1;
return this._getDisplayCost(a, children) - this._getDisplayCost(b, children);
return this._getDisplayCost(a) - this._getDisplayCost(b);
});
const cardForcesPool = this.config.enable_pool_mode === true;
@@ -1936,7 +1929,7 @@ class TaskMateRewardsCard extends LitElement {
const activeChild = activeChildId ? children.find((c) => c.id === activeChildId) : null;
const anyPoolReward = sortedRewards.some((r) => cardForcesPool || r.pool_enabled === true || r.is_jackpot);
const header = this._designHeader(rewards.length, hd);
const header = this._designHeader(rewards.length);
const body = sortedRewards.length === 0
? html`<div class="tmd-empty">${this._t('rewards.empty_title')}<br>${this._t('rewards.empty_subtitle')}</div>`
@@ -1958,7 +1951,7 @@ class TaskMateRewardsCard extends LitElement {
`;
}
_designHeader(count, hd) {
_designHeader(count) {
return html`
<div class="tmd-hd">
<span class="ic">🎁</span>
@@ -1969,7 +1962,7 @@ class TaskMateRewardsCard extends LitElement {
</div>`;
}
_designChildSelector(child, pointsIcon, pointsName, design) {
_designChildSelector(child, _pointsIcon, _pointsName, _design) {
const spendable = typeof child.spendable_balance === 'number'
? child.spendable_balance : (child.points || 0);
return html`
@@ -1983,7 +1976,7 @@ class TaskMateRewardsCard extends LitElement {
</div>`;
}
_designRewardRow(reward, children, pointsIcon, pointsName, design) {
_designRewardRow(reward, children, pointsIcon, pointsName, _design) {
const d = this._rewardData(reward, children);
const tone = this._designTone((reward.id || reward.name || '').length);
const isLoading = this._loading[reward.id];
@@ -322,7 +322,7 @@ class TaskMateStreakCard extends LitElement {
// Use backend-calculated streak if available, fall back to client calculation
const streak = child.current_streak !== undefined
? child.current_streak
: this._calculateStreak(childCompletions, chores, child.id);
: this._calculateStreak(childCompletions);
const daysShown = this.config.streak_days_shown || 14;
const dayDots = this._buildDayDots(childCompletions, chores, child.id, daysShown);
const achievements = this._getAchievements(child, childCompletions, streak, entity_ref);
@@ -370,7 +370,7 @@ class TaskMateStreakCard extends LitElement {
`;
}
_calculateStreak(completions, chores, childId) {
_calculateStreak(completions) {
const tz = this.hass?.config?.time_zone || Intl.DateTimeFormat().resolvedOptions().timeZone;
// Build set of unique days with at least one completion
@@ -492,7 +492,7 @@ class TaskMateStreakCard extends LitElement {
const childCompletions = completions.filter(c => c.child_id === child.id);
const streak = child.current_streak !== undefined
? child.current_streak
: this._calculateStreak(childCompletions, chores, child.id);
: this._calculateStreak(childCompletions);
const daysShown = this.config.streak_days_shown || 14;
const dayDots = this._buildDayDots(childCompletions, chores, child.id, daysShown);
const achievements = this._getAchievements(child, childCompletions, streak, entity_ref);
@@ -549,10 +549,6 @@ class TaskMateStreakCard extends LitElement {
return html`<ha-card class="tmd" style="--hd:${hd}">${header}<div class="tmd-bd">${body}</div></ha-card>`;
}
_earnedBadges(achievements) {
return achievements.filter(a => a.earned);
}
// Classic shows earned badges PLUS the next locked milestone. _getAchievements
// already returns exactly that set, so the designed badge rows render it whole,
// dimming the locked entry to match the classic .badge.locked treatment.
@@ -401,7 +401,7 @@ class TaskMateWeeklyCard extends LitElement {
// Max completions in a day for chart scale
const maxPerDay = Math.max(1, ...weekDays.map(d => (approvedCompletionsByDay[d.key] || []).length));
const weekLabel = this._getWeekLabel(weekDays, tz);
const weekLabel = this._getWeekLabel(weekDays);
return html`
<ha-card>
@@ -596,7 +596,7 @@ class TaskMateWeeklyCard extends LitElement {
}
const d = this._weekData(entity, tz);
const weekLabel = this._getWeekLabel(d.weekDays, tz);
const weekLabel = this._getWeekLabel(d.weekDays);
const title = this.config.title || this._t("weekly.default_title");
const icon = design === "playroom" ? "📅" : design === "console" ? "▦" : "▤";
@@ -718,7 +718,7 @@ class TaskMateWeeklyCard extends LitElement {
return days;
}
_getWeekLabel(weekDays, tz) {
_getWeekLabel(weekDays) {
const first = weekDays[0].date;
const last = weekDays[6].date;
const fmt = { month: "short", day: "numeric" };