"""The timer engine. The server owns the clock. A browser tab or the desktop window only renders `remaining_seconds`, computed from `started_at` — so reloading the page, closing the laptop lid or switching from the web UI to the desktop app all leave the same interval running, and the history is never a guess. One interval is one row. Work and breaks are both intervals, which is what makes "you actually rested 3 of the 7 breaks you were owed" a thing the report can say. """ from __future__ import annotations from dataclasses import dataclass from datetime import date, datetime, timedelta from typing import Any, Literal from sqlalchemy import func, select from sqlalchemy.orm import Session from app.models import PomodoroSession, TaskRecord, utcnow from app.settings_schema import AppSettings from app.timeutil import day_bounds_utc, ensure_utc, local_date Kind = Literal["work", "short_break", "long_break"] ACTIVE_STATUSES = ("running", "paused") class TimerError(RuntimeError): """An operation that does not fit the timer's current state.""" # -------------------------------------------------------------------------- # Reading state # -------------------------------------------------------------------------- def current_session(session: Session) -> PomodoroSession | None: return session.scalar( select(PomodoroSession) .where(PomodoroSession.status.in_(ACTIVE_STATUSES)) .order_by(PomodoroSession.started_at.desc()) ) def elapsed_seconds(row: PomodoroSession, now: datetime | None = None) -> int: """Wall-clock seconds spent inside the interval, excluding paused time.""" now = now or utcnow() if row.status == "paused" and row.paused_at is not None: end = ensure_utc(row.paused_at) elif row.ended_at is not None: end = ensure_utc(row.ended_at) else: end = now started = ensure_utc(row.started_at) return max(int((end - started).total_seconds()) - row.paused_seconds, 0) def remaining_seconds(row: PomodoroSession, now: datetime | None = None) -> int: return max(row.planned_seconds - elapsed_seconds(row, now), 0) def duration_for(settings: AppSettings, kind: Kind) -> int: minutes = { "work": settings.pomodoro.work_minutes, "short_break": settings.pomodoro.short_break_minutes, "long_break": settings.pomodoro.long_break_minutes, }[kind] return minutes * 60 def completed_work_today(session: Session, settings: AppSettings, day: date | None = None) -> int: day = day or local_date(settings.timezone) return int( session.scalar( select(func.count(PomodoroSession.id)).where( PomodoroSession.local_date == day, PomodoroSession.kind == "work", PomodoroSession.status == "completed", ) ) or 0 ) def next_kind(session: Session, settings: AppSettings) -> Kind: """What the timer should run next, following the long-break cycle. After every `cycles_before_long_break` completed work intervals today the next break is the long one. The count resets with the day, so a long break is never "owed" from yesterday. """ last = session.scalar( select(PomodoroSession) .where(PomodoroSession.status.in_(("completed", "abandoned"))) .order_by(PomodoroSession.started_at.desc()) ) if last is None or last.kind != "work" or last.status == "abandoned": return "work" done = completed_work_today(session, settings) if done and done % settings.pomodoro.cycles_before_long_break == 0: return "long_break" return "short_break" # -------------------------------------------------------------------------- # Transitions # -------------------------------------------------------------------------- def start( session: Session, settings: AppSettings, *, kind: Kind | None = None, minutes: int | None = None, task_id: int | None = None, note: str | None = None, ) -> PomodoroSession: """Begin an interval, abandoning whatever was still running.""" running = current_session(session) if running is not None: _finish(session, settings, running, status="abandoned", reason="superseded") kind = kind or next_kind(session, settings) planned = (minutes * 60) if minutes else duration_for(settings, kind) task_source = task_external_id = task_title = None if task_id is not None: task = session.get(TaskRecord, task_id) if task is None: raise TimerError(f"no task {task_id}") task_source, task_external_id, task_title = task.source, task.external_id, task.title done_today = completed_work_today(session, settings) row = PomodoroSession( kind=kind, status="running", started_at=utcnow(), local_date=local_date(settings.timezone), planned_seconds=planned, cycle_index=(done_today % settings.pomodoro.cycles_before_long_break) + 1, task_source=task_source, task_external_id=task_external_id, task_title=task_title, note=note, ) session.add(row) session.commit() return row def pause(session: Session, settings: AppSettings) -> PomodoroSession: row = _require_active(session) if not settings.pomodoro.allow_pause: raise TimerError("Pausing is switched off in your settings.") if row.status == "paused": return row row.status = "paused" row.paused_at = utcnow() if settings.pomodoro.count_interruptions and row.is_work: row.interruptions += 1 session.commit() return row def resume(session: Session) -> PomodoroSession: row = _require_active(session) if row.status != "paused" or row.paused_at is None: return row row.paused_seconds += max(int((utcnow() - ensure_utc(row.paused_at)).total_seconds()), 0) row.paused_at = None row.status = "running" session.commit() return row def complete( session: Session, settings: AppSettings, *, auto_next: bool = True ) -> tuple[PomodoroSession, PomodoroSession | None]: """Finish the current interval and, if configured, roll into the next one.""" row = _require_active(session) finished = _finish(session, settings, row, status="completed") follow_on: PomodoroSession | None = None if auto_next: wants_break = finished.is_work and settings.pomodoro.auto_start_breaks wants_work = (not finished.is_work) and settings.pomodoro.auto_start_work if wants_break or wants_work: follow_on = start(session, settings, kind=next_kind(session, settings)) return finished, follow_on def abandon( session: Session, settings: AppSettings, *, reason: str | None = None ) -> PomodoroSession: row = _require_active(session) if settings.pomodoro.strict_mode and row.is_work and not (reason or "").strip(): raise TimerError("Strict mode: give a reason for dropping this pomodoro.") return _finish(session, settings, row, status="abandoned", reason=reason) def add_interruption(session: Session) -> PomodoroSession: """"The phone rang" — counted without stopping the clock.""" row = _require_active(session) row.interruptions += 1 session.commit() return row def extend(session: Session, minutes: int) -> PomodoroSession: """Add time to the running interval ("just five more minutes").""" row = _require_active(session) row.planned_seconds = max(row.planned_seconds + minutes * 60, 60) session.commit() return row def attach_task(session: Session, task_id: int | None) -> PomodoroSession: row = _require_active(session) if task_id is None: row.task_source = row.task_external_id = row.task_title = None else: task = session.get(TaskRecord, task_id) if task is None: raise TimerError(f"no task {task_id}") row.task_source, row.task_external_id, row.task_title = ( task.source, task.external_id, task.title, ) session.commit() return row def _require_active(session: Session) -> PomodoroSession: row = current_session(session) if row is None: raise TimerError("No interval is running.") return row def _finish( session: Session, settings: AppSettings, row: PomodoroSession, *, status: str, reason: str | None = None, ) -> PomodoroSession: now = utcnow() if row.status == "paused" and row.paused_at is not None: row.paused_seconds += max(int((now - ensure_utc(row.paused_at)).total_seconds()), 0) row.paused_at = None row.actual_seconds = elapsed_seconds(row, now) row.ended_at = now row.status = status if reason: row.abandon_reason = reason session.commit() return row # -------------------------------------------------------------------------- # Serialisation # -------------------------------------------------------------------------- @dataclass(slots=True) class TimerState: """What the clients render. Everything derived, nothing stored twice.""" session: dict[str, Any] | None next_kind: str completed_today: int goal: int settings_version: str def session_dict(row: PomodoroSession | None) -> dict[str, Any] | None: if row is None: return None return { "id": row.id, "kind": row.kind, "status": row.status, "started_at": ensure_utc(row.started_at).isoformat(), "ended_at": ensure_utc(row.ended_at).isoformat() if row.ended_at else None, "planned_seconds": row.planned_seconds, "elapsed_seconds": elapsed_seconds(row), "remaining_seconds": remaining_seconds(row), "paused_seconds": row.paused_seconds, "interruptions": row.interruptions, "cycle_index": row.cycle_index, "task": ( { "source": row.task_source, "external_id": row.task_external_id, "title": row.task_title, } if row.task_title else None ), "note": row.note, } def state(session: Session, settings: AppSettings) -> dict[str, Any]: row = current_session(session) done = completed_work_today(session, settings) return { "current": session_dict(row), "next_kind": next_kind(session, settings), "completed_today": done, "goal": settings.pomodoro.daily_goal_pomodoros, "cycle_position": (done % settings.pomodoro.cycles_before_long_break) + 1, "cycles_before_long_break": settings.pomodoro.cycles_before_long_break, "durations": { "work": settings.pomodoro.work_minutes, "short_break": settings.pomodoro.short_break_minutes, "long_break": settings.pomodoro.long_break_minutes, }, "server_time": utcnow().isoformat(), } def sessions_for_day(session: Session, settings: AppSettings, day: date) -> list[PomodoroSession]: start_utc, end_utc = day_bounds_utc(day, settings.timezone) return list( session.scalars( select(PomodoroSession) .where(PomodoroSession.started_at >= start_utc, PomodoroSession.started_at < end_utc) .order_by(PomodoroSession.started_at.asc()) ) ) def reap_stale(session: Session, settings: AppSettings, *, max_overrun_hours: int = 6) -> int: """Close intervals left running by a crash or a machine that went to sleep. Without this one forgotten tab would report a 14-hour pomodoro and poison every average in the stats. """ cutoff = utcnow() - timedelta(hours=max_overrun_hours) stale = session.scalars( select(PomodoroSession).where( PomodoroSession.status.in_(ACTIVE_STATUSES), PomodoroSession.started_at < cutoff, ) ).all() for row in stale: row.status = "abandoned" row.ended_at = ensure_utc(row.started_at) + timedelta(seconds=row.planned_seconds) row.actual_seconds = row.planned_seconds row.abandon_reason = "auto-closed: left running" if stale: session.commit() return len(stale)