- 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
125 lines
4.8 KiB
Python
125 lines
4.8 KiB
Python
"""BoxController: drives state transitions and elapsed-time bookkeeping.
|
|
|
|
Pure logic, no I/O. Takes any object that quacks like a Store (get/update on
|
|
time_boxes) so it stays testable and decoupled from SQLite.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
from typing import Any, Optional, Protocol
|
|
|
|
from .timebox import BoxState, TimeBox
|
|
|
|
|
|
class TimeBoxStore(Protocol):
|
|
def get_timebox(self, box_id: int) -> Optional[ TimeBox]: ...
|
|
def update_timebox(self, box_id: int, **kwargs: Any) -> Optional[TimeBox]: ...
|
|
|
|
|
|
def _now_iso() -> str:
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
def _now_epoch() -> float:
|
|
return datetime.now(timezone.utc).timestamp()
|
|
|
|
|
|
class BoxController:
|
|
"""State-machine driver for a single TimeBox.
|
|
|
|
Every public method validates the transition via TimeBox.can(), applies
|
|
elapsed-time accounting, persists via the store, and returns the updated box.
|
|
"""
|
|
|
|
def __init__(self, store: TimeBoxStore) -> None:
|
|
self.store = store
|
|
|
|
def _accrue_and_freeze(self, box: TimeBox) -> None:
|
|
"""Fold live running time into accumulated_seconds and clear the clock."""
|
|
if box.state == BoxState.running and box.last_started_at is not None:
|
|
try:
|
|
started = datetime.fromisoformat(box.last_started_at).timestamp()
|
|
box.accumulated_seconds += max(0.0, _now_epoch() - started)
|
|
except (ValueError, TypeError):
|
|
pass
|
|
box.last_started_at = None
|
|
|
|
def _persist(self, box: TimeBox, **fields: Any) -> TimeBox:
|
|
if box.id is None:
|
|
raise ValueError("box has no id — persist via Store.create_timebox first")
|
|
updated = self.store.update_timebox(box.id, **fields)
|
|
if updated is None:
|
|
raise ValueError(f"box {box.id} disappeared during update")
|
|
return updated
|
|
|
|
def start(self, box: TimeBox) -> TimeBox:
|
|
if box.is_terminal():
|
|
raise ValueError(f"Cannot start terminal box in state {box.state.value}")
|
|
if box.state != BoxState.open:
|
|
raise ValueError(f"Cannot start box in state {box.state.value}; use resume()")
|
|
box.last_started_at = _now_iso()
|
|
return self._persist(box, state=BoxState.running, last_started_at=box.last_started_at)
|
|
|
|
def pause(self, box: TimeBox) -> TimeBox:
|
|
if box.is_terminal():
|
|
raise ValueError(f"Cannot pause terminal box in state {box.state.value}")
|
|
if box.state != BoxState.running:
|
|
raise ValueError(f"Cannot pause box in state {box.state.value}")
|
|
self._accrue_and_freeze(box)
|
|
return self._persist(box, state=BoxState.paused, accumulated_seconds=box.accumulated_seconds)
|
|
|
|
def resume(self, box: TimeBox) -> TimeBox:
|
|
if box.is_terminal():
|
|
raise ValueError(f"Cannot resume terminal box in state {box.state.value}")
|
|
if box.state not in (BoxState.paused, BoxState.interrupted):
|
|
raise ValueError(f"Cannot resume box in state {box.state.value}")
|
|
box.last_started_at = _now_iso()
|
|
return self._persist(box, state=BoxState.running, last_started_at=box.last_started_at)
|
|
|
|
def interrupt(self, box: TimeBox, reason: str) -> TimeBox:
|
|
if box.is_terminal():
|
|
raise ValueError(f"Cannot interrupt terminal box in state {box.state.value}")
|
|
self._accrue_and_freeze(box)
|
|
interruptions = list(box.interruptions)
|
|
interruptions.append({"at": _now_iso(), "reason": reason})
|
|
return self._persist(
|
|
box,
|
|
state=BoxState.interrupted,
|
|
accumulated_seconds=box.accumulated_seconds,
|
|
interruptions=interruptions,
|
|
reason=reason,
|
|
)
|
|
|
|
def complete(self, box: TimeBox) -> TimeBox:
|
|
if box.is_terminal():
|
|
raise ValueError(f"Cannot complete terminal box in state {box.state.value}")
|
|
self._accrue_and_freeze(box)
|
|
return self._persist(
|
|
box,
|
|
state=BoxState.completed,
|
|
accumulated_seconds=box.accumulated_seconds,
|
|
completed_at=_now_iso(),
|
|
)
|
|
|
|
def abandon(self, box: TimeBox, reason: str = "") -> TimeBox:
|
|
if box.is_terminal():
|
|
raise ValueError(f"Cannot abandon terminal box in state {box.state.value}")
|
|
self._accrue_and_freeze(box)
|
|
return self._persist(
|
|
box,
|
|
state=BoxState.abandoned,
|
|
accumulated_seconds=box.accumulated_seconds,
|
|
reason=reason,
|
|
)
|
|
|
|
def expire(self, box: TimeBox) -> TimeBox:
|
|
if box.state != BoxState.running:
|
|
raise ValueError(f"Cannot expire box in state {box.state.value}")
|
|
self._accrue_and_freeze(box)
|
|
return self._persist(
|
|
box,
|
|
state=BoxState.expired,
|
|
accumulated_seconds=box.accumulated_seconds,
|
|
completed_at=_now_iso(),
|
|
)
|