355 lines
12 KiB
Python
355 lines
12 KiB
Python
"""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)
|