mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 19:07:17 +01:00
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
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:
parent
1a74345c40
commit
4fee294aa0
@ -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
|
||||
|
||||
@ -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),
|
||||
|
||||
59
focus_project/focus_project/tests/test_minutes_beats.py
Normal file
59
focus_project/focus_project/tests/test_minutes_beats.py
Normal 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
|
||||
@ -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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user