348 files
This commit is contained in:
@@ -68,6 +68,21 @@ def format_datetime(dt: datetime | None) -> str | None:
|
||||
return utc_dt.isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def optional_float(value: Any) -> float | None:
|
||||
"""Coerce a stored/user value to a float, or None when it isn't set.
|
||||
|
||||
Used for limits where 0 is a meaningful value (a 0 °C threshold is real),
|
||||
so the usual "0 means off" sentinel can't be used. Empty strings and
|
||||
unparseable values both read as "no limit".
|
||||
"""
|
||||
if value is None or value == "":
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class TimedSession:
|
||||
"""Tracks an active or paused timed-task session."""
|
||||
@@ -159,6 +174,10 @@ class 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)
|
||||
# Guest profiles (#690): a visiting cousin gets a temporary child that
|
||||
# expires on its own and stays out of the family leaderboard.
|
||||
is_guest: bool = False
|
||||
guest_expires_on: str = "" # ISO date; profile auto-archives the day after
|
||||
id: str = field(default_factory=generate_id)
|
||||
|
||||
@classmethod
|
||||
@@ -178,6 +197,8 @@ class Child:
|
||||
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", [])),
|
||||
is_guest=bool(data.get("is_guest", False)),
|
||||
guest_expires_on=str(data.get("guest_expires_on", "") or ""),
|
||||
availability_entity=data.get("availability_entity", ""),
|
||||
availability_inverted=data.get("availability_inverted", False),
|
||||
unavailability_entity=data.get("unavailability_entity", ""),
|
||||
@@ -208,6 +229,8 @@ class Child:
|
||||
"streak_paused": self.streak_paused,
|
||||
"streak_milestones_achieved": self.streak_milestones_achieved,
|
||||
"awarded_perfect_weeks": self.awarded_perfect_weeks,
|
||||
"is_guest": self.is_guest,
|
||||
"guest_expires_on": self.guest_expires_on,
|
||||
"availability_entity": self.availability_entity,
|
||||
"availability_inverted": self.availability_inverted,
|
||||
"unavailability_entity": self.unavailability_entity,
|
||||
@@ -236,6 +259,9 @@ class Chore:
|
||||
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
|
||||
# Optional picture for the chore. Text-free pre-reader mode (#683) needs
|
||||
# one per chore; everything else falls back to the time-of-day icon.
|
||||
icon: str = ""
|
||||
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)
|
||||
@@ -251,12 +277,28 @@ class Chore:
|
||||
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
|
||||
# Weather-aware chores (#673): hide an outdoor chore while the chosen
|
||||
# weather.* entity reports unsuitable conditions. Every limit is optional
|
||||
# and evaluation is fail-open — a missing or unavailable entity never hides
|
||||
# a chore. Temperature/wind limits are read from the entity's attributes in
|
||||
# whatever unit HA reports them (native_temperature/wind_speed_unit).
|
||||
weather_entity: str = ""
|
||||
weather_block_conditions: list[str] = field(default_factory=list) # e.g. ["rainy", "pouring"]
|
||||
weather_temp_min: float | None = None # block below this temperature
|
||||
weather_temp_max: float | None = None # block above this temperature
|
||||
weather_wind_max: float | None = None # block above this wind speed
|
||||
# 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
|
||||
# Reactive chores (#674): a short-lived chore raised by an automation, e.g.
|
||||
# "the washing machine finished — empty it within 30 minutes". deadline_at
|
||||
# is an ISO datetime after which the chore is unavailable and gets
|
||||
# soft-disabled; beat it and speed_bonus_points is added to the award.
|
||||
deadline_at: str = ""
|
||||
speed_bonus_points: int = 0
|
||||
# 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 = ""
|
||||
@@ -311,6 +353,7 @@ class Chore:
|
||||
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"),
|
||||
icon=str(data.get("icon", "") or ""),
|
||||
difficulty=data.get("difficulty", "medium"),
|
||||
schedule_mode=schedule_mode,
|
||||
due_days=list(data.get("due_days", [])),
|
||||
@@ -321,6 +364,13 @@ class Chore:
|
||||
visibility_entity=data.get("visibility_entity", ""),
|
||||
visibility_state=data.get("visibility_state", "on"),
|
||||
visibility_operator=data.get("visibility_operator", "equals"),
|
||||
weather_entity=data.get("weather_entity", ""),
|
||||
weather_block_conditions=list(data.get("weather_block_conditions", [])),
|
||||
weather_temp_min=optional_float(data.get("weather_temp_min")),
|
||||
weather_temp_max=optional_float(data.get("weather_temp_max")),
|
||||
weather_wind_max=optional_float(data.get("weather_wind_max")),
|
||||
deadline_at=data.get("deadline_at", ""),
|
||||
speed_bonus_points=int(data.get("speed_bonus_points", 0) or 0),
|
||||
enabled=data.get("enabled", True),
|
||||
disabled_for=list(data.get("disabled_for", [])),
|
||||
depends_on=list(data.get("depends_on", []) or []),
|
||||
@@ -366,6 +416,7 @@ class Chore:
|
||||
"claim_allowance_minutes": self.claim_allowance_minutes,
|
||||
"daily_limit": self.daily_limit,
|
||||
"completion_sound": self.completion_sound,
|
||||
"icon": self.icon,
|
||||
"difficulty": self.difficulty,
|
||||
"schedule_mode": self.schedule_mode,
|
||||
"due_days": self.due_days,
|
||||
@@ -376,6 +427,13 @@ class Chore:
|
||||
"visibility_entity": self.visibility_entity,
|
||||
"visibility_state": self.visibility_state,
|
||||
"visibility_operator": self.visibility_operator,
|
||||
"weather_entity": self.weather_entity,
|
||||
"weather_block_conditions": self.weather_block_conditions,
|
||||
"weather_temp_min": self.weather_temp_min,
|
||||
"weather_temp_max": self.weather_temp_max,
|
||||
"weather_wind_max": self.weather_wind_max,
|
||||
"deadline_at": self.deadline_at,
|
||||
"speed_bonus_points": self.speed_bonus_points,
|
||||
"enabled": self.enabled,
|
||||
"disabled_for": self.disabled_for,
|
||||
"depends_on": self.depends_on,
|
||||
@@ -422,6 +480,11 @@ class Reward:
|
||||
restock_amount: int = 0
|
||||
restock_period: str = "weekly" # daily | weekly (Mon) | monthly (1st)
|
||||
restock_last: str = "" # ISO date this reward was last restocked
|
||||
# Timed unlock (#678): on approval, turn this entity on and turn it back
|
||||
# off after unlock_minutes. The entity must be on the parent's allowlist,
|
||||
# checked both at save time and again when it fires.
|
||||
unlock_entity: str = ""
|
||||
unlock_minutes: int = 0
|
||||
id: str = field(default_factory=generate_id)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
@@ -451,6 +514,8 @@ class Reward:
|
||||
expires_at=expires_at,
|
||||
restock_enabled=data.get("restock_enabled", False),
|
||||
restock_amount=int(data.get("restock_amount", 0) or 0),
|
||||
unlock_entity=str(data.get("unlock_entity", "") or ""),
|
||||
unlock_minutes=int(data.get("unlock_minutes", 0) or 0),
|
||||
restock_period=data.get("restock_period", "weekly"),
|
||||
restock_last=data.get("restock_last", ""),
|
||||
id=data.get("id", generate_id()),
|
||||
@@ -472,6 +537,8 @@ class Reward:
|
||||
"restock_amount": self.restock_amount,
|
||||
"restock_period": self.restock_period,
|
||||
"restock_last": self.restock_last,
|
||||
"unlock_entity": self.unlock_entity,
|
||||
"unlock_minutes": self.unlock_minutes,
|
||||
"id": self.id,
|
||||
}
|
||||
|
||||
@@ -999,6 +1066,73 @@ class TaskGroup:
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScheduledChange:
|
||||
"""A chore edit queued to take effect on a future date (#675).
|
||||
|
||||
"From 1 September this chore is worth 20 points" / "from Monday it's
|
||||
Ella's". ``changes`` maps chore field -> new value; only the fields in
|
||||
``SCHEDULED_CHANGE_FIELDS`` may be set, so a queued change can't rewrite
|
||||
runtime state like rotation anchors or publish history.
|
||||
|
||||
Applied changes are kept (not deleted) so the panel can show what happened
|
||||
and when — a silent config change is worse than no config change.
|
||||
"""
|
||||
|
||||
chore_id: str
|
||||
apply_on: str # ISO date, e.g. "2026-09-01"
|
||||
changes: dict[str, Any] = field(default_factory=dict)
|
||||
note: str = ""
|
||||
applied: bool = False
|
||||
applied_at: str = ""
|
||||
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]) -> "ScheduledChange":
|
||||
return cls(
|
||||
chore_id=data.get("chore_id", ""),
|
||||
apply_on=data.get("apply_on", ""),
|
||||
changes=dict(data.get("changes", {}) or {}),
|
||||
note=data.get("note", ""),
|
||||
applied=bool(data.get("applied", False)),
|
||||
applied_at=data.get("applied_at", ""),
|
||||
created_at=data.get("created_at", "") or dt_util_now_iso(),
|
||||
id=data.get("id", generate_id()),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"chore_id": self.chore_id,
|
||||
"apply_on": self.apply_on,
|
||||
"changes": self.changes,
|
||||
"note": self.note,
|
||||
"applied": self.applied,
|
||||
"applied_at": self.applied_at,
|
||||
"created_at": self.created_at,
|
||||
"id": self.id,
|
||||
}
|
||||
|
||||
|
||||
# Chore fields a scheduled change is allowed to set. Deliberately excludes
|
||||
# runtime state (assignment_current_child_id, skip_date, publish history) and
|
||||
# anything that would need extra coordination to change safely.
|
||||
SCHEDULED_CHANGE_FIELDS: dict[str, type | tuple[type, ...]] = {
|
||||
"points": int,
|
||||
"assigned_to": list,
|
||||
"enabled": bool,
|
||||
"time_category": str,
|
||||
"difficulty": str,
|
||||
"requires_approval": bool,
|
||||
"daily_limit": int,
|
||||
"due_days": list,
|
||||
"mandatory": bool,
|
||||
"mandatory_penalty_points": int,
|
||||
"description": str,
|
||||
"expires_on": str,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParentRecipient:
|
||||
"""A configured parent notification target."""
|
||||
@@ -1006,6 +1140,9 @@ class ParentRecipient:
|
||||
name: str
|
||||
notify_service: str
|
||||
enabled: bool = True
|
||||
# Presence entity for "whoever is home" routing (#687). Empty means this
|
||||
# parent is always considered available.
|
||||
presence_entity: str = ""
|
||||
id: str = field(default_factory=lambda: f"parent:{generate_id()}")
|
||||
|
||||
@classmethod
|
||||
@@ -1015,6 +1152,7 @@ class ParentRecipient:
|
||||
name=data.get("name", ""),
|
||||
notify_service=data.get("notify_service", ""),
|
||||
enabled=bool(data.get("enabled", True)),
|
||||
presence_entity=str(data.get("presence_entity", "") or ""),
|
||||
id=rid,
|
||||
)
|
||||
|
||||
@@ -1022,6 +1160,7 @@ class ParentRecipient:
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"presence_entity": self.presence_entity,
|
||||
"notify_service": self.notify_service,
|
||||
"enabled": self.enabled,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user