- TimeBox model with explicit recoverable BoxState state machine - Store layer: time_boxes schema, CRUD, active-box recovery - BoxController: start/pause/resume/interrupt/complete/expire with elapsed math - CLI group (start/status/pause/resume/interrupt/complete/abandon/recover/list/focus) - Live ADHD-friendly focus REPL with single-key commands, calm UX, no-shame quit - Interruptions logged with timestamp + reason for pattern recognition - Non-interactive helpers + auto-resume on entry - 65 tests green, strict vertical RED-GREEN TDD
117 lines
4.3 KiB
Python
117 lines
4.3 KiB
Python
"""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
|