278 files
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, datetime, time
|
||||
from datetime import date, datetime
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -19,7 +19,10 @@ from ..const import (
|
||||
ScheduleType,
|
||||
)
|
||||
from ..helpers.dates import parse_iso_date
|
||||
from ..helpers.history import completed_entries
|
||||
from ..helpers.phases import current_phase
|
||||
from ..helpers.schedule import Schedule, read_legacy_fields
|
||||
from ..helpers.status import compute_status, effective_warning_days, is_past_schedule_time
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -155,14 +158,10 @@ class MaintenanceTask:
|
||||
|
||||
@property
|
||||
def current_phase_name(self) -> str | None:
|
||||
"""Name of the cycle phase currently due (#139), or None."""
|
||||
if not (self.phases and self.phase_sequence):
|
||||
return None
|
||||
from ..helpers.phases import clamp_phase_cursor
|
||||
|
||||
phase_id = self.phase_sequence[clamp_phase_cursor(self.phase_cursor, len(self.phase_sequence))]
|
||||
definition = self.phases.get(phase_id)
|
||||
return definition.get("name") if isinstance(definition, dict) else None
|
||||
"""Name of the cycle phase currently due (#139), or None — the model's
|
||||
view of ``helpers.phases.current_phase`` (the cursor clamp lives there)."""
|
||||
phase = current_phase({"phases": self.phases, "phase_sequence": self.phase_sequence, "phase_cursor": self.phase_cursor})
|
||||
return phase.get("name") if phase is not None else None
|
||||
|
||||
def _planned_grid_due(self) -> date | None:
|
||||
"""``next_due`` WITHOUT the postpone override — the drift-free grid.
|
||||
@@ -220,16 +219,14 @@ class MaintenanceTask:
|
||||
passed (in HA's configured TZ). Returns False when no `schedule_time` is set
|
||||
— that preserves the historical "due at midnight" semantic.
|
||||
"""
|
||||
if not self.schedule_time:
|
||||
return False
|
||||
try:
|
||||
# Tolerate both "HH:MM" (panel/WS) and "HH:MM:SS" (HA TimeSelector
|
||||
# in the config-flow) — take the first two components.
|
||||
parts = str(self.schedule_time).split(":")
|
||||
target = time(int(parts[0]), int(parts[1]))
|
||||
except (ValueError, TypeError, IndexError):
|
||||
return False
|
||||
return dt_util.now().time() >= target
|
||||
return is_past_schedule_time(self.schedule_time)
|
||||
|
||||
@property
|
||||
def effective_warning_days(self) -> int:
|
||||
"""The warning window capped at one interval in real days (#58) —
|
||||
what the status ladder compares ``days_until_due`` against; published
|
||||
to the coordinator payload for the dict twin (helpers.status)."""
|
||||
return effective_warning_days(self.warning_days, self._schedule().span_days())
|
||||
|
||||
@property
|
||||
def archived(self) -> bool:
|
||||
@@ -238,50 +235,21 @@ class MaintenanceTask:
|
||||
|
||||
@property
|
||||
def status(self) -> MaintenanceStatus:
|
||||
"""Determine the current status of this task."""
|
||||
# Archived takes precedence over everything — a retired task is inert.
|
||||
# Mirror this in helpers/status.compute_status_from_task_dict (the dict
|
||||
# twin used where only coordinator data is available).
|
||||
if self.archived_at is not None:
|
||||
return MaintenanceStatus.ARCHIVED
|
||||
"""Determine the current status of this task.
|
||||
|
||||
days = self.days_until_due
|
||||
|
||||
# Trigger ∧/∨ safety interval: with the "all" combinator BOTH legs must
|
||||
# be met — the trigger must have fired AND the due date been reached
|
||||
# (the interval is a minimum age, not a deadline). Default "any" keeps
|
||||
# the historical whichever-first behaviour. Mirror any change in
|
||||
# helpers/status.compute_status_from_task_dict (the dict twin).
|
||||
all_mode = (self.trigger_config or {}).get("trigger_combinator") == "all"
|
||||
time_met = days is None or days <= 0
|
||||
|
||||
# Trigger takes precedence
|
||||
if self._trigger_active and (not all_mode or time_met):
|
||||
return MaintenanceStatus.TRIGGERED
|
||||
|
||||
if days is None:
|
||||
# Manual task or no schedule: always OK unless triggered
|
||||
return MaintenanceStatus.OK
|
||||
|
||||
if all_mode and not self._trigger_active:
|
||||
# The elapsed interval alone never actions an "all" task.
|
||||
return MaintenanceStatus.OK
|
||||
|
||||
if days < 0:
|
||||
return MaintenanceStatus.OVERDUE
|
||||
# Sub-day refinement: same-day past schedule_time also counts as overdue.
|
||||
# Without this, a task with schedule_time="09:00" would only flip at midnight.
|
||||
if days == 0 and self._is_past_schedule_time():
|
||||
return MaintenanceStatus.OVERDUE
|
||||
# Don't let the warning window exceed one interval, measured in real
|
||||
# days via the Schedule (a 6-*month* task must not collapse a 14-day
|
||||
# warning to min(14, 6) — issue #58). Single source: the Schedule, not
|
||||
# raw interval fields.
|
||||
span = self._schedule().span_days()
|
||||
effective_warning = min(self.warning_days, span) if span else self.warning_days
|
||||
if days <= effective_warning:
|
||||
return MaintenanceStatus.DUE_SOON
|
||||
return MaintenanceStatus.OK
|
||||
The ladder itself is ``helpers.status.compute_status`` — shared with
|
||||
the dict twin the entities use after a live trigger update — so the
|
||||
two cannot drift; this property only supplies the model's inputs.
|
||||
"""
|
||||
return compute_status(
|
||||
archived=self.archived_at is not None,
|
||||
enabled=self.enabled,
|
||||
days_until_due=self.days_until_due,
|
||||
trigger_active=self._trigger_active,
|
||||
all_mode=(self.trigger_config or {}).get("trigger_combinator") == "all",
|
||||
warning_days=self.effective_warning_days,
|
||||
past_schedule_time=self._is_past_schedule_time(),
|
||||
)
|
||||
|
||||
@property
|
||||
def can_complete_now(self) -> bool:
|
||||
@@ -317,7 +285,7 @@ class MaintenanceTask:
|
||||
@property
|
||||
def times_performed(self) -> int:
|
||||
"""Count the number of completed maintenance entries in history."""
|
||||
return sum(1 for entry in self.history if entry.get("type") == HistoryEntryType.COMPLETED)
|
||||
return len(completed_entries(self.history))
|
||||
|
||||
@property
|
||||
def total_cost(self) -> float:
|
||||
@@ -354,6 +322,21 @@ class MaintenanceTask:
|
||||
|
||||
# --- Methods ---
|
||||
|
||||
def would_be_latest(self, ts: datetime) -> bool:
|
||||
"""Whether a completion stamped ``ts`` would be the task's LATEST one
|
||||
(else it is a backfill). String comparison, deliberately - history
|
||||
timestamps mix TZ-aware (live completions) and naive (hand-edited)
|
||||
values, and the edit reconciliation already compares them as strings.
|
||||
last_performed (date-only ISO) joins the anchors: an imported or
|
||||
history-trimmed task has a cycle anchor but no lifecycle entries, and
|
||||
a backfill must not drag that anchor backwards either. A full
|
||||
timestamp on the same day sorts after the bare date, so a same-day
|
||||
completion still counts as latest."""
|
||||
anchors = [h.get("timestamp") or "" for h in self.history if h.get("type") in LIFECYCLE_HISTORY_TYPES]
|
||||
if self.last_performed:
|
||||
anchors.append(self.last_performed)
|
||||
return ts.isoformat() >= max(anchors, default="")
|
||||
|
||||
def complete(
|
||||
self,
|
||||
notes: str | None = None,
|
||||
@@ -399,10 +382,7 @@ class MaintenanceTask:
|
||||
# must not drag that anchor backwards either. A full timestamp on the
|
||||
# same day sorts after the bare date, so a same-day completion still
|
||||
# counts as latest.
|
||||
anchors = [h.get("timestamp") or "" for h in self.history if h.get("type") in LIFECYCLE_HISTORY_TYPES]
|
||||
if self.last_performed:
|
||||
anchors.append(self.last_performed)
|
||||
is_latest = ts_iso >= max(anchors, default="")
|
||||
is_latest = self.would_be_latest(ts)
|
||||
|
||||
# Phases (#139): the step being completed is the one currently due.
|
||||
# Resolved BEFORE the cursor advances; a pure backfill gets no phase
|
||||
|
||||
Reference in New Issue
Block a user