init
This commit is contained in:
121 files changed
+17641
No files matched your search
@@ -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)
|
||||
Reference in new issue
Block a user