focus_project 0.8.0: tutor beats chain timer
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
focus-project pre-commit / pre-commit (push) Has been cancelled
focus-project tests / test (push) Has been cancelled

A tutor heartbeat (a graded check-in card in the tutor app) is a beat
of the open timer segment like a checkin: within 600 s it extends the
segment, later it ends it at the last beat; it never opens one.
leetcode, sql_drills and browser heartbeats are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-11-08 08:39:12 +01:00
parent 1a74345c40
commit 4fee294aa0
4 changed files with 83 additions and 7 deletions

View File

@ -16,7 +16,9 @@ Data: `$FOCUS_PROJECT_DATA` or `~/.local/share/focus_project/` (`projects.json`,
`activity.jsonl`). `minutes.py` is owned by the minutes/harvester agent.
Time rules: `minutes_for` clips every interval at "now" (an event stamped ahead of
the clock earns only once the clock reaches it); `record_activity` rejects a `ts`
more than 120 s ahead (ValueError). `end_project(id, day)` rejects a past `day`
more than 120 s ahead (ValueError). Timer segments (`start` -> `checkin` ... -> `stop`)
lapse after a 600 s gap; a `tutor`/`heartbeat` is a beat too (0.8.0: a graded check-in card
extends the open segment, never opens one). `end_project(id, day)` rejects a past `day`
(ValueError); today or later is fine. The harvester pays every project day of the
last 31 days, so late-synced events for older days are credited (idempotent).
Overtime carries over: own minutes past a day's target bank (max 60) and pay on the

View File

@ -8,6 +8,11 @@ starts a fresh one. Time after the last check-in is not credited until the
next event vouches for it. Every other source (tutor, sql_drills, ...) is a
heartbeat covering the five minutes before it. Only events recorded for
``project`` count.
0.8.0: a ``tutor`` ``heartbeat`` (a graded check-in card) is also a beat of
the open segment, like a ``checkin``: within :data:`MAX_GAP_S` of the last
beat it extends the segment; later it ends the segment at that last beat. It
never opens a segment. Other heartbeat sources never chain the timer.
"""
from __future__ import annotations
@ -55,24 +60,34 @@ def read_events(project_id: str) -> list[dict[str, Any]]:
def timer_intervals(events: list[dict[str, Any]]) -> list[Interval]:
"""Credited spans of the ``timer`` events (already time-ordered)."""
"""Credited spans of the ``timer`` events and tutor beats (time-ordered)."""
spans: list[Interval] = []
last: float | None = None # the last event of the open segment
last: float | None = None # the last beat of the open segment
for event in events:
ts = float(event["ts"])
kind = event.get("kind")
if kind not in {"start", "checkin", "stop"}:
beat = _is_beat(event)
if not beat and kind not in {"start", "checkin", "stop"}:
continue
on_time = False
if last is not None and ts - last <= MAX_GAP_S:
spans.append((last, ts))
last = None if kind == "stop" else ts
on_time = True
# A beat never opens a segment; a stop closes it; start/checkin open it.
keep = on_time if beat else kind != "stop"
last = ts if keep else None
return spans
def _is_beat(event: dict[str, Any]) -> bool:
"""A tutor heartbeat: it chains the timer segment (0.8.0)."""
return event.get("source") == "tutor" and event.get("kind") == "heartbeat"
def intervals(day: date, project: Project) -> list[Interval]: # noqa: ARG001
"""Timer segments plus heartbeats of ``project`` (the registry clips)."""
events = read_events(project.id)
timer = [e for e in events if e.get("source") == "timer"]
timer = [e for e in events if e.get("source") == "timer" or _is_beat(e)]
beats = [e for e in events if e.get("source") != "timer"]
return [
*timer_intervals(timer),

View File

@ -0,0 +1,59 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""0.8.0: a tutor heartbeat chains the timer segment like a checkin."""
from __future__ import annotations
from focus_project import minutes
from focus_project.tests.test_minutes import D1, at, events, project
def test_tutor_heartbeat_keeps_the_segment_alive() -> None:
"""0.8.0: a tutor heartbeat is a beat of the open segment, like a checkin."""
project()
events(
("timer", "start", at(9)),
("tutor", "heartbeat", at(9, 7)),
("timer", "stop", at(9, 12)),
)
assert minutes.minutes_for(D1) == 12
def test_late_tutor_heartbeat_ends_the_segment_at_the_last_beat() -> None:
project()
events(
("timer", "start", at(9)),
("tutor", "heartbeat", at(9, 11)), # 11 min > 600 s: segment ends at 9:00
("timer", "stop", at(9, 13)), # nothing open: ignored
)
assert minutes.minutes_for(D1) == 5 # only the heartbeat's own [9:06, 9:11]
def test_tutor_heartbeat_never_opens_a_segment() -> None:
project()
events(
("tutor", "heartbeat", at(9)),
("timer", "stop", at(9, 4)),
)
assert minutes.minutes_for(D1) == 5
def test_other_heartbeats_never_chain_the_timer() -> None:
project()
events(
("timer", "start", at(9)),
("sql_drills", "heartbeat", at(9, 7)),
("timer", "stop", at(9, 12)),
)
assert minutes.minutes_for(D1) == 5 # [9:02, 9:07] only
def test_tutor_heartbeats_chain_with_checkins() -> None:
project()
events(
("timer", "start", at(9)),
("tutor", "heartbeat", at(9, 7)),
("timer", "checkin", at(9, 14)),
("tutor", "heartbeat", at(9, 21)),
("timer", "stop", at(9, 25)),
)
assert minutes.minutes_for(D1) == 25

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "focus_project"
version = "0.7.1"
version = "0.8.0"
description = "Time-boxed focus projects: replace chosen gates, pay a daily-minutes gate"
requires-python = ">=3.14"
# Stdlib only on purpose: guards import it from the system python.