"""Data models for TaskMate integration.""" from __future__ import annotations import logging import uuid from dataclasses import dataclass, field from datetime import datetime, timezone from typing import Any _LOGGER = logging.getLogger(__name__) def generate_id() -> str: """Generate a unique ID. Returns the first 16 hex chars of a UUID4 (64 bits). Short enough to use in dotted HA keys and service-call payloads without being unwieldy; still provides ~1.8e19 values, far more than the entities a household will ever create. """ return str(uuid.uuid4()).replace("-", "")[:16] def dt_util_now_iso() -> str: """Current local time as an ISO string (module-level so dataclass defaults can use it).""" from homeassistant.util import dt as dt_util return dt_util.now().isoformat() def parse_datetime(value: str | datetime | None) -> datetime | None: """Parse a datetime value, ensuring timezone awareness. If the datetime is naive (no timezone info), assume UTC for backward compatibility. All newly stored datetimes include timezone info via format_datetime(), so naive values only appear in legacy data. """ if value is None: return None if isinstance(value, datetime): if value.tzinfo is None: return value.replace(tzinfo=timezone.utc) return value if isinstance(value, str): try: dt = datetime.fromisoformat(value) except (ValueError, TypeError): _LOGGER.warning("Could not parse stored datetime %r", value) return None if dt.tzinfo is None: return dt.replace(tzinfo=timezone.utc) return dt return None def format_datetime(dt: datetime | None) -> str | None: """Format a datetime as ISO string with timezone info. Converts to UTC before formatting to ensure consistency. """ if dt is None: return None # Ensure we have timezone info if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) # Convert to UTC and format with 'Z' suffix for clarity utc_dt = dt.astimezone(timezone.utc) # Use isoformat but replace +00:00 with Z for cleaner output return utc_dt.isoformat().replace("+00:00", "Z") @dataclass class TimedSession: """Tracks an active or paused timed-task session.""" chore_id: str child_id: str state: str = "stopped" # "running" | "paused" | "stopped" segments: list[dict[str, Any]] = field(default_factory=list) total_seconds_today: int = 0 session_date: str = "" # ISO date this session belongs to id: str = field(default_factory=generate_id) @classmethod def from_dict(cls, data: dict[str, Any]) -> "TimedSession": return cls( chore_id=data.get("chore_id", ""), child_id=data.get("child_id", ""), state=data.get("state", "stopped"), segments=list(data.get("segments", [])), total_seconds_today=data.get("total_seconds_today", 0), session_date=data.get("session_date", ""), id=data.get("id", generate_id()), ) def to_dict(self) -> dict[str, Any]: return { "chore_id": self.chore_id, "child_id": self.child_id, "state": self.state, "segments": self.segments, "total_seconds_today": self.total_seconds_today, "session_date": self.session_date, "id": self.id, } @dataclass class BonusSubTask: """An optional bonus sub-task embedded within a parent chore.""" name: str points: int = 5 description: str = "" id: str = field(default_factory=generate_id) @classmethod def from_dict(cls, data: dict[str, Any]) -> BonusSubTask: return cls( name=data.get("name", ""), points=data.get("points", 5), description=data.get("description", ""), id=data.get("id", generate_id()), ) def to_dict(self) -> dict[str, Any]: return { "name": self.name, "points": self.points, "description": self.description, "id": self.id, } @dataclass class Child: """Represents a child.""" name: str avatar: str = "mdi:account-circle" points: int = 0 total_points_earned: int = 0 total_chores_completed: int = 0 current_streak: int = 0 best_streak: int = 0 pending_rewards: list[str] = field(default_factory=list) chore_order: list[str] = field(default_factory=list) # Custom chore ordering for this child last_completion_date: str | None = None # ISO date string of last chore completion (for streak tracking) streak_paused: bool = False # True if streak is paused due to missed day (pause mode) streak_milestones_achieved: list[int] = field(default_factory=list) awarded_perfect_weeks: list[str] = field(default_factory=list) availability_entity: str = "" # HA entity id; empty = always available availability_inverted: bool = False # When True, _AVAILABLE_STATES means UNAVAILABLE unavailability_entity: str = "" # Second HA entity; _AVAILABLE_STATES = child is busy pause_streak_when_unavailable: bool = False # Freeze streak + hide chores while this child's availability sensor says away (may be a calendar.* entity) career_score: int = 0 total_penalties_received: int = 0 notify_service: str | None = None linked_user_id: str = "" # HA user id; when set, only that user (or an admin) may self-serve as this child quiet_hours_start: str = "" # "HH:MM" — start of do-not-disturb window; empty = no quiet hours quiet_hours_end: str = "" # "HH:MM" — end of do-not-disturb window; start>end means overnight level: int = 1 # cached XP level (derived from total_points_earned) id: str = field(default_factory=generate_id) @classmethod def from_dict(cls, data: dict[str, Any]) -> Child: """Create a Child from a dictionary.""" return cls( name=data.get("name", ""), avatar=data.get("avatar", "mdi:account-circle"), points=data.get("points", 0), total_points_earned=data.get("total_points_earned", 0), total_chores_completed=data.get("total_chores_completed", 0), current_streak=data.get("current_streak", 0), 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), 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", [])), availability_entity=data.get("availability_entity", ""), availability_inverted=data.get("availability_inverted", False), unavailability_entity=data.get("unavailability_entity", ""), 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), linked_user_id=data.get("linked_user_id", ""), quiet_hours_start=data.get("quiet_hours_start", ""), quiet_hours_end=data.get("quiet_hours_end", ""), level=int(data.get("level", 1) or 1), id=data.get("id", generate_id()), ) def to_dict(self) -> dict[str, Any]: """Convert to dictionary.""" return { "name": self.name, "avatar": self.avatar, "points": self.points, "total_points_earned": self.total_points_earned, "total_chores_completed": self.total_chores_completed, "current_streak": self.current_streak, "best_streak": self.best_streak, "pending_rewards": self.pending_rewards, "chore_order": self.chore_order, "last_completion_date": self.last_completion_date, "streak_paused": self.streak_paused, "streak_milestones_achieved": self.streak_milestones_achieved, "awarded_perfect_weeks": self.awarded_perfect_weeks, "availability_entity": self.availability_entity, "availability_inverted": self.availability_inverted, "unavailability_entity": self.unavailability_entity, "pause_streak_when_unavailable": self.pause_streak_when_unavailable, "career_score": self.career_score, "total_penalties_received": self.total_penalties_received, "notify_service": self.notify_service, "linked_user_id": self.linked_user_id, "quiet_hours_start": self.quiet_hours_start, "quiet_hours_end": self.quiet_hours_end, "level": self.level, "id": self.id, } @dataclass class Chore: """Represents a chore.""" name: str points: int = 10 description: str = "" assigned_to: list[str] = field(default_factory=list) # List of child IDs requires_approval: bool = True time_category: str = "anytime" # morning, afternoon, evening, night, anytime claim_allowance_minutes: int = 0 # Grace minutes past period end during which the chore stays claimable; 0 = no grace. Night chores still cap at midnight. daily_limit: int = 1 completion_sound: str = "coin" # Sound to play on completion difficulty: str = "medium" # easy | medium | hard — scales awarded points by the tier multiplier (medium = ×1.0 baseline) # Scheduling # schedule_mode: "specific_days" = show on selected days of week (Mode A) # "recurring" = rolling window recurrence (Mode B) schedule_mode: str = "specific_days" due_days: list[str] = field(default_factory=list) # Mode A: days to show chore # Mode B fields recurrence: str = "weekly" # every_2_days | weekly | every_2_weeks | monthly | every_3_months | every_6_months recurrence_day: str = "" # optional: which day of week for weekly/every_2_weeks recurrence_start: str = "" # optional: ISO date anchor for every_2_days first_occurrence_mode: str = "available_immediately" # available_immediately | wait_for_first_occurrence # Dynamic visibility visibility_entity: str = "" # optional: entity_id to check for visibility visibility_state: str = "on" # state/value that makes chore visible (e.g. "on", "true", "123") visibility_operator: str = "equals" # equals, gte, lte, gt, lt, not_equals # One-shot chore fields enabled: bool = True # False = soft-disabled (completed or expired) disabled_for: list[str] = field(default_factory=list) # Child IDs this chore is disabled for depends_on: list[str] = field(default_factory=list) # Chore IDs that must be approved-completed today before this is available created_date: str = "" # ISO date for one-shot expiry, e.g. "2026-04-16" expires_on: str = "" # optional ISO end date; chore auto-disables the day after # Time-of-day incentive: when due_time (HH:MM) is set, a completion at/before # it earns +early_bonus, after it loses late_penalty (applied to the award). due_time: str = "" early_bonus: int = 0 late_penalty: int = 0 # Mandatory chores (#532): when True, a missed completion at the chore's # time-period end raises a parent review item (Apply Penalty / Postpone / # Dismiss). mandatory_penalty_points is the fixed deduction Apply Penalty applies. mandatory: bool = False mandatory_penalty_points: int = 0 require_photo: bool = False # require an evidence photo; forces parent approval # Dynamic assignment (sibling rotation) assignment_mode: str = "everyone" # everyone | alternating | random assignment_rotation_anchor: str = "" # ISO date; day-0 of the rotation for alternating assignment_current_child_id: str = "" # cached active child ID for today (computed at midnight and on create/update) require_availability: bool = False # When True, skip children whose availability entity says they're unavailable # Skip state (ephemeral: cleared at midnight when skip_date != today) skip_date: str = "" # ISO date the skip applies to ("" = no active skip) skip_count: int = 0 # number of times skipped today; added to rotation index # Calendar publish: list of HA calendar entity ids to mirror the chore to. Any number supported. publish_calendar_entities: list[str] = field(default_factory=list) # ISO dates already written to the configured calendars. Used for both # idempotence (don't re-publish the same day) and projection cleanup # (entries < today are pruned before each publish pass). publish_calendar_published_dates: list[str] = field(default_factory=list) # Bonus sub-tasks: optional extra-credit tasks that unlock after the parent chore is completed bonus_subtasks: list[BonusSubTask] = field(default_factory=list) # Timed task fields task_type: str = "standard" # "standard" | "timed" timed_rate_points: int = 10 # points awarded per rate window timed_rate_minutes: int = 5 # rate window size in minutes timed_max_daily_minutes: int = 0 # 0 = unlimited; caps total daily duration id: str = field(default_factory=generate_id) @classmethod def from_dict(cls, data: dict[str, Any]) -> Chore: """Create a Chore from a dictionary.""" # Migration: if old data has due_days set but no schedule_mode, # treat as specific_days. Otherwise default to specific_days. schedule_mode = data.get("schedule_mode", "specific_days") # If old data has due_days but schedule_mode wasn't stored, preserve as specific_days if "schedule_mode" not in data and data.get("due_days"): schedule_mode = "specific_days" return cls( name=data.get("name", ""), points=data.get("points", 10), description=data.get("description", ""), assigned_to=list(data.get("assigned_to", [])), requires_approval=data.get("requires_approval", True), time_category=data.get("time_category", "anytime"), claim_allowance_minutes=max(0, int(data.get("claim_allowance_minutes", 0) or 0)), daily_limit=data.get("daily_limit", 1), completion_sound=data.get("completion_sound", "coin"), difficulty=data.get("difficulty", "medium"), schedule_mode=schedule_mode, due_days=list(data.get("due_days", [])), recurrence=data.get("recurrence", "weekly"), recurrence_day=data.get("recurrence_day", ""), recurrence_start=data.get("recurrence_start", ""), first_occurrence_mode=data.get("first_occurrence_mode", "available_immediately"), visibility_entity=data.get("visibility_entity", ""), visibility_state=data.get("visibility_state", "on"), visibility_operator=data.get("visibility_operator", "equals"), enabled=data.get("enabled", True), disabled_for=list(data.get("disabled_for", [])), depends_on=list(data.get("depends_on", []) or []), created_date=data.get("created_date", ""), expires_on=data.get("expires_on", ""), due_time=data.get("due_time", ""), early_bonus=int(data.get("early_bonus", 0) or 0), late_penalty=int(data.get("late_penalty", 0) or 0), mandatory=bool(data.get("mandatory", False)), mandatory_penalty_points=max(0, int(data.get("mandatory_penalty_points", 0) or 0)), require_photo=data.get("require_photo", False), assignment_mode=data.get("assignment_mode", "everyone"), assignment_rotation_anchor=data.get("assignment_rotation_anchor", ""), assignment_current_child_id=data.get("assignment_current_child_id", ""), require_availability=data.get("require_availability", False), skip_date=data.get("skip_date", ""), skip_count=int(data.get("skip_count", 0) or 0), publish_calendar_entities=list(data.get("publish_calendar_entities", [])), # Back-compat: old records stored a single ISO date in # `publish_calendar_last_date`. Seed the new list with it so we # don't immediately republish today on the next tick after upgrade. publish_calendar_published_dates=( list(data.get("publish_calendar_published_dates", [])) or ([data["publish_calendar_last_date"]] if data.get("publish_calendar_last_date") else []) ), bonus_subtasks=[BonusSubTask.from_dict(b) for b in data.get("bonus_subtasks", [])], task_type=data.get("task_type", "standard"), timed_rate_points=data.get("timed_rate_points", 10), timed_rate_minutes=max(1, int(data.get("timed_rate_minutes", 5) or 5)), timed_max_daily_minutes=max(0, int(data.get("timed_max_daily_minutes", 0) or 0)), id=data.get("id", generate_id()), ) def to_dict(self) -> dict[str, Any]: """Convert to dictionary.""" return { "name": self.name, "points": self.points, "description": self.description, "assigned_to": self.assigned_to, "requires_approval": self.requires_approval, "time_category": self.time_category, "claim_allowance_minutes": self.claim_allowance_minutes, "daily_limit": self.daily_limit, "completion_sound": self.completion_sound, "difficulty": self.difficulty, "schedule_mode": self.schedule_mode, "due_days": self.due_days, "recurrence": self.recurrence, "recurrence_day": self.recurrence_day, "recurrence_start": self.recurrence_start, "first_occurrence_mode": self.first_occurrence_mode, "visibility_entity": self.visibility_entity, "visibility_state": self.visibility_state, "visibility_operator": self.visibility_operator, "enabled": self.enabled, "disabled_for": self.disabled_for, "depends_on": self.depends_on, "created_date": self.created_date, "expires_on": self.expires_on, "due_time": self.due_time, "early_bonus": self.early_bonus, "late_penalty": self.late_penalty, "mandatory": self.mandatory, "mandatory_penalty_points": self.mandatory_penalty_points, "require_photo": self.require_photo, "assignment_mode": self.assignment_mode, "assignment_rotation_anchor": self.assignment_rotation_anchor, "assignment_current_child_id": self.assignment_current_child_id, "require_availability": self.require_availability, "skip_date": self.skip_date, "skip_count": self.skip_count, "publish_calendar_entities": self.publish_calendar_entities, "publish_calendar_published_dates": self.publish_calendar_published_dates, "bonus_subtasks": [b.to_dict() for b in self.bonus_subtasks], "task_type": self.task_type, "timed_rate_points": self.timed_rate_points, "timed_rate_minutes": self.timed_rate_minutes, "timed_max_daily_minutes": self.timed_max_daily_minutes, "id": self.id, } @dataclass class Reward: """Represents a reward.""" name: str cost: int = 50 description: str = "" icon: str = "mdi:gift" assigned_to: list[str] = field(default_factory=list) # List of child IDs, empty means all children is_jackpot: bool = False # If True, pool stars from all assigned children together pool_enabled: bool = False # If True, this reward uses pool-mode (savings jar) semantics quantity: int | None = None # Remaining stock; None means unlimited expires_at: str | None = None # ISO "YYYY-MM-DD"; None means no deadline # Auto-restock: periodically reset `quantity` back to restock_amount. restock_enabled: bool = False restock_amount: int = 0 restock_period: str = "weekly" # daily | weekly (Mon) | monthly (1st) restock_last: str = "" # ISO date this reward was last restocked id: str = field(default_factory=generate_id) def __post_init__(self) -> None: # Jackpots are inherently pooled (#552): everyone deposits into the shared # jar, so pool mode is always on. Enforced here so every construction path # — WS add, service add, and storage load (legacy migration) — stays # consistent and never yields an unclaimable single-child jackpot. if self.is_jackpot: self.pool_enabled = True @classmethod def from_dict(cls, data: dict[str, Any]) -> Reward: """Create a Reward from a dictionary.""" raw_quantity = data.get("quantity") quantity = int(raw_quantity) if raw_quantity is not None else None raw_expires = data.get("expires_at") expires_at = str(raw_expires) if raw_expires else None return cls( name=data.get("name", ""), cost=data.get("cost", 50), description=data.get("description", ""), icon=data.get("icon", "mdi:gift"), assigned_to=list(data.get("assigned_to", [])), is_jackpot=data.get("is_jackpot", False), pool_enabled=data.get("pool_enabled", False), quantity=quantity, expires_at=expires_at, restock_enabled=data.get("restock_enabled", False), restock_amount=int(data.get("restock_amount", 0) or 0), restock_period=data.get("restock_period", "weekly"), restock_last=data.get("restock_last", ""), id=data.get("id", generate_id()), ) def to_dict(self) -> dict[str, Any]: """Convert to dictionary.""" return { "name": self.name, "cost": self.cost, "description": self.description, "icon": self.icon, "assigned_to": self.assigned_to, "is_jackpot": self.is_jackpot, "pool_enabled": self.pool_enabled, "quantity": self.quantity, "expires_at": self.expires_at, "restock_enabled": self.restock_enabled, "restock_amount": self.restock_amount, "restock_period": self.restock_period, "restock_last": self.restock_last, "id": self.id, } @dataclass class Quest: """A chore chain: an ordered sequence of chores that awards a bonus. A child advances one step each time they complete (and have approved) the chore at their current step. Finishing the final step awards ``bonus_points``. If ``repeatable`` the child's progress resets so they can run the chain again; otherwise it stays complete. """ name: str description: str = "" icon: str = "mdi:map-marker-path" steps: list[str] = field(default_factory=list) # ordered chore IDs bonus_points: int = 25 assigned_to: list[str] = field(default_factory=list) # child IDs; empty = all repeatable: bool = False active: bool = True id: str = field(default_factory=generate_id) @classmethod def from_dict(cls, data: dict[str, Any]) -> Quest: return cls( name=data.get("name", ""), description=data.get("description", ""), icon=data.get("icon", "mdi:map-marker-path"), steps=[str(s) for s in data.get("steps", [])], bonus_points=int(data.get("bonus_points", 25) or 0), assigned_to=list(data.get("assigned_to", [])), repeatable=bool(data.get("repeatable", False)), active=bool(data.get("active", True)), id=data.get("id", generate_id()), ) def to_dict(self) -> dict[str, Any]: return { "name": self.name, "description": self.description, "icon": self.icon, "steps": self.steps, "bonus_points": self.bonus_points, "assigned_to": self.assigned_to, "repeatable": self.repeatable, "active": self.active, "id": self.id, } @dataclass class Challenge: """A time-boxed, metric-based challenge that refreshes each period. Within each period (``daily`` or ``weekly``) a child works toward a ``target`` of a ``metric`` (chores completed or points earned). Hitting the target awards ``bonus_points`` once; progress and the award reset automatically when the period rolls over. """ name: str description: str = "" icon: str = "mdi:trophy-outline" scope: str = "daily" # daily | weekly metric: str = "chores" # chores | points target: int = 3 bonus_points: int = 15 assigned_to: list[str] = field(default_factory=list) # child IDs; empty = all active: bool = True id: str = field(default_factory=generate_id) @classmethod def from_dict(cls, data: dict[str, Any]) -> Challenge: return cls( name=data.get("name", ""), description=data.get("description", ""), icon=data.get("icon", "mdi:trophy-outline"), scope=data.get("scope", "daily"), metric=data.get("metric", "chores"), target=int(data.get("target", 3) or 0), bonus_points=int(data.get("bonus_points", 15) or 0), assigned_to=list(data.get("assigned_to", [])), active=bool(data.get("active", True)), id=data.get("id", generate_id()), ) def to_dict(self) -> dict[str, Any]: return { "name": self.name, "description": self.description, "icon": self.icon, "scope": self.scope, "metric": self.metric, "target": self.target, "bonus_points": self.bonus_points, "assigned_to": self.assigned_to, "active": self.active, "id": self.id, } @dataclass class ChoreCompletion: """Represents a chore completion record.""" chore_id: str child_id: str completed_at: datetime approved: bool = False approved_at: datetime | None = None points_awarded: int = 0 bonus_subtask_id: str = "" # Non-empty = this completion is for a bonus sub-task timed_duration_seconds: int = 0 photo_url: str = "" # optional evidence photo (URL/path) attached at completion id: str = field(default_factory=generate_id) @classmethod def from_dict(cls, data: dict[str, Any]) -> ChoreCompletion: """Create a ChoreCompletion from a dictionary.""" completed_at = parse_datetime(data.get("completed_at")) approved_at = parse_datetime(data.get("approved_at")) return cls( chore_id=data.get("chore_id", ""), child_id=data.get("child_id", ""), completed_at=completed_at or datetime.now(timezone.utc), approved=data.get("approved", False), approved_at=approved_at, points_awarded=data.get("points_awarded", 0), bonus_subtask_id=data.get("bonus_subtask_id", ""), timed_duration_seconds=data.get("timed_duration_seconds", 0), photo_url=data.get("photo_url", ""), id=data.get("id", generate_id()), ) def to_dict(self) -> dict[str, Any]: """Convert to dictionary.""" return { "chore_id": self.chore_id, "child_id": self.child_id, "completed_at": format_datetime(self.completed_at), "approved": self.approved, "approved_at": format_datetime(self.approved_at), "points_awarded": self.points_awarded, "bonus_subtask_id": self.bonus_subtask_id, "timed_duration_seconds": self.timed_duration_seconds, "photo_url": self.photo_url, "id": self.id, } @dataclass class MandatoryMiss: """A mandatory chore left incomplete when its time-period window closed (#532). One per (chore, child, day). Only pending items are persisted; resolving one (penalty/postpone/dismiss) removes it. penalty_points is snapshotted at creation so later edits to the chore don't change an outstanding miss. """ chore_id: str child_id: str due_date: str # ISO date the chore was missed period_id: str # the window that closed ("anytime" for all-day) penalty_points: int = 0 postpone_count: int = 0 escalation_stage: int = 0 # 0=none 1=nudged 2=reminded 3=parent-alerted (FEAT-6) created_at: str = field(default_factory=dt_util_now_iso) id: str = field(default_factory=generate_id) @classmethod def from_dict(cls, data: dict[str, Any]) -> "MandatoryMiss": """Create a MandatoryMiss from a dictionary.""" return cls( chore_id=data.get("chore_id", ""), child_id=data.get("child_id", ""), due_date=data.get("due_date", ""), period_id=data.get("period_id", "anytime"), penalty_points=max(0, int(data.get("penalty_points", 0) or 0)), postpone_count=max(0, int(data.get("postpone_count", 0) or 0)), escalation_stage=max(0, int(data.get("escalation_stage", 0) or 0)), created_at=data.get("created_at", "") or dt_util_now_iso(), id=data.get("id", generate_id()), ) def to_dict(self) -> dict[str, Any]: """Convert to dictionary.""" return { "chore_id": self.chore_id, "child_id": self.child_id, "due_date": self.due_date, "period_id": self.period_id, "penalty_points": self.penalty_points, "postpone_count": self.postpone_count, "escalation_stage": self.escalation_stage, "created_at": self.created_at, "id": self.id, } @dataclass class RewardClaim: """Represents a reward claim.""" reward_id: str child_id: str claimed_at: datetime approved: bool = False approved_at: datetime | None = None id: str = field(default_factory=generate_id) @classmethod def from_dict(cls, data: dict[str, Any]) -> RewardClaim: """Create a RewardClaim from a dictionary.""" claimed_at = parse_datetime(data.get("claimed_at")) approved_at = parse_datetime(data.get("approved_at")) return cls( reward_id=data.get("reward_id", ""), child_id=data.get("child_id", ""), claimed_at=claimed_at or datetime.now(timezone.utc), approved=data.get("approved", False), approved_at=approved_at, id=data.get("id", generate_id()), ) def to_dict(self) -> dict[str, Any]: """Convert to dictionary.""" return { "reward_id": self.reward_id, "child_id": self.child_id, "claimed_at": format_datetime(self.claimed_at), "approved": self.approved, "approved_at": format_datetime(self.approved_at), "id": self.id, } @dataclass class PoolAllocation: """Represents points a child has deposited into a specific reward's pool.""" child_id: str reward_id: str allocated_points: int = 0 id: str = field(default_factory=generate_id) @classmethod def from_dict(cls, data: dict[str, Any]) -> "PoolAllocation": """Create a PoolAllocation from a dictionary.""" return cls( child_id=data.get("child_id", ""), reward_id=data.get("reward_id", ""), allocated_points=data.get("allocated_points", 0), id=data.get("id", generate_id()), ) def to_dict(self) -> dict[str, Any]: """Convert to dictionary.""" return { "child_id": self.child_id, "reward_id": self.reward_id, "allocated_points": self.allocated_points, "id": self.id, } @dataclass class Penalty: """Represents a penalty that deducts points from a child.""" name: str points: int # Points to deduct (always positive; deduction is applied on use) description: str = "" icon: str = "mdi:alert-circle-outline" assigned_to: list[str] = field(default_factory=list) # Child IDs who can receive this penalty (empty = all) id: str = field(default_factory=generate_id) @classmethod def from_dict(cls, data: dict[str, Any]) -> "Penalty": """Create a Penalty from a dictionary.""" return cls( id=data.get("id", generate_id()), name=data.get("name", ""), # Always positive — a negative value would *grant* points on use points=max(0, int(data.get("points", 0) or 0)), description=data.get("description", ""), icon=data.get("icon", "mdi:alert-circle-outline"), assigned_to=list(data.get("assigned_to", [])), ) def to_dict(self) -> dict[str, Any]: """Convert to dictionary.""" return { "id": self.id, "name": self.name, "points": self.points, "description": self.description, "icon": self.icon, "assigned_to": self.assigned_to, } @dataclass class Bonus: """Represents a bonus that awards points to a child.""" name: str points: int # Points to award (always positive) description: str = "" icon: str = "mdi:star-circle-outline" assigned_to: list[str] = field(default_factory=list) # Child IDs who can receive this bonus (empty = all) id: str = field(default_factory=generate_id) @classmethod def from_dict(cls, data: dict[str, Any]) -> "Bonus": """Create a Bonus from a dictionary.""" return cls( id=data.get("id", generate_id()), name=data.get("name", ""), # Always positive — a negative value would *deduct* points on use points=max(0, int(data.get("points", 0) or 0)), description=data.get("description", ""), icon=data.get("icon", "mdi:star-circle-outline"), assigned_to=list(data.get("assigned_to", [])), ) def to_dict(self) -> dict[str, Any]: """Convert to dictionary.""" return { "id": self.id, "name": self.name, "points": self.points, "description": self.description, "icon": self.icon, "assigned_to": self.assigned_to, } @dataclass class BadgeCriterion: """A single threshold rule used by Badge evaluation.""" metric: str operator: str = ">=" value: int = 0 @classmethod def from_dict(cls, data: dict[str, Any]) -> "BadgeCriterion": return cls( metric=data.get("metric", ""), operator=data.get("operator", ">="), value=int(data.get("value", 0) or 0), ) def to_dict(self) -> dict[str, Any]: return {"metric": self.metric, "operator": self.operator, "value": self.value} @dataclass class PointsTransaction: """Represents a manual points adjustment (add or remove).""" child_id: str points: int # positive = added, negative = removed reason: str = "" created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) id: str = field(default_factory=generate_id) # Groups transactions that must be reversed together (e.g. both legs of a # gift). Empty for standalone transactions. Pre-upgrade rows load as "". link_id: str = "" @classmethod def from_dict(cls, data: dict[str, Any]) -> PointsTransaction: """Create a PointsTransaction from a dictionary.""" created_at = parse_datetime(data.get("created_at")) return cls( child_id=data.get("child_id", ""), points=data.get("points", 0), reason=data.get("reason", ""), created_at=created_at or datetime.now(timezone.utc), id=data.get("id", generate_id()), link_id=data.get("link_id", ""), ) def to_dict(self) -> dict[str, Any]: """Convert to dictionary.""" return { "child_id": self.child_id, "points": self.points, "reason": self.reason, "created_at": format_datetime(self.created_at), "id": self.id, "link_id": self.link_id, } @dataclass class Badge: """An achievement badge definition (built-in or custom).""" name: str description: str = "" icon: str = "mdi:trophy" tier: str = "bronze" # "bronze" | "silver" | "gold" | "platinum" point_bonus: int = 0 criteria: list[BadgeCriterion] = field(default_factory=list) combinator: str = "AND" # reserved; v1 always AND assigned_to: list[str] = field(default_factory=list) # empty = all kids builtin: bool = False enabled: bool = True notify_on_earn: bool = True created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) id: str = field(default_factory=generate_id) @classmethod def from_dict(cls, data: dict[str, Any]) -> "Badge": created_at = parse_datetime(data.get("created_at")) return cls( id=data.get("id", generate_id()), name=data.get("name", ""), description=data.get("description", ""), icon=data.get("icon", "mdi:trophy"), tier=data.get("tier", "bronze"), point_bonus=int(data.get("point_bonus", 0) or 0), criteria=[BadgeCriterion.from_dict(c) for c in data.get("criteria", [])], combinator=data.get("combinator", "AND"), assigned_to=list(data.get("assigned_to", [])), builtin=bool(data.get("builtin", False)), enabled=bool(data.get("enabled", True)), notify_on_earn=bool(data.get("notify_on_earn", True)), created_at=created_at or datetime.now(timezone.utc), ) def to_dict(self) -> dict[str, Any]: return { "id": self.id, "name": self.name, "description": self.description, "icon": self.icon, "tier": self.tier, "point_bonus": self.point_bonus, "criteria": [c.to_dict() for c in self.criteria], "combinator": self.combinator, "assigned_to": self.assigned_to, "builtin": self.builtin, "enabled": self.enabled, "notify_on_earn": self.notify_on_earn, "created_at": format_datetime(self.created_at), } @dataclass class AwardedBadge: """A badge earn record for a specific child.""" child_id: str badge_id: str earned_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) manually_awarded: bool = False silent: bool = False # True = retroactive backfill; suppresses notify + bonus bonus_credited: int = 0 # actual point_bonus credited at earn time id: str = field(default_factory=generate_id) @classmethod def from_dict(cls, data: dict[str, Any]) -> "AwardedBadge": earned_at = parse_datetime(data.get("earned_at")) return cls( id=data.get("id", generate_id()), child_id=data.get("child_id", ""), badge_id=data.get("badge_id", ""), earned_at=earned_at or datetime.now(timezone.utc), manually_awarded=bool(data.get("manually_awarded", False)), silent=bool(data.get("silent", False)), bonus_credited=int(data.get("bonus_credited", 0) or 0), ) def to_dict(self) -> dict[str, Any]: return { "id": self.id, "child_id": self.child_id, "badge_id": self.badge_id, "earned_at": format_datetime(self.earned_at), "manually_awarded": self.manually_awarded, "silent": self.silent, "bonus_credited": self.bonus_credited, } @dataclass class TaskGroup: """Groups chores so assignments stay coordinated across them. Policies: - sticky: followers are forced onto the leader chore's assignee today. - spread: group members are assigned distinct children today (wraps when there are more chores than children). `chore_ids` order is meaningful: index 0 is the leader for sticky; for spread it's the iteration order when walking the "already used" set. """ name: str policy: str = "sticky" # sticky | spread chore_ids: list[str] = field(default_factory=list) id: str = field(default_factory=generate_id) @classmethod def from_dict(cls, data: dict[str, Any]) -> "TaskGroup": return cls( name=data.get("name", ""), policy=data.get("policy", "sticky"), chore_ids=list(data.get("chore_ids", [])), id=data.get("id", generate_id()), ) def to_dict(self) -> dict[str, Any]: return { "name": self.name, "policy": self.policy, "chore_ids": self.chore_ids, "id": self.id, } @dataclass class ParentRecipient: """A configured parent notification target.""" name: str notify_service: str enabled: bool = True id: str = field(default_factory=lambda: f"parent:{generate_id()}") @classmethod def from_dict(cls, data: dict[str, Any]) -> ParentRecipient: rid = data.get("id") or f"parent:{generate_id()}" return cls( name=data.get("name", ""), notify_service=data.get("notify_service", ""), enabled=bool(data.get("enabled", True)), id=rid, ) def to_dict(self) -> dict[str, Any]: return { "id": self.id, "name": self.name, "notify_service": self.notify_service, "enabled": self.enabled, } @dataclass class NotificationRoute: """Per-recipient notification settings inside a NotificationConfig.""" enabled: bool = False time: str | None = None # "HH:MM" — only meaningful for per-recipient-time types @classmethod def from_dict(cls, data: dict[str, Any]) -> NotificationRoute: return cls( enabled=bool(data.get("enabled", False)), time=data.get("time"), ) def to_dict(self) -> dict[str, Any]: d: dict[str, Any] = {"enabled": self.enabled} if self.time is not None: d["time"] = self.time return d @dataclass class NotificationConfig: """Stored configuration for one notification type.""" type_id: str master_enabled: bool = False routes: dict[str, NotificationRoute] = field(default_factory=dict) @classmethod def from_dict(cls, data: dict[str, Any]) -> NotificationConfig: raw_routes = data.get("routes", {}) or {} return cls( type_id=data.get("type_id", ""), master_enabled=bool(data.get("master_enabled", False)), routes={ rid: NotificationRoute.from_dict(rdata) for rid, rdata in raw_routes.items() }, ) def to_dict(self) -> dict[str, Any]: return { "type_id": self.type_id, "master_enabled": self.master_enabled, "routes": {rid: r.to_dict() for rid, r in self.routes.items()}, } @dataclass class CustomNotification: """Parent-defined scheduled reminder.""" name: str message_template: str time: str # "HH:MM" day_mask: int = 0b1111111 # bit0=Mon … bit6=Sun recipient_ids: list[str] = field(default_factory=list) enabled: bool = True id: str = field(default_factory=generate_id) @classmethod def from_dict(cls, data: dict[str, Any]) -> CustomNotification: return cls( name=data.get("name", ""), message_template=data.get("message_template", ""), time=data.get("time", "20:00"), day_mask=int(data.get("day_mask", 0b1111111)), recipient_ids=list(data.get("recipient_ids", [])), enabled=bool(data.get("enabled", True)), id=data.get("id") or generate_id(), ) def to_dict(self) -> dict[str, Any]: return { "id": self.id, "name": self.name, "message_template": self.message_template, "time": self.time, "day_mask": self.day_mask, "recipient_ids": list(self.recipient_ids), "enabled": self.enabled, }