From 4fee294aa0b6f915d15115245371c5d7d1a0096d Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 8 Nov 2026 08:39:12 +0100 Subject: [PATCH] focus_project 0.8.0: tutor beats chain timer 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 --- focus_project/README.md | 4 +- .../focus_project/sources/activity.py | 25 ++++++-- .../focus_project/tests/test_minutes_beats.py | 59 +++++++++++++++++++ focus_project/pyproject.toml | 2 +- 4 files changed, 83 insertions(+), 7 deletions(-) create mode 100644 focus_project/focus_project/tests/test_minutes_beats.py diff --git a/focus_project/README.md b/focus_project/README.md index 95ef7e6..739facf 100644 --- a/focus_project/README.md +++ b/focus_project/README.md @@ -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 diff --git a/focus_project/focus_project/sources/activity.py b/focus_project/focus_project/sources/activity.py index 8cdce1d..a0dff32 100644 --- a/focus_project/focus_project/sources/activity.py +++ b/focus_project/focus_project/sources/activity.py @@ -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), diff --git a/focus_project/focus_project/tests/test_minutes_beats.py b/focus_project/focus_project/tests/test_minutes_beats.py new file mode 100644 index 0000000..2f9db55 --- /dev/null +++ b/focus_project/focus_project/tests/test_minutes_beats.py @@ -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 diff --git a/focus_project/pyproject.toml b/focus_project/pyproject.toml index e13e36c..bbc843f 100644 --- a/focus_project/pyproject.toml +++ b/focus_project/pyproject.toml @@ -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.