"""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: if not settings.pomodoro.timer_enabled: raise TimerError("Timer is disabled (view-only mode).") 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, expect_id: int | None = None, ) -> tuple[PomodoroSession | None, PomodoroSession | None]: """Finish the current interval and, if configured, roll into the next one. `expect_id` is the interval the caller believes it is finishing. Every client runs its own local countdown and calls this when it hits zero, so two clients open at once both call within the same second — and the second call used to land on the interval `auto_next` had just created and end it immediately. That is a break disappearing the moment it starts, and it is not something a client can fix on its own: only the server knows which interval is current. So a call that names an interval which is no longer running does nothing at all, and says so by returning no `finished`. """ row = current_session(session) if expect_id is not None and (row is None or row.id != expect_id): return None, None if row is None: raise TimerError("No interval is running.") 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 rescale_current(session: Session, settings: AppSettings) -> PomodoroSession | None: """Resize the running/paused interval to match a just-saved duration. Without this, a preset (or any Focus/Short/Long edit) only takes effect from the *next* interval — the one already ticking keeps whatever length it was born with. `elapsed_seconds` is wall-clock, not stored, so this only has to move `planned_seconds`; time already spent still counts, it is just measured against the new total. """ row = current_session(session) if row is None: return None planned = duration_for(settings, row.kind) # type: ignore[arg-type] if planned != row.planned_seconds: row.planned_seconds = planned 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(), "timer_enabled": settings.pomodoro.timer_enabled, } 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)