This commit is contained in:
timka123 committed 2026-10-18 18:02:05 +03:00
commit 17a0a3c4c9
121 files changed
+17641

No files matched your search

+354
View File
@@ -0,0 +1,354 @@
"""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)