From afcc1a47c790fd6b3cb11abee3df089c24201b97 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Tue, 10 Nov 2026 16:35:46 +0100 Subject: [PATCH] focus_project 0.10.0: timer needs backing beats A timer span earns only if a same-project tutor/sql_drills/leetcode beat backs it (days from 2026-11-08). status() gains unbacked_minutes. The 0.8.0 tag shipped only the tutor-chain half. Todo: fda71ed1 Co-Authored-By: Claude Sonnet 5.5 --- earned_time/README.md | 1 + focus_project/README.md | 4 + focus_project/focus_project/__init__.py | 5 +- focus_project/focus_project/minutes.py | 31 ++- .../focus_project/sources/activity.py | 67 ++++++- .../focus_project/sources/leetcode.py | 11 +- focus_project/focus_project/status.py | 7 +- .../tests/test_activity_status.py | 2 + .../focus_project/tests/test_backing.py | 181 ++++++++++++++++++ .../tests/test_leetcode_source.py | 28 +++ focus_project/pyproject.toml | 2 +- 11 files changed, 322 insertions(+), 17 deletions(-) create mode 100644 focus_project/focus_project/tests/test_backing.py diff --git a/earned_time/README.md b/earned_time/README.md index 1aa92d2..6b46efd 100644 --- a/earned_time/README.md +++ b/earned_time/README.md @@ -199,6 +199,7 @@ their `max_gaming_minutes` + pure bonuses again (240/120 min = 2/min; full day = from the start day, cutting all of it, 180 with LeetCode's formerly free hour (0.20.0); maturity never delays it. Ledger `~/.local/share/focus_project/ledger.json`: signed `credit` rows, `entry_id` `--m`, `detail.minutes` (positive int), `detail.ended_at` (else `day`). `focus_project` unusable = no project (logged). +Unbacked timer time pays 0 from 2026-11-08 (focus_project 0.10.0). ## Adding a gate diff --git a/focus_project/README.md b/focus_project/README.md index 3ff7e12..2a4c9be 100644 --- a/focus_project/README.md +++ b/focus_project/README.md @@ -36,6 +36,10 @@ uncovers today. A NEW project starting today may replace gates and set any targe new obligation, not a reduction (but see the floor). Floor (0.7.1, coordinator decision for kuhy): `daily_minutes >= 30 x replaced gates` (min 30; `book` = `reading`), enforced on create and update (`daily_floor(replaces)`), so a day cannot be made cheap. +Backed timer (0.10.0, days from `BACKING_FROM` = 2026-11-08): a timer span earns only if a +same-project `tutor`/`sql_drills`/`leetcode` beat (or accepted submission) is in it, 5 min +before it, or bridges it (beats <= 20 min apart). `unbacked_minutes_for(day)` / +`status()["unbacked_minutes"]` show the rest. A beat backs only the spans it touches (the tutor chain makes a graded card back the whole stretch). Known gap: any coach turn backs. Open the work: `python3 -m focus_project.open [--no-site]` (tutor focus screen, `work_url`). Install: `./install.sh` (stdlib only, editable user install for `/usr/bin/python3`). diff --git a/focus_project/focus_project/__init__.py b/focus_project/focus_project/__init__.py index cde6cd3..52b8868 100644 --- a/focus_project/focus_project/__init__.py +++ b/focus_project/focus_project/__init__.py @@ -31,10 +31,12 @@ from focus_project._update import Update, apply_update, update_project from focus_project.activity import record_activity from focus_project.checks import register_source, run_checks from focus_project.evidence import Evidence -from focus_project.minutes import minutes_for +from focus_project.minutes import minutes_for, unbacked_minutes_for +from focus_project.sources.activity import BACKING_FROM from focus_project.status import status __all__ = [ + "BACKING_FROM", "Evidence", "Limits", "Milestone", @@ -59,5 +61,6 @@ __all__ = [ "run_checks", "stands_down", "status", + "unbacked_minutes_for", "update_project", ] diff --git a/focus_project/focus_project/minutes.py b/focus_project/focus_project/minutes.py index 9105732..062c5b6 100644 --- a/focus_project/focus_project/minutes.py +++ b/focus_project/focus_project/minutes.py @@ -8,6 +8,10 @@ minutes (floor). Time not yet lived is never credited: every interval is clipped at "now", so an event stamped ahead of the clock (a fast phone clock) earns only once the real clock reaches it. An unreadable registry reads as 0 (logged): fail closed. + +Unbacked minutes (0.8.0): timer time the anti-cheat gate refused +(:mod:`focus_project.sources.activity`) and no other source covers, on the +local day up to "now"; carry and the cap play no part. Shown, never paid. """ from __future__ import annotations @@ -15,9 +19,10 @@ from __future__ import annotations from datetime import date # noqa: TC003 import logging -from focus_project import _api, _clock, carry +from focus_project import _api, _clock, carry, sources from focus_project._errors import RegistryError from focus_project._own import union_seconds +from focus_project.sources import activity _LOG = logging.getLogger(__name__) @@ -38,4 +43,26 @@ def minutes_for(day: date, now: float | None = None) -> int: return carry.balance(day, moment).counted(project.daily_on(day)) -__all__ = ["minutes_for", "union_seconds"] +def unbacked_minutes_for(day: date, now: float | None = None) -> int: + """Whole minutes of timer time on ``day`` that the gate did not count. + + 0 with no project, before :data:`~focus_project.sources.activity.BACKING_FROM`, + or with an unreadable registry (logged). + """ + try: + project = _api.active(day) + if project is None: + return 0 + lo, hi = sources.day_bounds(day) + bounds = (lo, min(hi, _clock.now() if now is None else now)) + unbacked = sources.clip(activity.split_timer(day, project)[1], bounds) + counted = sources.clip(sources.collect(day, project), bounds) + except (RegistryError, OSError) as exc: + _LOG.warning("focus_project registry unreadable, 0 unbacked: %s", exc) + return 0 + # |unbacked minus counted| = |unbacked union counted| - |counted|. + gap = union_seconds(unbacked + counted) - union_seconds(counted) + return int(gap // 60) + + +__all__ = ["minutes_for", "unbacked_minutes_for", "union_seconds"] diff --git a/focus_project/focus_project/sources/activity.py b/focus_project/focus_project/sources/activity.py index 7b70f65..3de57d0 100644 --- a/focus_project/focus_project/sources/activity.py +++ b/focus_project/focus_project/sources/activity.py @@ -13,23 +13,36 @@ heartbeat covering the five minutes before it. Only events recorded for 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. + +0.10.0 anti-cheat (days from :data:`BACKING_FROM`): a timer span earns only +if real activity of the same project backs it, whole span or nothing. A +backing event is a ``tutor``/``sql_drills``/``leetcode`` heartbeat or an +accepted LeetCode submission (leetcode projects only); ``browser`` never +backs. A span ``(a, b)`` is backed if a backing event lies in +``[a - 300, b]``, or between two consecutive backing events at most +:data:`BRIDGE_S` apart. Check-ins alone earn 0. Earlier days keep the old +rule (every span counts), so paid days never change. """ from __future__ import annotations +from datetime import date +import importlib +from itertools import pairwise import json import logging -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Final from focus_project._paths import activity_path -from focus_project.sources import Interval, heartbeat_interval +from focus_project.sources import HEARTBEAT_WINDOW_S, Interval, heartbeat_interval if TYPE_CHECKING: - from datetime import date - from focus_project._model import Project MAX_GAP_S = 600.0 +BACKING_FROM: Final = date(2026, 11, 8) +BACKING_SOURCES: Final = frozenset({"tutor", "sql_drills", "leetcode"}) +BRIDGE_S: Final = 1200.0 _LOG = logging.getLogger(__name__) @@ -102,12 +115,50 @@ def sessions(project_id: str) -> list[Interval]: return merged -def intervals(day: date, project: Project) -> list[Interval]: # noqa: ARG001 - """Timer segments plus heartbeats of ``project`` (the registry clips).""" +def backing_times(events: list[dict[str, Any]], project: Project) -> list[float]: + """Sorted times of the events that may back a timer span. + + The LeetCode ledger is read in its own guard: if it fails, only the + submissions are lost, never the heartbeats (fail closed, narrow). + """ + times = [float(e["ts"]) for e in events if e.get("source") in BACKING_SOURCES] + try: + # Imported lazily: it pulls in earned_time, and the timer must keep + # working (heartbeat backing only) where that is not installed. + leetcode = importlib.import_module("focus_project.sources.leetcode") + times += leetcode.submission_times(project) + except Exception: # noqa: BLE001 (lose the submissions, keep the beats) + _LOG.exception("leetcode backing unavailable, heartbeats only") + return sorted(times) + + +def is_backed(span: Interval, times: list[float]) -> bool: + """Whether a backing event lies in or just before ``span``, or bridges it.""" + lo, hi = span + if any(lo - HEARTBEAT_WINDOW_S <= t <= hi for t in times): + return True + return any(a <= lo and hi <= b and b - a <= BRIDGE_S for a, b in pairwise(times)) + + +def split_timer( + day: date, project: Project, events: list[dict[str, Any]] | None = None +) -> tuple[list[Interval], list[Interval]]: + """``(backed, unbacked)`` timer spans of ``project``, judged for ``day``.""" + found = read_events(project.id) if events is None else events + chain = [e for e in found if e.get("source") == "timer" or _is_beat(e)] + spans = timer_intervals(chain) + if day < BACKING_FROM: + return spans, [] + times = backing_times(found, project) + backed = [s for s in spans if is_backed(s, times)] + return backed, [s for s in spans if s not in backed] + + +def intervals(day: date, project: Project) -> list[Interval]: + """Backed 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" or _is_beat(e)] beats = [e for e in events if e.get("source") != "timer"] return [ - *timer_intervals(timer), + *split_timer(day, project, events)[0], *(heartbeat_interval(float(e["ts"])) for e in beats), ] diff --git a/focus_project/focus_project/sources/leetcode.py b/focus_project/focus_project/sources/leetcode.py index d8ca18a..2914202 100644 --- a/focus_project/focus_project/sources/leetcode.py +++ b/focus_project/focus_project/sources/leetcode.py @@ -55,8 +55,8 @@ def _submitted_at(row: object, key: bytes) -> float | None: return None -def intervals(day: date, project: Project) -> list[Interval]: # noqa: ARG001 - """One heartbeat per accepted submission (the registry clips to the day).""" +def submission_times(project: Project) -> list[float]: + """Times of verified accepted submissions; [] for a non-leetcode project.""" if not any("leetcode" in pattern.lower() for pattern in project.site_patterns): return [] key = ledger.read_key() @@ -64,4 +64,9 @@ def intervals(day: date, project: Project) -> list[Interval]: # noqa: ARG001 _LOG.warning("leetcode source: no signing key, contributes 0") return [] stamps = (_submitted_at(row, key) for row in ledger.read_rows(ledger_file())) - return [heartbeat_interval(ts) for ts in stamps if ts is not None] + return [ts for ts in stamps if ts is not None] + + +def intervals(day: date, project: Project) -> list[Interval]: # noqa: ARG001 + """One heartbeat per accepted submission (the registry clips to the day).""" + return [heartbeat_interval(ts) for ts in submission_times(project)] diff --git a/focus_project/focus_project/status.py b/focus_project/focus_project/status.py index e6f6f19..5f17f9a 100644 --- a/focus_project/focus_project/status.py +++ b/focus_project/focus_project/status.py @@ -6,7 +6,7 @@ from __future__ import annotations import logging from typing import TYPE_CHECKING, Any -from focus_project import _api, _milestones, carry, evidence +from focus_project import _api, _milestones, carry, evidence, minutes if TYPE_CHECKING: from datetime import date @@ -36,7 +36,8 @@ def status(day: date) -> dict[str, Any]: ``minutes`` is what was really worked plus what the day took from the bank, so it may exceed ``daily_minutes`` (121/120); ``counted_minutes`` - is the capped figure that pays. + is the capped figure that pays. ``unbacked_minutes`` is timer time the + anti-cheat gate refused (shown as "not counted", never paid). """ project = _api.active(day) if project is None: @@ -52,6 +53,7 @@ def status(day: date) -> dict[str, Any]: "unlock_minutes": None, "ends": None, "replaces": [], + "unbacked_minutes": 0, "milestones": [], "missed": False, } @@ -72,6 +74,7 @@ def status(day: date) -> dict[str, Any]: "unlock_minutes": project.unlock_on(day), "ends": project.last_day.isoformat(), "replaces": list(project.replaces_on(day)), + "unbacked_minutes": minutes.unbacked_minutes_for(day), # Interview projects: [{id, due ISO, state}] and whether any is red. "milestones": rows, "missed": missed, diff --git a/focus_project/focus_project/tests/test_activity_status.py b/focus_project/focus_project/tests/test_activity_status.py index 88e1984..ba13b2e 100644 --- a/focus_project/focus_project/tests/test_activity_status.py +++ b/focus_project/focus_project/tests/test_activity_status.py @@ -137,6 +137,7 @@ def test_status_active_reports_the_worked_minutes() -> None: "unlock_minutes": 30, "ends": "2026-11-05", "replaces": ["leetcode"], + "unbacked_minutes": 0, "milestones": [], "missed": False, } @@ -160,6 +161,7 @@ def test_status_inactive() -> None: "unlock_minutes", "ends", "replaces", + "unbacked_minutes", "milestones", "missed", } diff --git a/focus_project/focus_project/tests/test_backing.py b/focus_project/focus_project/tests/test_backing.py new file mode 100644 index 0000000..8a3ac3e --- /dev/null +++ b/focus_project/focus_project/tests/test_backing.py @@ -0,0 +1,181 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""0.10.0: a timer span earns only if a same-project beat backs it.""" + +from __future__ import annotations + +from datetime import date +from typing import TYPE_CHECKING + +import focus_project as fp +from focus_project import _api, minutes +from focus_project.sources import activity +from focus_project.tests import test_minutes as tm + +if TYPE_CHECKING: + import pytest + +DAY = date(2026, 11, 8) # BACKING_FROM + + +def at(hour: int, minute: int = 0) -> float: + return tm.at(hour, minute, day=DAY) + + +def proj() -> None: + fp.create_project( + "hr", + name="HR prep", + start=DAY, + end=date(2026, 11, 12), + replaces=["leetcode"], + daily_minutes=120, + unlock_minutes=30, + site_patterns=["leetcode.com"], + ) + + +def rows(*items: tuple[str, str, float]) -> None: + tm.events(*items) + + +def timer_only() -> None: + rows( + ("timer", "start", at(9)), + ("timer", "checkin", at(9, 5)), + ("timer", "stop", at(9, 10)), + ) + + +def test_timer_only_stretch_pays_zero_and_shows_unbacked() -> None: + proj() + timer_only() + assert minutes.minutes_for(DAY) == 0 + assert minutes.unbacked_minutes_for(DAY) == 10 + assert fp.status(DAY)["unbacked_minutes"] == 10 + + +def test_a_beat_in_the_span_backs_all_of_it() -> None: + proj() + timer_only() + rows(("sql_drills", "heartbeat", at(9, 5))) + assert minutes.unbacked_minutes_for(DAY) == 0 + assert minutes.minutes_for(DAY) == 10 + + +def test_a_beat_backs_only_the_spans_it_touches() -> None: + proj() + timer_only() + rows(("sql_drills", "heartbeat", at(9, 7))) + # 9:00-9:05 stays unbacked, but the beat itself pays 9:02-9:07. + assert minutes.unbacked_minutes_for(DAY) == 2 + + +def test_a_beat_at_the_end_of_the_span_backs_it() -> None: + proj() + rows( + ("timer", "start", at(9)), + ("timer", "stop", at(9, 10)), + ("sql_drills", "heartbeat", at(9, 10)), + ) + assert minutes.unbacked_minutes_for(DAY) == 0 + assert minutes.minutes_for(DAY) == 10 + + +def test_a_beat_just_before_the_span_backs_it() -> None: + proj() + rows( + ("sql_drills", "heartbeat", at(8, 56)), # within 300 s before 9:00 + ("timer", "start", at(9)), + ("timer", "stop", at(9, 5)), + ) + assert minutes.unbacked_minutes_for(DAY) == 0 + + +def test_a_late_beat_does_not_back_it() -> None: + proj() + timer_only() + rows(("sql_drills", "heartbeat", at(9, 40))) + assert minutes.unbacked_minutes_for(DAY) == 10 + + +def test_beats_up_to_twenty_minutes_apart_bridge_the_span() -> None: + proj() + rows( + ("tutor", "heartbeat", at(8, 50)), + ("timer", "start", at(9)), + ("timer", "checkin", at(9, 5)), + ("timer", "stop", at(9, 8)), + ("tutor", "heartbeat", at(9, 10)), + ) + assert minutes.unbacked_minutes_for(DAY) == 0 + + +def test_beats_over_twenty_minutes_apart_do_not_bridge() -> None: + proj() + rows( + ("tutor", "heartbeat", at(8, 30)), + ("timer", "start", at(9)), + ("timer", "stop", at(9, 8)), + ("tutor", "heartbeat", at(9, 40)), + ) + assert minutes.unbacked_minutes_for(DAY) == 8 + + +def test_browser_never_backs() -> None: + proj() + timer_only() + rows(("browser", "heartbeat", at(9, 7))) + # The beat pays its own 9:02-9:07 but backs no timer span. + assert minutes.unbacked_minutes_for(DAY) == 5 + assert minutes.minutes_for(DAY) == 5 + + +def test_days_before_backing_from_keep_the_old_rule() -> None: + fp.create_project( + "hr", + name="Old", + start=date(2026, 11, 2), + end=date(2026, 11, 5), + replaces=["leetcode"], + daily_minutes=120, + unlock_minutes=30, + site_patterns=["leetcode.com"], + ) + old = date(2026, 11, 2) + rows(("timer", "start", tm.at(9)), ("timer", "stop", tm.at(9, 10))) + assert minutes.minutes_for(old) == 10 + assert minutes.unbacked_minutes_for(old) == 0 + + +def test_no_project_is_zero_unbacked() -> None: + assert minutes.unbacked_minutes_for(DAY) == 0 + assert fp.status(DAY)["unbacked_minutes"] == 0 + + +def test_unbacked_is_clipped_at_now() -> None: + proj() + timer_only() + assert minutes.unbacked_minutes_for(DAY, now=at(9, 4)) == 4 + + +def test_split_timer_returns_backed_and_unbacked() -> None: + proj() + timer_only() + project = _api.active(DAY) + assert project is not None + backed, unbacked = activity.split_timer(DAY, project) + assert backed == [] + assert unbacked == [(at(9), at(9, 5)), (at(9, 5), at(9, 10))] + + +def test_backing_from_is_exported() -> None: + assert date(2026, 11, 8) == fp.BACKING_FROM + assert fp.unbacked_minutes_for is minutes.unbacked_minutes_for + + +def test_registry_error_reads_zero(monkeypatch: pytest.MonkeyPatch) -> None: + def boom(_day: date) -> None: + raise OSError + + monkeypatch.setattr("focus_project._api.active", boom) + assert minutes.unbacked_minutes_for(DAY) == 0 diff --git a/focus_project/focus_project/tests/test_leetcode_source.py b/focus_project/focus_project/tests/test_leetcode_source.py index 8e6f644..c46357c 100644 --- a/focus_project/focus_project/tests/test_leetcode_source.py +++ b/focus_project/focus_project/tests/test_leetcode_source.py @@ -14,6 +14,8 @@ import pytest import focus_project as fp from focus_project import minutes, sources from focus_project.sources import leetcode +from focus_project.tests.test_backing import DAY, proj, rows +from focus_project.tests.test_backing import at as day_at from focus_project.tests.test_minutes import D1, at, project KEY = b"k" * 32 @@ -127,3 +129,29 @@ def test_read_only_replay_of_real_ledger(monkeypatch: pytest.MonkeyPatch) -> Non assert got assert all(b - a == sources.HEARTBEAT_WINDOW_S for a, b in got) assert REAL.stat().st_mtime_ns == before + + +def test_accepted_submission_backs_a_timer_span(tmp_path: Path) -> None: + """0.10.0: an accepted submission backs timer time on a backed day.""" + proj() + rows(("timer", "start", day_at(9)), ("timer", "stop", day_at(9, 10))) + assert minutes.unbacked_minutes_for(DAY) == 10 + write(tmp_path, ac_row(day_at(9, 10))) + assert minutes.unbacked_minutes_for(DAY) == 0 + assert minutes.minutes_for(DAY) == 10 + + +def test_unreadable_ledger_leaves_heartbeat_backing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def boom(_project: object) -> list[float]: + raise OSError + + monkeypatch.setattr(leetcode, "submission_times", boom) + proj() + rows( + ("timer", "start", day_at(9)), + ("timer", "stop", day_at(9, 10)), + ("tutor", "heartbeat", day_at(9, 10)), + ) + assert minutes.unbacked_minutes_for(DAY) == 0 diff --git a/focus_project/pyproject.toml b/focus_project/pyproject.toml index 0aa5598..7d8a330 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.9.1" +version = "0.10.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.