"""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(), )