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 <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-11-10 16:35:46 +01:00
parent 0527bc680d
commit afcc1a47c7
11 changed files with 322 additions and 17 deletions

View File

@ -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` `<project>-<YYYYMMDD>-m<n>`, `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

View File

@ -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`).

View File

@ -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",
]

View File

@ -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"]

View File

@ -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),
]

View File

@ -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)]

View File

@ -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,

View File

@ -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",
}

View File

@ -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

View File

@ -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

View File

@ -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.