agent-todo-tracker/agent_todos/timebox.py
Hermes Agent 7a03de305a feat(box): ADHD-friendly time-boxing mode (issue #16)
- 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
2026-08-22 21:49:35 +00:00

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