"""Time-boxing model and state machine for ADHD-friendly focus sessions. A TimeBox is a single focus session on a TODO (or standalone). It tracks an explicit, recoverable state machine so interruptions are never lost — you can always pause, resume, interrupt-with-a-reason, or abandon without shame. """ from __future__ import annotations from dataclasses import dataclass, field from datetime import datetime, timezone from enum import Enum from typing import Any, Optional class BoxState(str, Enum): """Lifecycle of a time-box. All states are explicit and auditable.""" open = "open" # created, not yet started running = "running" # actively counting down paused = "paused" # paused by choice, recoverable interrupted = "interrupted" # paused by external cause, recoverable completed = "completed" # finished deliberately (terminal) abandoned = "abandoned" # quit without finishing (terminal) expired = "expired" # timer ran to zero (terminal) # Adjacency list: which states each state may transition into. # No terminal state has outgoing edges. Interrupted is functionally paused # but tagged so the UI can surface it as a recoverable interruption. _TRANSITIONS: dict[BoxState, set[BoxState]] = { BoxState.open: {BoxState.running, BoxState.abandoned}, BoxState.running: { BoxState.paused, BoxState.interrupted, BoxState.completed, BoxState.abandoned, BoxState.expired, }, BoxState.paused: { BoxState.running, BoxState.interrupted, BoxState.completed, BoxState.abandoned, BoxState.expired, }, BoxState.interrupted: { BoxState.running, BoxState.completed, BoxState.abandoned, BoxState.expired, }, BoxState.completed: set(), BoxState.abandoned: set(), BoxState.expired: set(), } def _now_iso() -> str: return datetime.now(timezone.utc).isoformat() @dataclass class TimeBox: id: Optional[int] todo_id: Optional[int] duration_seconds: int state: BoxState = BoxState.open accumulated_seconds: float = 0.0 last_started_at: Optional[str] = None interruptions: list[dict[str, Any]] = field(default_factory=list) created_at: str = field(default_factory=_now_iso) updated_at: str = field(default_factory=_now_iso) completed_at: Optional[str] = None reason: Optional[str] = None # why interrupted / abandoned / completed def __post_init__(self) -> None: if self.duration_seconds <= 0: raise ValueError("duration_seconds must be a positive integer") def can(self, target: BoxState) -> bool: """True if the transition target is legal from the current state.""" return target in _TRANSITIONS.get(self.state, set()) def has_active_state(self) -> bool: """True while the box is still in play (not terminal).""" return self.state in { BoxState.open, BoxState.running, BoxState.paused, BoxState.interrupted, } def is_terminal(self) -> bool: return not self.has_active_state() def _last_started_epoch(self) -> Optional[float]: if self.last_started_at is None: return None try: return datetime.fromisoformat(self.last_started_at).timestamp() except (ValueError, TypeError): return None def elapsed_seconds(self) -> float: """Total focused time so far. For running boxes, includes live time.""" elapsed = float(self.accumulated_seconds) started = self._last_started_epoch() if self.state == BoxState.running and started is not None: elapsed += max(0.0, datetime.now(timezone.utc).timestamp() - started) return elapsed def remaining_seconds(self) -> int: """Seconds left in the box, floored at zero.""" return max(0, self.duration_seconds - int(self.elapsed_seconds())) def progress_percent(self) -> int: """How much of the box is used up, clamped 0..100.""" if self.duration_seconds <= 0: return 100 return min(100, int(round( 100.0 * self.elapsed_seconds() / self.duration_seconds ))) def started(self) -> bool: """Whether the box has ever been started (needed for restart recovery).""" return self.last_started_at is not None