"""Storage management for TaskMate integration.""" from __future__ import annotations import logging from datetime import date, timedelta from typing import Any from homeassistant.core import HomeAssistant from homeassistant.helpers.storage import Store from .const import DOMAIN from .models import ( AwardedBadge, Badge, Bonus, Challenge, Child, Chore, ChoreCompletion, CustomNotification, MandatoryMiss, NotificationConfig, NotificationRoute, ParentRecipient, Penalty, PointsTransaction, PoolAllocation, Quest, Reward, RewardClaim, ScheduledChange, TaskGroup, TimedSession, ) _LOGGER = logging.getLogger(__name__) STORAGE_VERSION = 1 STORAGE_KEY = f"{DOMAIN}.storage" # Debounce window for coalescing the 2-4 saves a single operation can trigger # into one disk write (PERF-3). Small enough that a crash loses at most this # much; HA flushes pending delayed saves on shutdown. _SAVE_DEBOUNCE_SECONDS = 1.0 class TaskMateStorage: """Manage TaskMate data storage.""" def __init__(self, hass: HomeAssistant, entry_id: str) -> None: """Initialize storage.""" self.hass = hass self.entry_id = entry_id self._store = Store(hass, STORAGE_VERSION, f"{STORAGE_KEY}.{entry_id}") self._data: dict[str, Any] = {} # Monotonic counter bumped on every persisted mutation (PERF-2). Lets the # coordinator skip rebuilding its data snapshot when nothing has changed. self._data_version = 0 async def async_load(self) -> dict[str, Any]: """Load data from storage.""" data = await self._store.async_load() is_fresh = data is None if is_fresh: data = { "children": [], "chores": [], "rewards": [], "completions": [], "mandatory_misses": [], "reward_claims": [], "points_transactions": [], "pool_allocations": [], "task_groups": [], "badges": [], "awarded_badges": [], "points_name": "Stars", "points_icon": "mdi:star", "last_completed": {}, } self._data = data # Ensure last_completed store exists (migration for existing installs) if "last_completed" not in self._data: self._data["last_completed"] = {} # Ensure pool_allocations store exists (migration for v3.0 pool mode) if "pool_allocations" not in self._data: self._data["pool_allocations"] = [] # Ensure task_groups store exists (migration for existing installs) if "task_groups" not in self._data: self._data["task_groups"] = [] # Ensure timed_sessions store exists (migration for timed tasks feature) if "timed_sessions" not in self._data: self._data["timed_sessions"] = [] # Ensure career_score_history store exists if "career_score_history" not in self._data: self._data["career_score_history"] = {} # Ensure templates store exists if "templates" not in self._data: self._data["templates"] = [] # Ensure badges store exists (migration for achievement badges feature) if "badges" not in self._data: self._data["badges"] = [] # Ensure awarded_badges store exists (migration for achievement badges feature) if "awarded_badges" not in self._data: self._data["awarded_badges"] = [] # Ensure chore_display_order store exists (global admin ordering) if "chore_display_order" not in self._data: self._data["chore_display_order"] = [] # Ensure scheduled_changes store exists (#675) if "scheduled_changes" not in self._data: self._data["scheduled_changes"] = [] # Notifications overhaul (v3.9.0) if "parent_recipients" not in self._data: self._data["parent_recipients"] = [] if "notification_config" not in self._data: self._data["notification_config"] = {} if "custom_notifications" not in self._data: self._data["custom_notifications"] = [] if "notifications_migration_done" not in self._data: self._run_notifications_migration() self._data["notifications_migration_done"] = True # Badge migration / seeding self._seed_builtin_badges(is_fresh=is_fresh) # Run data migrations await self._migrate_assigned_to_child_ids() await self._migrate_pool_allocations_v2() await self._migrate_career_score() return data async def _migrate_pool_allocations_v2(self) -> None: """Migrate beta1 pool allocations to beta2 semantics. In v3.0.0-beta1, pool allocations did NOT deduct from child.points (the points stayed in the gross balance until redeem). From beta2 onward, allocations deduct immediately so the visible balance reflects commitment. For existing installs: subtract each allocation's allocated_points from the corresponding child's points, once. Guarded by a version flag so it runs only on the first beta2 load. """ if self._data.get("_pool_semantics_version", 1) >= 2: return allocations = self._data.get("pool_allocations", []) children = self._data.get("children", []) if not allocations or not children: # Mark as migrated even if nothing to do self._data["_pool_semantics_version"] = 2 await self.async_save() return # Build child lookup keyed by id children_by_id = {c.get("id"): c for c in children} adjusted = 0 for alloc in allocations: child_data = children_by_id.get(alloc.get("child_id")) if not child_data: continue allocated = int(alloc.get("allocated_points", 0) or 0) if allocated <= 0: continue current_points = int(child_data.get("points", 0) or 0) child_data["points"] = max(0, current_points - allocated) adjusted += 1 self._data["_pool_semantics_version"] = 2 if adjusted: _LOGGER.info( "TaskMate: migrated %d pool allocation(s) to beta2 semantics " "(points now deducted at allocation time)", adjusted ) await self.async_save() async def _migrate_assigned_to_child_ids(self) -> None: """Migrate chore assigned_to from child names to child IDs if needed. This handles legacy data where assigned_to might contain child names instead of child IDs. """ children = self._data.get("children", []) chores = self._data.get("chores", []) if not children or not chores: return # Build a map of child name -> child ID for migration name_to_id = {} valid_ids = set() for child in children: child_id = child.get("id", "") child_name = child.get("name", "") if child_id: valid_ids.add(child_id) if child_name and child_id: name_to_id[child_name] = child_id # Check and migrate each chore's assigned_to data_modified = False for chore in chores: assigned_to = chore.get("assigned_to", []) if not assigned_to: continue new_assigned_to = [] chore_modified = False for assignment in assigned_to: if assignment in valid_ids: # Already a valid child ID new_assigned_to.append(assignment) elif assignment in name_to_id: # This is a child name, convert to ID new_assigned_to.append(name_to_id[assignment]) chore_modified = True _LOGGER.warning( "Migrating chore '%s' assigned_to: '%s' -> '%s' (name to ID)", chore.get("name", "unknown"), assignment, name_to_id[assignment] ) else: # Unknown value, keep it but log a warning new_assigned_to.append(assignment) _LOGGER.warning( "Chore '%s' has unknown assigned_to value: '%s'", chore.get("name", "unknown"), assignment ) if chore_modified: chore["assigned_to"] = new_assigned_to data_modified = True if data_modified: _LOGGER.info("Data migration completed: converted child names to IDs in assigned_to") await self.async_save() async def _migrate_career_score(self) -> None: """Initialize career_score for existing installations. Sets career_score = total_points_earned for each child (we cannot retroactively determine penalty totals from the pruned transaction buffer). Runs once, guarded by _career_score_initialized flag. """ if self._data.get("_career_score_initialized"): return children = self._data.get("children", []) for child_data in children: earned = int(child_data.get("total_points_earned", 0) or 0) child_data.setdefault("career_score", earned) child_data.setdefault("total_penalties_received", 0) self._data["_career_score_initialized"] = True if children: _LOGGER.info( "TaskMate: initialized career_score for %d child(ren) " "from total_points_earned", len(children) ) await self.async_save() async def async_save(self) -> None: """Persist data, debounced (PERF-3). A single TaskMate operation often calls this 2-4× (e.g. mutate a record, award points, refresh). ``Store.async_delay_save`` coalesces all of them into one disk write after a short delay, instead of serialising the whole ``_data`` blob each time. HA flushes any pending delayed write on shutdown; ``async_shutdown`` also forces a flush via ``async_save_now``. Use ``async_save_now`` when an immediate on-disk write is required. """ # Bump synchronously, before any await: a mutation method calls this with # no interleaving await after touching _data, so the new version is # visible the instant anything else (e.g. the 30 s poll) can run. self._data_version = getattr(self, "_data_version", 0) + 1 self._store.async_delay_save(lambda: self._data, _SAVE_DEBOUNCE_SECONDS) async def async_save_now(self) -> None: """Persist data immediately, bypassing the debounce (shutdown/flush).""" self._data_version = getattr(self, "_data_version", 0) + 1 await self._store.async_save(self._data) @property def data_version(self) -> int: """Monotonic version of the in-memory data; bumped on each save.""" return getattr(self, "_data_version", 0) @property def data(self) -> dict[str, Any]: """Return current data.""" return self._data # Children management def get_children(self) -> list[Child]: """Get all children.""" return [Child.from_dict(c) for c in self._data.get("children", [])] def get_child(self, child_id: str) -> Child | None: """Get a child by ID.""" for child_data in self._data.get("children", []): if child_data.get("id") == child_id: return Child.from_dict(child_data) return None def add_child(self, child: Child) -> None: """Add a child.""" if "children" not in self._data: self._data["children"] = [] self._data["children"].append(child.to_dict()) def update_child(self, child: Child) -> None: """Update a child.""" children = self._data.get("children", []) for i, c in enumerate(children): if c.get("id") == child.id: children[i] = child.to_dict() return # If not found, add it self.add_child(child) def remove_child(self, child_id: str) -> None: """Remove a child and cascade-delete their awarded badges.""" self._data["children"] = [ c for c in self._data.get("children", []) if c.get("id") != child_id ] self.remove_awards_for_child(child_id) # Chores management def get_chores(self) -> list[Chore]: """Get all chores.""" return [Chore.from_dict(c) for c in self._data.get("chores", [])] def get_chore(self, chore_id: str) -> Chore | None: """Get a chore by ID.""" for chore_data in self._data.get("chores", []): if chore_data.get("id") == chore_id: return Chore.from_dict(chore_data) return None def add_chore(self, chore: Chore) -> None: """Add a chore.""" if "chores" not in self._data: self._data["chores"] = [] self._data["chores"].append(chore.to_dict()) def update_chore(self, chore: Chore) -> None: """Update a chore.""" chores = self._data.get("chores", []) for i, c in enumerate(chores): if c.get("id") == chore.id: chores[i] = chore.to_dict() return self.add_chore(chore) def remove_chore(self, chore_id: str) -> None: """Remove a chore.""" self._data["chores"] = [ c for c in self._data.get("chores", []) if c.get("id") != chore_id ] order = self._data.get("chore_display_order", []) 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) # Rewards management def get_rewards(self) -> list[Reward]: """Get all rewards.""" return [Reward.from_dict(r) for r in self._data.get("rewards", [])] def get_reward(self, reward_id: str) -> Reward | None: """Get a reward by ID.""" for reward_data in self._data.get("rewards", []): if reward_data.get("id") == reward_id: return Reward.from_dict(reward_data) return None def add_reward(self, reward: Reward) -> None: """Add a reward.""" if "rewards" not in self._data: self._data["rewards"] = [] self._data["rewards"].append(reward.to_dict()) def update_reward(self, reward: Reward) -> None: """Update a reward.""" rewards = self._data.get("rewards", []) for i, r in enumerate(rewards): if r.get("id") == reward.id: rewards[i] = reward.to_dict() return self.add_reward(reward) def remove_reward(self, reward_id: str) -> None: """Remove a reward.""" self._data["rewards"] = [ r for r in self._data.get("rewards", []) if r.get("id") != reward_id ] # Completions management def get_completions(self) -> list[ChoreCompletion]: """Get all chore completions.""" return [ChoreCompletion.from_dict(c) for c in self._data.get("completions", [])] def get_pending_completions(self) -> list[ChoreCompletion]: """Get pending (unapproved) completions.""" return [c for c in self.get_completions() if not c.approved] def add_completion(self, completion: ChoreCompletion) -> None: """Add a completion record.""" if "completions" not in self._data: self._data["completions"] = [] self._data["completions"].append(completion.to_dict()) def update_completion(self, completion: ChoreCompletion) -> None: """Update a completion record.""" completions = self._data.get("completions", []) for i, c in enumerate(completions): if c.get("id") == completion.id: completions[i] = completion.to_dict() return _LOGGER.warning( "update_completion: completion %s not found (possibly pruned mid-update)", completion.id, ) def remove_completion(self, completion_id: str) -> None: """Remove a completion record.""" self._data["completions"] = [ c for c in self._data.get("completions", []) if c.get("id") != completion_id ] # Mandatory-miss management (#532) def get_mandatory_misses(self) -> list[MandatoryMiss]: """Get all pending mandatory-miss review items.""" return [MandatoryMiss.from_dict(m) for m in self._data.get("mandatory_misses", [])] def add_mandatory_miss(self, miss: MandatoryMiss) -> None: """Add a mandatory-miss item.""" self._data.setdefault("mandatory_misses", []).append(miss.to_dict()) def update_mandatory_miss(self, miss: MandatoryMiss) -> None: """Replace a mandatory-miss item by id.""" items = self._data.get("mandatory_misses", []) for i, m in enumerate(items): if m.get("id") == miss.id: items[i] = miss.to_dict() return def remove_mandatory_miss(self, miss_id: str) -> None: """Remove a mandatory-miss item by id.""" self._data["mandatory_misses"] = [ m for m in self._data.get("mandatory_misses", []) if m.get("id") != miss_id ] def replace_mandatory_misses(self, misses: list[MandatoryMiss]) -> None: """Replace the whole mandatory-miss collection.""" self._data["mandatory_misses"] = [m.to_dict() for m in misses] # Reward claims management def get_reward_claims(self) -> list[RewardClaim]: """Get all reward claims.""" return [RewardClaim.from_dict(r) for r in self._data.get("reward_claims", [])] def get_pending_reward_claims(self) -> list[RewardClaim]: """Get pending (unapproved) reward claims.""" return [c for c in self.get_reward_claims() if not c.approved] def add_reward_claim(self, claim: RewardClaim) -> None: """Add a reward claim.""" if "reward_claims" not in self._data: self._data["reward_claims"] = [] self._data["reward_claims"].append(claim.to_dict()) def update_reward_claim(self, claim: RewardClaim) -> None: """Update a reward claim.""" claims = self._data.get("reward_claims", []) for i, c in enumerate(claims): if c.get("id") == claim.id: claims[i] = claim.to_dict() return _LOGGER.warning( "update_reward_claim: claim %s not found (possibly removed mid-update)", claim.id, ) def remove_reward_claim(self, claim_id: str) -> None: """Remove a reward claim.""" self._data["reward_claims"] = [ c for c in self._data.get("reward_claims", []) if c.get("id") != claim_id ] # Penalties management def get_penalties(self) -> list[Penalty]: """Get all penalties.""" return [Penalty.from_dict(p) for p in self._data.get("penalties", [])] def get_penalty(self, penalty_id: str) -> Penalty | None: """Get a penalty by ID.""" for p in self._data.get("penalties", []): if p.get("id") == penalty_id: return Penalty.from_dict(p) return None def add_penalty(self, penalty) -> None: """Add a new penalty.""" self._data.setdefault("penalties", []).append(penalty.to_dict()) def update_penalty(self, penalty) -> None: """Update an existing penalty.""" penalties = self._data.get("penalties", []) for i, p in enumerate(penalties): if p.get("id") == penalty.id: penalties[i] = penalty.to_dict() return penalties.append(penalty.to_dict()) def remove_penalty(self, penalty_id: str) -> None: """Remove a penalty.""" self._data["penalties"] = [ p for p in self._data.get("penalties", []) if p.get("id") != penalty_id ] # Bonuses management def get_bonuses(self) -> list[Bonus]: """Get all bonuses.""" return [Bonus.from_dict(b) for b in self._data.get("bonuses", [])] def get_bonus(self, bonus_id: str) -> Bonus | None: """Get a bonus by ID.""" for b in self._data.get("bonuses", []): if b.get("id") == bonus_id: return Bonus.from_dict(b) return None def add_bonus(self, bonus) -> None: """Add a new bonus.""" self._data.setdefault("bonuses", []).append(bonus.to_dict()) def update_bonus(self, bonus) -> None: """Update an existing bonus.""" bonuses = self._data.get("bonuses", []) for i, b in enumerate(bonuses): if b.get("id") == bonus.id: bonuses[i] = bonus.to_dict() return bonuses.append(bonus.to_dict()) def remove_bonus(self, bonus_id: str) -> None: """Remove a bonus.""" self._data["bonuses"] = [ b for b in self._data.get("bonuses", []) if b.get("id") != bonus_id ] # Badges management def get_badges(self) -> list[Badge]: """Get all badges.""" return [Badge.from_dict(b) for b in self._data.get("badges", [])] def get_badge(self, badge_id: str) -> Badge | None: """Get a badge by ID.""" for b in self._data.get("badges", []): if b.get("id") == badge_id: return Badge.from_dict(b) return None def add_badge(self, badge: Badge) -> None: """Add a new badge.""" self._data.setdefault("badges", []).append(badge.to_dict()) def update_badge(self, badge: Badge) -> None: """Update an existing badge.""" badges = self._data.get("badges", []) for i, b in enumerate(badges): if b.get("id") == badge.id: badges[i] = badge.to_dict() return badges.append(badge.to_dict()) def remove_badge(self, badge_id: str) -> None: """Remove a badge and cascade-delete its awards.""" self._data["badges"] = [ b for b in self._data.get("badges", []) if b.get("id") != badge_id ] self.remove_awards_for_badge(badge_id) # Awarded badges management def get_awarded_badges(self) -> list[AwardedBadge]: """Get all awarded badges.""" return [AwardedBadge.from_dict(a) for a in self._data.get("awarded_badges", [])] def get_awarded_badges_for_child(self, child_id: str) -> list[AwardedBadge]: """Get awarded badges for a specific child.""" return [a for a in self.get_awarded_badges() if a.child_id == child_id] def add_awarded_badge(self, awarded: AwardedBadge) -> None: """Add an awarded-badge record.""" self._data.setdefault("awarded_badges", []).append(awarded.to_dict()) def remove_awarded_badge(self, awarded_id: str) -> None: """Remove an awarded-badge record by id.""" self._data["awarded_badges"] = [ a for a in self._data.get("awarded_badges", []) if a.get("id") != awarded_id ] def remove_awards_for_badge(self, badge_id: str) -> None: """Cascade-delete all awards referencing a badge id.""" self._data["awarded_badges"] = [ a for a in self._data.get("awarded_badges", []) if a.get("badge_id") != badge_id ] def remove_awards_for_child(self, child_id: str) -> None: """Cascade-delete all awards for a child id.""" self._data["awarded_badges"] = [ a for a in self._data.get("awarded_badges", []) if a.get("child_id") != child_id ] def has_awarded(self, child_id: str, badge_id: str) -> bool: """Check whether the child has already earned this badge.""" for a in self._data.get("awarded_badges", []): if a.get("child_id") == child_id and a.get("badge_id") == badge_id: return True return False def _seed_builtin_badges(self, *, is_fresh: bool) -> None: """Seed the built-in badge catalogue. On fresh install (is_fresh=True): add all built-ins and set the backfill_pending flag so existing kid state can be retro-awarded silently. On existing install (is_fresh=False): add only built-ins missing from storage; preserve parent customisations; do not set backfill flag. Idempotent. """ from .coord_badges import BUILTIN_CATALOGUE existing = self._data.get("badges", []) existing_ids = {b.get("id") for b in existing} for builtin in BUILTIN_CATALOGUE: if builtin.id not in existing_ids: existing.append(builtin.to_dict()) self._data["badges"] = existing if is_fresh: self._data["badges_backfill_pending"] = True def _run_notifications_migration(self) -> None: """Seed parent_recipients + notification_config from legacy notify_service. Idempotent: a guard flag is written by the caller in async_load. """ legacy = (self._data.get("settings", {}) or {}).get("notify_service", "") parents: list[dict] = [] existing_parents = self._data.setdefault("parent_recipients", []) existing_services = {r.get("notify_service") for r in existing_parents} if legacy and legacy not in existing_services: seeded = ParentRecipient(name="Parent", notify_service=legacy) parents.append(seeded.to_dict()) existing_parents.extend(parents) # Defaults: previously-active types ON (preserves existing behaviour); # new types OFF (no surprise pings on upgrade). defaults_on = {"pending_chore_approval", "pending_reward_claim", "badge_earned"} defaults_off = {"bedtime_reminder", "streak_at_risk", "all_chores_done"} nc = self._data.setdefault("notification_config", {}) seeded_parent_id = parents[0]["id"] if parents else None for tid in defaults_on: cfg = NotificationConfig( type_id=tid, master_enabled=True, routes={ seeded_parent_id: NotificationRoute(enabled=True) } if seeded_parent_id else {}, ) nc[tid] = cfg.to_dict() for tid in defaults_off: nc[tid] = NotificationConfig(type_id=tid, master_enabled=False).to_dict() # --- parent recipients --- def get_parent_recipients(self) -> list[ParentRecipient]: return [ParentRecipient.from_dict(d) for d in self._data.get("parent_recipients", [])] def upsert_parent_recipient(self, p: ParentRecipient) -> None: rows = self._data.setdefault("parent_recipients", []) for i, row in enumerate(rows): if row.get("id") == p.id: rows[i] = p.to_dict() return rows.append(p.to_dict()) def delete_parent_recipient(self, parent_id: str) -> None: self._data["parent_recipients"] = [ r for r in self._data.get("parent_recipients", []) if r.get("id") != parent_id ] # --- notification config --- def get_notification_config(self, type_id: str) -> NotificationConfig: raw = (self._data.get("notification_config", {}) or {}).get(type_id) if not raw: return NotificationConfig(type_id=type_id) return NotificationConfig.from_dict(raw) def set_notification_master(self, type_id: str, enabled: bool) -> None: cfg = self.get_notification_config(type_id) cfg.master_enabled = enabled self._data.setdefault("notification_config", {})[type_id] = cfg.to_dict() def set_notification_route( self, type_id: str, recipient_id: str, route: NotificationRoute ) -> None: cfg = self.get_notification_config(type_id) cfg.routes[recipient_id] = route self._data.setdefault("notification_config", {})[type_id] = cfg.to_dict() def get_all_notification_configs(self) -> dict[str, NotificationConfig]: return { tid: NotificationConfig.from_dict(raw) for tid, raw in (self._data.get("notification_config", {}) or {}).items() } # --- custom notifications --- def get_custom_notifications(self) -> list[CustomNotification]: return [ CustomNotification.from_dict(d) for d in self._data.get("custom_notifications", []) ] def upsert_custom_notification(self, n: CustomNotification) -> None: rows = self._data.setdefault("custom_notifications", []) for i, row in enumerate(rows): if row.get("id") == n.id: rows[i] = n.to_dict() return rows.append(n.to_dict()) def delete_custom_notification(self, custom_id: str) -> None: self._data["custom_notifications"] = [ r for r in self._data.get("custom_notifications", []) if r.get("id") != custom_id ] # --- streak-at-risk cutoff --- def get_streak_at_risk_cutoff(self) -> str: return (self._data.get("settings", {}) or {}).get( "streak_at_risk_cutoff_time", "20:00" ) def set_streak_at_risk_cutoff(self, hhmm: str) -> None: self._data.setdefault("settings", {})["streak_at_risk_cutoff_time"] = hhmm def get_parent_user_ids(self) -> list[str]: """HA user IDs granted the non-admin TaskMate parent role (#661).""" raw = (self._data.get("settings", {}) or {}).get("parent_user_ids", []) if not isinstance(raw, list): return [] return [x for x in raw if isinstance(x, str) and x] def set_parent_user_ids(self, ids: list[str]) -> None: """Replace the parent role list (deduped, order-preserving, strings only).""" seen: list[str] = [] for x in ids or []: if isinstance(x, str) and x and x not in seen: seen.append(x) self._data.setdefault("settings", {})["parent_user_ids"] = seen # --- mandatory reminder escalation (FEAT-6) --- def get_escalation_reminder_minutes(self) -> int: """Minutes after a mandatory miss before the child reminder escalates.""" try: return max(1, int((self._data.get("settings", {}) or {}).get( "mandatory_escalation_reminder_minutes", 30))) except (TypeError, ValueError): return 30 def get_escalation_parent_minutes(self) -> int: """Minutes after a mandatory miss before the parent alert escalates.""" try: return max(1, int((self._data.get("settings", {}) or {}).get( "mandatory_escalation_parent_minutes", 120))) except (TypeError, ValueError): return 120 def set_escalation_minutes(self, reminder_minutes: int, parent_minutes: int) -> None: s = self._data.setdefault("settings", {}) s["mandatory_escalation_reminder_minutes"] = max(1, int(reminder_minutes)) s["mandatory_escalation_parent_minutes"] = max(1, int(parent_minutes)) # Task groups management def get_task_groups(self) -> list[TaskGroup]: """Get all task groups.""" return [TaskGroup.from_dict(g) for g in self._data.get("task_groups", [])] def get_task_group(self, group_id: str) -> TaskGroup | None: """Get a task group by ID.""" for g in self._data.get("task_groups", []): if g.get("id") == group_id: return TaskGroup.from_dict(g) return None def get_task_group_for_chore(self, chore_id: str) -> TaskGroup | None: """Return the group that contains this chore, or None.""" for g in self._data.get("task_groups", []): if chore_id in g.get("chore_ids", []): return TaskGroup.from_dict(g) return None def add_task_group(self, group: TaskGroup) -> None: """Add a task group.""" self._data.setdefault("task_groups", []).append(group.to_dict()) def update_task_group(self, group: TaskGroup) -> None: """Update an existing task group.""" groups = self._data.get("task_groups", []) for i, g in enumerate(groups): if g.get("id") == group.id: groups[i] = group.to_dict() return groups.append(group.to_dict()) def remove_task_group(self, group_id: str) -> None: """Remove a task group.""" self._data["task_groups"] = [ g for g in self._data.get("task_groups", []) if g.get("id") != group_id ] def remove_chore_from_task_groups(self, chore_id: str) -> None: """Strip a chore ID from every group (used on chore delete).""" for g in self._data.get("task_groups", []): if chore_id in g.get("chore_ids", []): g["chore_ids"] = [c for c in g["chore_ids"] if c != chore_id] # Scheduled config changes (#675) def get_scheduled_changes(self) -> list[ScheduledChange]: """All scheduled changes, pending and already applied.""" return [ScheduledChange.from_dict(c) for c in self._data.get("scheduled_changes", [])] def get_scheduled_change(self, change_id: str) -> ScheduledChange | None: for c in self._data.get("scheduled_changes", []): if c.get("id") == change_id: return ScheduledChange.from_dict(c) return None def add_scheduled_change(self, change: ScheduledChange) -> None: self._data.setdefault("scheduled_changes", []).append(change.to_dict()) def update_scheduled_change(self, change: ScheduledChange) -> None: changes = self._data.setdefault("scheduled_changes", []) for i, c in enumerate(changes): if c.get("id") == change.id: changes[i] = change.to_dict() return changes.append(change.to_dict()) def remove_scheduled_change(self, change_id: str) -> None: self._data["scheduled_changes"] = [ c for c in self._data.get("scheduled_changes", []) if c.get("id") != change_id ] def remove_scheduled_changes_for_chore(self, chore_id: str) -> None: """Drop a deleted chore's queued changes so they can't fire on nothing.""" self._data["scheduled_changes"] = [ c for c in self._data.get("scheduled_changes", []) if c.get("chore_id") != chore_id ] # Points transactions management def get_points_transactions(self) -> list[PointsTransaction]: """Get all points transactions.""" return [PointsTransaction.from_dict(t) for t in self._data.get("points_transactions", [])] def add_points_transaction(self, transaction: PointsTransaction) -> None: """Add a points transaction record.""" if "points_transactions" not in self._data: self._data["points_transactions"] = [] self._data["points_transactions"].append(transaction.to_dict()) # Accumulate season (leaderboard) points from every positive award here, # the single choke point all awards flow through — the rolling 200-cap on # transactions makes them unreliable for a monthly total (FEAT-2). if transaction.points > 0: self.record_season_points( transaction.child_id, transaction.points, transaction.created_at ) # Keep only the last 200 transactions to avoid unbounded storage growth if len(self._data["points_transactions"]) > 200: self._data["points_transactions"] = self._data["points_transactions"][-200:] # ── Leaderboard seasons (FEAT-2) ────────────────────────────────────── def record_season_points(self, child_id: str, points: int, when) -> None: """Add ``points`` to ``child_id``'s tally for the calendar month of ``when``.""" if points <= 0 or not child_id: return key = when.strftime("%Y-%m") seasons = self._data.setdefault("season_points", {}) month = seasons.setdefault(key, {}) month[child_id] = month.get(child_id, 0) + int(points) # Keep ~13 months so year-over-year stays available without unbounded growth. if len(seasons) > 13: for stale in sorted(seasons)[:-13]: del seasons[stale] def get_season_points(self, ym: str) -> dict[str, int]: """Per-child points earned in calendar month ``ym`` ("YYYY-MM").""" return dict((self._data.get("season_points", {}) or {}).get(ym, {})) # ── Allowance payout ledger (FEAT-3) ────────────────────────────────── def get_allowance_payouts(self) -> list[dict]: """Recorded allowance payouts, oldest first.""" return list(self._data.get("allowance_payouts", [])) def add_allowance_payout(self, entry: dict) -> None: """Append an allowance payout; cap the ledger at 500 entries.""" ledger = self._data.setdefault("allowance_payouts", []) ledger.append(entry) if len(ledger) > 500: del ledger[:-500] def get_season_champions(self) -> list[dict]: """Recorded monthly champions, oldest first.""" return list(self._data.get("season_champions", [])) def add_season_champion(self, entry: dict) -> None: """Record a month's champion once; cap history at 24 months.""" champs = self._data.setdefault("season_champions", []) if any(c.get("month") == entry.get("month") for c in champs): return champs.append(entry) if len(champs) > 24: del champs[:-24] def remove_points_transaction(self, transaction_id: str) -> bool: """Remove a points transaction by id. Returns True if one was removed.""" txns = self._data.get("points_transactions", []) for i, t in enumerate(txns): if t.get("id") == transaction_id: del txns[i] return True return False # ── Admin audit log ────────────────────────────────────────────────── def get_audit_log(self) -> list[dict]: """Return admin audit entries, newest first.""" return list(reversed(self._data.get("audit_log", []))) def add_audit_entry(self, entry: dict) -> None: """Append an admin audit entry, capping the log at 500 (oldest dropped).""" log = self._data.setdefault("audit_log", []) log.append(entry) if len(log) > 500: del log[:-500] def clear_audit_log(self) -> None: """Remove all audit entries.""" self._data["audit_log"] = [] # ── Chore swap requests ────────────────────────────────────────────── def get_swap_requests(self) -> list[dict]: return list(self._data.get("swap_requests", [])) def add_swap_request(self, req: dict) -> None: self._data.setdefault("swap_requests", []).append(req) def update_swap_request(self, req_id: str, **changes) -> bool: for r in self._data.get("swap_requests", []): if r.get("id") == req_id: r.update(changes) return True return False def remove_swap_request(self, req_id: str) -> bool: reqs = self._data.get("swap_requests", []) for i, r in enumerate(reqs): if r.get("id") == req_id: del reqs[i] return True return False # ── Quests (chore chains) ──────────────────────────────────────────── def get_quests(self) -> list[Quest]: return [Quest.from_dict(q) for q in self._data.get("quests", [])] def get_quest(self, quest_id: str) -> Quest | None: for q in self._data.get("quests", []): if q.get("id") == quest_id: return Quest.from_dict(q) return None def add_quest(self, quest: Quest) -> None: self._data.setdefault("quests", []).append(quest.to_dict()) def update_quest(self, quest: Quest) -> None: quests = self._data.get("quests", []) for i, q in enumerate(quests): if q.get("id") == quest.id: quests[i] = quest.to_dict() return self.add_quest(quest) def remove_quest(self, quest_id: str) -> None: self._data["quests"] = [ q for q in self._data.get("quests", []) if q.get("id") != quest_id ] # Drop any progress tracked for this quest prog = self._data.get("quest_progress", {}) prog.pop(quest_id, None) def get_quest_progress(self) -> dict: """All quest progress: {quest_id: {child_id: {step, completed_count, last_completed}}}.""" return self._data.setdefault("quest_progress", {}) def get_quest_child_progress(self, quest_id: str, child_id: str) -> dict: return self.get_quest_progress().setdefault(quest_id, {}).get(child_id, {}) def set_quest_child_progress(self, quest_id: str, child_id: str, progress: dict) -> None: self.get_quest_progress().setdefault(quest_id, {})[child_id] = progress def remove_quest_progress_for_child(self, child_id: str) -> None: for child_map in self.get_quest_progress().values(): child_map.pop(child_id, None) # ── Challenges (daily / weekly) ────────────────────────────────────── def get_challenges(self) -> list[Challenge]: return [Challenge.from_dict(c) for c in self._data.get("challenges", [])] def get_challenge(self, challenge_id: str) -> Challenge | None: for c in self._data.get("challenges", []): if c.get("id") == challenge_id: return Challenge.from_dict(c) return None def add_challenge(self, challenge: Challenge) -> None: self._data.setdefault("challenges", []).append(challenge.to_dict()) def update_challenge(self, challenge: Challenge) -> None: items = self._data.get("challenges", []) for i, c in enumerate(items): if c.get("id") == challenge.id: items[i] = challenge.to_dict() return self.add_challenge(challenge) def remove_challenge(self, challenge_id: str) -> None: self._data["challenges"] = [ c for c in self._data.get("challenges", []) if c.get("id") != challenge_id ] self._data.get("challenge_progress", {}).pop(challenge_id, None) def get_challenge_progress(self) -> dict: """All challenge progress: {challenge_id: {child_id: {period, awarded}}}.""" return self._data.setdefault("challenge_progress", {}) def get_challenge_child_progress(self, challenge_id: str, child_id: str) -> dict: return self.get_challenge_progress().setdefault(challenge_id, {}).get(child_id, {}) def set_challenge_child_progress(self, challenge_id: str, child_id: str, progress: dict) -> None: self.get_challenge_progress().setdefault(challenge_id, {})[child_id] = progress def remove_challenge_progress_for_child(self, child_id: str) -> None: for child_map in self.get_challenge_progress().values(): child_map.pop(child_id, None) # ── Backup / restore ───────────────────────────────────────────────── def export_data(self) -> dict: """Return a deep copy of the full stored data (for backup/export).""" import copy return copy.deepcopy(self._data) def import_data(self, data: dict) -> None: """Replace all stored data with ``data`` (restore from a backup). Core collection keys are ensured so downstream readers never KeyError on a partial import. """ import copy if not isinstance(data, dict): raise ValueError("import data must be an object") self._data = copy.deepcopy(data) list_keys = ( "children", "chores", "rewards", "penalties", "bonuses", "task_groups", "completions", "mandatory_misses", "reward_claims", "points_transactions", "pool_allocations", "badges", "awarded_badges", "parent_recipients", "audit_log", "timed_sessions", "quests", "challenges", ) for k in list_keys: if not isinstance(self._data.get(k), list): self._data[k] = [] if not isinstance(self._data.get("settings"), dict): self._data["settings"] = {} if not isinstance(self._data.get("quest_progress"), dict): self._data["quest_progress"] = {} if not isinstance(self._data.get("challenge_progress"), dict): self._data["challenge_progress"] = {} self._sanitize_imported_records() def _sanitize_imported_records(self) -> None: """Re-validate untrusted inner records after a full-replace import (SEC-5). ``import_data`` deep-copies the payload in with only top-level coercion, so a crafted backup could smuggle a ``photo_url`` that bypasses the ``is_taskmate_photo_url`` gate enforced at the ``complete_chore`` boundary. Strip any completion ``photo_url`` that isn't one of our own well-formed photo URLs so the panel never renders a foreign/dangerous one. """ from .photos import is_taskmate_photo_url for comp in self._data.get("completions", []): if not isinstance(comp, dict): continue url = comp.get("photo_url") if url and not is_taskmate_photo_url(url): _LOGGER.warning( "Import: dropped non-TaskMate photo_url on completion %s", comp.get("id", "?"), ) comp["photo_url"] = "" def replace_completions(self, completions: list[ChoreCompletion]) -> None: """Replace all completions with the given list.""" self._data["completions"] = [c.to_dict() for c in completions] def remove_completions_for_child(self, child_id: str) -> None: """Remove all completions for a given child.""" self._data["completions"] = [ c for c in self._data.get("completions", []) if c.get("child_id") != child_id ] def remove_completions_for_chore(self, chore_id: str) -> None: """Remove all completions for a given chore.""" self._data["completions"] = [ c for c in self._data.get("completions", []) if c.get("chore_id") != chore_id ] def remove_reward_claims_for_child(self, child_id: str) -> None: """Remove all reward claims for a given child.""" self._data["reward_claims"] = [ c for c in self._data.get("reward_claims", []) if c.get("child_id") != child_id ] def remove_reward_claims_for_reward(self, reward_id: str) -> None: """Remove all reward claims for a given reward.""" self._data["reward_claims"] = [ c for c in self._data.get("reward_claims", []) if c.get("reward_id") != reward_id ] # Pool allocations management (v3.0 pool mode) def get_pool_allocations(self) -> list[PoolAllocation]: """Get all pool allocations.""" return [PoolAllocation.from_dict(a) for a in self._data.get("pool_allocations", [])] def get_pool_allocation(self, child_id: str, reward_id: str) -> PoolAllocation | None: """Get a pool allocation for a specific (child, reward) pair.""" for a in self._data.get("pool_allocations", []): if a.get("child_id") == child_id and a.get("reward_id") == reward_id: return PoolAllocation.from_dict(a) return None def upsert_pool_allocation(self, allocation: PoolAllocation) -> None: """Insert or update a pool allocation (keyed by child_id + reward_id).""" allocations = self._data.setdefault("pool_allocations", []) for i, a in enumerate(allocations): if a.get("child_id") == allocation.child_id and a.get("reward_id") == allocation.reward_id: allocations[i] = allocation.to_dict() return allocations.append(allocation.to_dict()) def remove_pool_allocation(self, child_id: str, reward_id: str) -> None: """Remove a pool allocation for a specific (child, reward) pair.""" self._data["pool_allocations"] = [ a for a in self._data.get("pool_allocations", []) if not (a.get("child_id") == child_id and a.get("reward_id") == reward_id) ] def remove_pool_allocations_for_child(self, child_id: str) -> None: """Remove all pool allocations for a given child.""" self._data["pool_allocations"] = [ a for a in self._data.get("pool_allocations", []) if a.get("child_id") != child_id ] def remove_pool_allocations_for_reward(self, reward_id: str) -> None: """Remove all pool allocations for a given reward.""" self._data["pool_allocations"] = [ a for a in self._data.get("pool_allocations", []) if a.get("reward_id") != reward_id ] def get_total_allocated_for_child(self, child_id: str) -> int: """Return the sum of this child's allocations across all rewards.""" return sum( a.get("allocated_points", 0) for a in self._data.get("pool_allocations", []) if a.get("child_id") == child_id ) def get_total_allocated_for_reward(self, reward_id: str) -> int: """Return the sum of all children's allocations for a specific reward (used for jackpots).""" return sum( a.get("allocated_points", 0) for a in self._data.get("pool_allocations", []) if a.get("reward_id") == reward_id ) def remove_transactions_for_child(self, child_id: str) -> None: """Remove all points transactions for a given child.""" self._data["points_transactions"] = [ t for t in self._data.get("points_transactions", []) if t.get("child_id") != child_id ] def remove_last_completed_for_child(self, child_id: str) -> None: """Remove all last_completed records for a given child.""" lc = self._data.get("last_completed", {}) for chore_id in list(lc.keys()): lc[chore_id].pop(child_id, None) if not lc[chore_id]: del lc[chore_id] def remove_last_completed_for_chore(self, chore_id: str) -> None: """Remove last_completed records for a given chore.""" self._data.get("last_completed", {}).pop(chore_id, None) # Last completed store — used for recurrence window checks def get_last_completed(self, chore_id: str, child_id: str) -> dict: """Get last_completed record for a chore/child pair. Returns dict with 'current' and 'previous' ISO datetime strings, or empty dict if never completed. """ return self._data.get("last_completed", {}).get(chore_id, {}).get(child_id, {}) def set_last_completed(self, chore_id: str, child_id: str, completed_at_iso: str) -> None: """Record a new completion — shifts current to previous.""" if "last_completed" not in self._data: self._data["last_completed"] = {} if chore_id not in self._data["last_completed"]: self._data["last_completed"][chore_id] = {} existing = self._data["last_completed"][chore_id].get(child_id, {}) current = existing.get("current") self._data["last_completed"][chore_id][child_id] = { "current": completed_at_iso, "previous": current, # may be None } def undo_last_completed(self, chore_id: str, child_id: str) -> None: """Undo the most recent completion — restores previous as current.""" record = self._data.get("last_completed", {}).get(chore_id, {}).get(child_id) if not record: return previous = record.get("previous") if previous: self._data["last_completed"][chore_id][child_id] = { "current": previous, "previous": None, } else: # No previous — remove the record entirely del self._data["last_completed"][chore_id][child_id] if not self._data["last_completed"][chore_id]: del self._data["last_completed"][chore_id] # Timed sessions management def get_timed_sessions(self) -> list[TimedSession]: """Get all timed sessions.""" return [TimedSession.from_dict(s) for s in self._data.get("timed_sessions", [])] def get_timed_session(self, chore_id: str, child_id: str, session_date: str) -> TimedSession | None: """Get a timed session for a specific chore/child/date.""" for s in self._data.get("timed_sessions", []): if (s.get("chore_id") == chore_id and s.get("child_id") == child_id and s.get("session_date") == session_date): return TimedSession.from_dict(s) return None def get_active_timed_session(self, chore_id: str, child_id: str) -> TimedSession | None: """Get a running or paused session for a chore/child pair.""" for s in self._data.get("timed_sessions", []): if (s.get("chore_id") == chore_id and s.get("child_id") == child_id and s.get("state") in ("running", "paused")): return TimedSession.from_dict(s) return None def save_timed_session(self, session: TimedSession) -> None: """Insert or update a timed session.""" sessions = self._data.setdefault("timed_sessions", []) for i, s in enumerate(sessions): if s.get("id") == session.id: sessions[i] = session.to_dict() return sessions.append(session.to_dict()) def remove_timed_session(self, session_id: str) -> None: """Remove a timed session.""" self._data["timed_sessions"] = [ s for s in self._data.get("timed_sessions", []) if s.get("id") != session_id ] # Generic settings def get_setting(self, key: str, default: Any = "") -> Any: """Get a generic setting value (may be a bool/number/list, not just str).""" return self._data.get("settings", {}).get(key, default) def set_setting(self, key: str, value: Any) -> None: """Set a generic setting value.""" if "settings" not in self._data: self._data["settings"] = {} self._data["settings"][key] = value def get_settings(self) -> dict[str, Any]: """The whole settings dict (live reference; callers must not assume a copy).""" return self._data.get("settings", {}) or {} # ── Typed top-level flags (ARCH-1) ──────────────────────────────────── # These live at the root of _data (not under "settings"). Accessors keep the # key names + defaults in one place so setup/migration logic can't drift. def is_initial_setup_done(self) -> bool: return bool(self._data.get("_initial_setup_done")) def mark_initial_setup_done(self) -> None: self._data["_initial_setup_done"] = True def is_badges_backfill_pending(self) -> bool: return bool(self._data.get("badges_backfill_pending")) def clear_badges_backfill_pending(self) -> None: self._data.pop("badges_backfill_pending", None) # Settings def get_points_name(self) -> str: """Get the points currency name.""" return self._data.get("points_name", "Stars") def set_points_name(self, name: str) -> None: """Set the points currency name.""" self._data["points_name"] = name def get_points_icon(self) -> str: """Get the points icon.""" return self._data.get("points_icon", "mdi:star") def set_points_icon(self, icon: str) -> None: """Set the points icon.""" self._data["points_icon"] = icon # Career score history management def get_career_score_history(self, child_id: str) -> list[dict]: """Get career score history for a child.""" return list(self._data.get("career_score_history", {}).get(child_id, [])) def append_career_score_snapshot(self, child_id: str, date_str: str, score: int) -> None: """Upsert a daily career score snapshot for a child. If an entry for the given date already exists, its score is updated (last-write-wins). Entries older than 90 days are pruned. """ history = self._data.setdefault("career_score_history", {}) entries = history.setdefault(child_id, []) # Upsert: update existing date or append for entry in entries: if entry.get("date") == date_str: entry["score"] = score break else: entries.append({"date": date_str, "score": score}) # Prune entries older than 90 days cutoff = (date.today() - timedelta(days=90)).isoformat() history[child_id] = [e for e in entries if e.get("date", "") >= cutoff] def remove_career_score_history_for_child(self, child_id: str) -> None: """Remove all career score history for a child.""" history = self._data.get("career_score_history", {}) history.pop(child_id, None) def prune_all_done_flags(self, keep_date: str) -> None: """Drop all-chores-done flags for dates other than keep_date. Keys are "all_done__". """ flags = self._data.get("all_done_flags", {}) for key in list(flags): if not key.endswith(keep_date): flags.pop(key, None) # Template management def get_custom_templates(self) -> list[dict]: """Get all custom (user-created) templates.""" return list(self._data.get("templates", [])) def get_custom_template(self, template_id: str) -> dict | None: """Get a single custom template by ID.""" for tpl in self._data.get("templates", []): if tpl.get("id") == template_id: return dict(tpl) return None def add_custom_template(self, template: dict) -> None: """Add a custom template.""" if "templates" not in self._data: self._data["templates"] = [] self._data["templates"].append(template) def update_custom_template(self, template_id: str, updates: dict) -> None: """Update a custom template's fields.""" templates = self._data.get("templates", []) for tpl in templates: if tpl.get("id") == template_id: tpl.update(updates) return raise ValueError(f"Template {template_id} not found") def remove_custom_template(self, template_id: str) -> None: """Remove a custom template.""" templates = self._data.get("templates", []) original_len = len(templates) self._data["templates"] = [t for t in templates if t.get("id") != template_id] if len(self._data["templates"]) == original_len: raise ValueError(f"Template {template_id} not found")