diff --git a/focus_project/README.md b/focus_project/README.md index 2368aef..e92bf20 100644 --- a/focus_project/README.md +++ b/focus_project/README.md @@ -19,5 +19,10 @@ 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` (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 +next project day; the bank never crosses the project's end. `minutes_for(day)` is the +capped pay figure, `status(day)["minutes"]` the real count (121/120) with `carried_in`, +`carry_out`, `own_minutes`, `counted_minutes`. Carry ledger rows: `--carry` +(`detail.carry`), never over `daily_minutes` together with own rows. 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/_own.py b/focus_project/focus_project/_own.py new file mode 100644 index 0000000..4806a61 --- /dev/null +++ b/focus_project/focus_project/_own.py @@ -0,0 +1,31 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""A project day's own worked minutes: the union of every source's intervals.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from focus_project import sources + +if TYPE_CHECKING: + from datetime import date + + from focus_project._model import Project + + +def union_seconds(spans: list[sources.Interval]) -> float: + """Total length of the union of ``spans``.""" + total = 0.0 + end = float("-inf") + for lo, hi in sorted(spans): + if hi <= end: + continue + total += hi - max(lo, end) + end = hi + return total + + +def own_minutes_of(project: Project, day: date, moment: float) -> int: + """Whole minutes worked on ``day``, uncapped, nothing after ``moment``.""" + spans = sources.clip(sources.collect(day, project), (float("-inf"), moment)) + return int(union_seconds(spans) // 60) diff --git a/focus_project/focus_project/carry.py b/focus_project/focus_project/carry.py new file mode 100644 index 0000000..2e98629 --- /dev/null +++ b/focus_project/focus_project/carry.py @@ -0,0 +1,75 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""Overtime carries over: minutes past a day's target bank for the next day. + +Contract (kuhy, 2026-11-02): own minutes beyond ``daily_on(day)`` are banked +(at most :data:`BANK_CAP`) and pay on the NEXT project day. The bank never +crosses the project's last day (what is left then expires) and a carry never +pays on a day with no project. The balance is derived from the activity +record, never stored, so it cannot drift; a project day's counted minutes +are ``min(daily, own + carried_in)``. The bank is spent first, so what a day +takes from it is fixed when the day starts and only own minutes grow. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date, timedelta +import logging +from typing import Final + +from focus_project import _api, _clock +from focus_project._errors import RegistryError +from focus_project._own import own_minutes_of + +BANK_CAP: Final = 60 +_LOG = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class Balance: + """One project day: worked minutes, bank taken in, bank left for tomorrow.""" + + own: int + carried_in: int + carry_out: int + + def paid_bank(self, daily: int) -> int: + """Minutes of the day paid from the bank (fixed at the day's start).""" + return min(self.carried_in, daily) + + def paid_own(self, daily: int) -> int: + """Minutes of the day paid from own work, after the bank.""" + return min(self.own, daily - self.paid_bank(daily)) + + def counted(self, daily: int) -> int: + """Minutes that count on the day: own plus carried, capped at ``daily``.""" + return min(daily, self.own + self.carried_in) + + +_NONE: Final = Balance(0, 0, 0) + + +def balance(day: date, now: float | None = None) -> Balance: + """The balance of ``day`` (zeros with no project or an unreadable registry).""" + moment = _clock.now() if now is None else now + try: + project = _api.active(day) + if project is None: + return _NONE + bank = 0 + cursor = project.start + while True: + own = own_minutes_of(project, cursor, moment) + total = own + bank + left = min(BANK_CAP, max(0, total - project.daily_on(cursor))) + if cursor >= day: + return Balance(own, bank, left) + bank, cursor = left, cursor + timedelta(days=1) + except (RegistryError, OSError) as exc: + _LOG.warning("focus_project registry unreadable, nothing banked: %s", exc) + return _NONE + + +def own_minutes(day: date, now: float | None = None) -> int: + """Minutes worked on ``day``, not capped at the target (0 with no project).""" + return balance(day, now).own diff --git a/focus_project/focus_project/harvest.py b/focus_project/focus_project/harvest.py index 45da69f..e5a39ed 100644 --- a/focus_project/focus_project/harvest.py +++ b/focus_project/focus_project/harvest.py @@ -7,6 +7,9 @@ one-minute ``.timer``). Harvests every project day of the last after the fact) is paid once a run sees it; ``credit_to`` is idempotent, so settled days cost one read and write nothing. Failure modes, by path: +Overtime past a day's target banks for the next day (``carry``): that day's +carry row pays it from the day's start, own-work rows pay the rest. + * a source that errors: logged, contributes 0, the run continues (fail closed); * an unreadable registry, signing key or ledger: logged, exit status 1, so the unit shows ``failed`` instead of silently crediting nothing. @@ -18,9 +21,8 @@ from datetime import date, timedelta import logging import sys -from focus_project import _api, _clock, ledger +from focus_project import _api, _clock, carry, ledger from focus_project._errors import RegistryError -from focus_project.minutes import minutes_for BACKFILL_DAYS = 31 # bounds the per-run cost; the relay accepts events <= 48 h old _LOG = logging.getLogger("focus_project.harvest") @@ -43,11 +45,18 @@ def harvest_day(day: date, now: float) -> int: project = _api.active(day) if project is None: return 0 - total = minutes_for(day, now=now) - added = ledger.credit_to(project, day, total, now=now) + daily = project.daily_on(day) + bank = carry.balance(day, now) + added = ledger.credit_to(project, day, bank.paid_bank(daily), now=now, carry=True) + added += ledger.credit_to(project, day, bank.paid_own(daily), now=now) if added: _LOG.info( - "credited %d min to %s on %s (total %d)", added, project.id, day, total + "credited %d min to %s on %s (total %d, %d from the bank)", + added, + project.id, + day, + bank.counted(daily), + bank.paid_bank(daily), ) return added diff --git a/focus_project/focus_project/ledger.py b/focus_project/focus_project/ledger.py index 20b3d5f..dda127c 100644 --- a/focus_project/focus_project/ledger.py +++ b/focus_project/focus_project/ledger.py @@ -93,10 +93,19 @@ def _write_rows(path: Path, rows: list[Any]) -> None: Path(handle.name).replace(path) -def paid_minutes(rows: list[Any], key: bytes, project_id: str, day: date) -> int: +def paid_minutes( + rows: list[Any], + key: bytes, + project_id: str, + day: date, + *, + carry: bool | None = None, +) -> int: """Minutes already paid for ``project_id`` on ``day`` by verified rows. - A repeated ``entry_id`` pays once, at its smallest count. + ``carry`` picks one class (True: banked-overtime rows, False: own-work + rows); None sums both. A repeated ``entry_id`` pays once, at its smallest + count. """ by_id: dict[str, int] = {} for row in rows: @@ -112,6 +121,7 @@ def paid_minutes(rows: list[Any], key: bytes, project_id: str, day: date) -> int or not isinstance(minutes, int) or isinstance(minutes, bool) or minutes < 1 + or (carry is not None and bool(detail.get("carry")) != carry) or not earned_time.verified(row, key) ): continue @@ -121,13 +131,31 @@ def paid_minutes(rows: list[Any], key: bytes, project_id: str, day: date) -> int def build_row( # noqa: PLR0913 - project_id: str, day: date, total: int, minutes: int, key: bytes, *, now: float + project_id: str, + day: date, + total: int, + minutes: int, + key: bytes, + *, + now: float, + carry: bool = False, + first_carry: bool = True, ) -> dict[str, Any]: - """The signed row paying ``minutes`` that bring the day's total to ``total``.""" + """The signed row paying ``minutes`` that bring the day's total to ``total``. + + A ``carry`` row pays banked overtime: ``--carry``, or + ``-carry-m`` when it tops up an earlier carry row (late data for + the day before grew the bank). + """ ended = min(now, day_bounds(day)[1] - 1) + stem = f"{project_id}-{day:%Y%m%d}" + if not carry: + entry_id = f"{stem}-m{total}" + else: + entry_id = f"{stem}-carry" if first_carry else f"{stem}-carry-m{total}" row: dict[str, Any] = { "kind": CREDIT, - "entry_id": f"{project_id}-{day:%Y%m%d}-m{total}", + "entry_id": entry_id, "day": day.isoformat(), "created_at": float(now), "detail": { @@ -135,6 +163,7 @@ def build_row( # noqa: PLR0913 "minutes": int(minutes), "total": int(total), "ended_at": float(ended), + **({"carry": True} if carry else {}), }, } row["hmac"] = str(earned_time.entry_signature(row, key)) @@ -142,10 +171,18 @@ def build_row( # noqa: PLR0913 def credit_to( - project: Project, day: date, total: int, *, now: float | None = None + project: Project, + day: date, + total: int, + *, + now: float | None = None, + carry: bool = False, ) -> int: """Pay the day up to ``total`` cumulative minutes; return minutes added. + ``carry`` pays the banked-overtime class and counts only its own rows; + the default pays (and counts) own work. + Idempotent: nothing is written when the ledger already pays ``total`` or more. Raises LedgerError (unreadable ledger, unreadable key, unverifiable write) so the harvester can fail loudly instead of crediting nothing quietly. @@ -159,11 +196,18 @@ def credit_to( with path.with_suffix(".lock").open("w") as lock: fcntl.flock(lock, fcntl.LOCK_EX) rows = read_rows(path) - paid = paid_minutes(rows, key, project.id, day) + paid = paid_minutes(rows, key, project.id, day, carry=carry) if total <= paid: return 0 row = build_row( - project.id, day, total, total - paid, key, now=now or time.time() + project.id, + day, + total, + total - paid, + key, + now=now or time.time(), + carry=carry, + first_carry=paid == 0, ) if any( isinstance(r, dict) and r.get("entry_id") == row["entry_id"] for r in rows @@ -172,7 +216,7 @@ def credit_to( raise LedgerError(msg) _write_rows(path, [*rows, row]) stored = read_rows(path) - if paid_minutes(stored, key, project.id, day) != total: + if paid_minutes(stored, key, project.id, day, carry=carry) != total: msg = f"row {row['entry_id']} not found verified on read-back" raise LedgerError(msg) return total - paid diff --git a/focus_project/focus_project/minutes.py b/focus_project/focus_project/minutes.py index 05db91f..9105732 100644 --- a/focus_project/focus_project/minutes.py +++ b/focus_project/focus_project/minutes.py @@ -1,8 +1,9 @@ # Copyright (c) 2026 Krzysztof Rudnicki """Credited focus minutes per day: the union of every source's intervals. -Contract: union of activity intervals on the local calendar day, capped at -the active project's ``daily_minutes``; overlaps never double count; whole +Contract: union of activity intervals on the local calendar day plus the +overtime carried in from the day before (:mod:`focus_project.carry`), capped +at the active project's ``daily_minutes``; overlaps never double count; whole 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 @@ -14,24 +15,13 @@ from __future__ import annotations from datetime import date # noqa: TC003 import logging -from focus_project import _api, _clock, sources +from focus_project import _api, _clock, carry from focus_project._errors import RegistryError +from focus_project._own import union_seconds _LOG = logging.getLogger(__name__) -def union_seconds(spans: list[sources.Interval]) -> float: - """Total length of the union of ``spans``.""" - total = 0.0 - end = float("-inf") - for lo, hi in sorted(spans): - if hi <= end: - continue - total += hi - max(lo, end) - end = hi - return total - - def minutes_for(day: date, now: float | None = None) -> int: """Minutes of focus work credited on ``day`` (0 with no project active). @@ -45,6 +35,7 @@ def minutes_for(day: date, now: float | None = None) -> int: if project is None: return 0 moment = _clock.now() if now is None else now - spans = sources.clip(sources.collect(day, project), (float("-inf"), moment)) - seconds = union_seconds(spans) - return min(int(seconds // 60), project.daily_on(day)) + return carry.balance(day, moment).counted(project.daily_on(day)) + + +__all__ = ["minutes_for", "union_seconds"] diff --git a/focus_project/focus_project/status.py b/focus_project/focus_project/status.py index 158c4bd..b25f9e8 100644 --- a/focus_project/focus_project/status.py +++ b/focus_project/focus_project/status.py @@ -6,27 +6,44 @@ from __future__ import annotations from datetime import date # noqa: TC003 from typing import Any -from focus_project import _api, minutes +from focus_project import _api, carry def status(day: date) -> dict[str, Any]: - """Progress of the project active on ``day``; ``project`` is None if none.""" + """Progress of the project active on ``day``; ``project`` is None if none. + + ``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. + """ project = _api.active(day) if project is None: return { "project": None, "name": None, "minutes": 0, + "own_minutes": 0, + "counted_minutes": 0, + "carried_in": 0, + "carry_out": 0, "daily_minutes": None, "unlock_minutes": None, "ends": None, "replaces": [], } + daily = project.daily_on(day) + bank = carry.balance(day) return { "project": project.id, "name": project.name, - "minutes": minutes.minutes_for(day), - "daily_minutes": project.daily_on(day), + # Real minutes behind the day (own + banked in), never capped: the + # number to show. Pay is ``counted_minutes``; earned_time caps it. + "minutes": bank.own + bank.carried_in, + "own_minutes": bank.own, + "counted_minutes": bank.counted(daily), + "carried_in": bank.carried_in, + "carry_out": bank.carry_out, + "daily_minutes": daily, "unlock_minutes": project.unlock_on(day), "ends": project.last_day.isoformat(), "replaces": list(project.replaces), diff --git a/focus_project/focus_project/tests/test_activity_status.py b/focus_project/focus_project/tests/test_activity_status.py index df95587..a84b398 100644 --- a/focus_project/focus_project/tests/test_activity_status.py +++ b/focus_project/focus_project/tests/test_activity_status.py @@ -3,7 +3,7 @@ from __future__ import annotations -from datetime import date +from datetime import date, datetime import importlib import json from pathlib import Path # noqa: TC003 @@ -13,12 +13,16 @@ import time import pytest import focus_project as fp -from focus_project import _clock, minutes +from focus_project import _clock from focus_project._paths import data_dir D1 = date(2026, 11, 2) +def _at(hour: int, minute: int) -> float: + return datetime(2026, 11, 2, hour, minute).astimezone().timestamp() + + def _project() -> None: fp.create_project( "hr", @@ -115,15 +119,20 @@ def test_record_activity_concurrent_appends(data: Path) -> None: assert all(json.loads(x)["source"] == "tutor" for x in lines) -def test_status_active_uses_minutes_module(monkeypatch: pytest.MonkeyPatch) -> None: +def test_status_active_reports_the_worked_minutes() -> None: _project() - assert fp.status(D1)["minutes"] == 0 # the stub - monkeypatch.setattr(minutes, "minutes_for", lambda _day: 45) + assert fp.status(D1)["minutes"] == 0 + for k in range(1, 10): # nine heartbeats, five minutes each + fp.record_activity("tutor", "heartbeat", ts=_at(8, 5 * k)) st = fp.status(D1) assert st == { "project": "hr", "name": "HackerRank prep", "minutes": 45, + "own_minutes": 45, + "counted_minutes": 45, + "carried_in": 0, + "carry_out": 0, "daily_minutes": 120, "unlock_minutes": 30, "ends": "2026-11-05", @@ -141,6 +150,10 @@ def test_status_inactive() -> None: "project", "name", "minutes", + "own_minutes", + "counted_minutes", + "carried_in", + "carry_out", "daily_minutes", "unlock_minutes", "ends", diff --git a/focus_project/focus_project/tests/test_carry.py b/focus_project/focus_project/tests/test_carry.py new file mode 100644 index 0000000..d6d64bb --- /dev/null +++ b/focus_project/focus_project/tests/test_carry.py @@ -0,0 +1,183 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""Overtime carries over: own minutes, the bank, pay and the carry ledger rows.""" + +from __future__ import annotations + +from datetime import date, timedelta +import json +from pathlib import Path # noqa: TC003 +from typing import Any + +import earned_time +import pytest + +import focus_project as fp +from focus_project import carry, harvest, ledger, minutes +from focus_project.tests.test_minutes import D1, at, events, project + +KEY = b"k" * 32 +D2 = D1 + timedelta(days=1) +D3 = D2 + timedelta(days=1) +D4 = D3 + timedelta(days=1) # the last day of project() +D5 = D4 + timedelta(days=1) # after the end + + +@pytest.fixture(autouse=True) +def _key(tmp_path: Path) -> None: + (tmp_path / "hmac.key").write_bytes(KEY) + + +def work(day: date, mins: int) -> None: + """``mins`` focused minutes on ``day``: tutor heartbeats every five minutes.""" + events( + *( + ("tutor", "heartbeat", at(8, 0, day=day) + 300 * k) + for k in range(1, mins // 5 + 1) + ) + ) + + +def rows() -> list[dict[str, Any]]: + out: list[dict[str, Any]] = json.loads(ledger.ledger_path().read_text())["entries"] + return out + + +def test_own_minutes_are_not_capped_but_pay_is() -> None: + project() + work(D1, 125) + assert carry.own_minutes(D1) == 125 + assert minutes.minutes_for(D1) == 120 + + +def test_overtime_banks_into_the_next_day() -> None: + project() + work(D1, 125) + assert carry.balance(D1) == carry.Balance(own=125, carried_in=0, carry_out=5) + assert carry.balance(D2) == carry.Balance(own=0, carried_in=5, carry_out=0) + assert minutes.minutes_for(D2) == 5 + + +def test_carry_adds_to_own_minutes_on_the_next_day() -> None: + project() + work(D1, 125) + work(D2, 100) + assert minutes.minutes_for(D2) == 105 + assert carry.balance(D2).carry_out == 0 + + +def test_the_bank_is_capped_at_sixty() -> None: + project() + work(D1, 200) + assert carry.balance(D1).carry_out == carry.BANK_CAP == 60 + + +def test_overtime_chains_over_days() -> None: + project() + work(D1, 130) + work(D2, 125) + assert carry.balance(D2) == carry.Balance(own=125, carried_in=10, carry_out=15) + assert minutes.minutes_for(D3) == 15 + + +def test_the_bank_never_crosses_the_project_end() -> None: + project() + work(D4, 150) + assert carry.balance(D4).carry_out == 30 + assert minutes.minutes_for(D5) == 0 + assert carry.balance(D5) == carry.Balance(0, 0, 0) + + +def test_the_first_day_has_nothing_banked_in() -> None: + project() + assert carry.balance(D1 - timedelta(days=1)).carried_in == 0 + assert carry.balance(D1).carried_in == 0 + + +def test_a_past_target_is_used_for_a_past_day() -> None: + project() + work(D1, 125) + fp.update_project("hr", today=D2, daily_minutes=100, unlock_minutes=30) + assert carry.balance(D1).carry_out == 5 # D1 still judged at 120 + assert carry.balance(D2).carried_in == 5 + + +def test_status_reports_real_minutes_and_the_bank() -> None: + project() + work(D1, 125) + shown = fp.status(D1) + assert shown["minutes"] == 125 + assert shown["own_minutes"] == 125 + assert shown["counted_minutes"] == 120 + assert (shown["carried_in"], shown["carry_out"]) == (0, 5) + nxt = fp.status(D2) + assert (nxt["minutes"], nxt["carried_in"]) == (5, 5) + + +def test_status_without_a_project_has_zero_bank() -> None: + shown = fp.status(D1) + assert (shown["own_minutes"], shown["carried_in"], shown["carry_out"]) == (0, 0, 0) + + +def test_unreadable_registry_banks_nothing(data: Path) -> None: + data.mkdir(parents=True) + (data / "projects.json").write_text("{") + assert carry.balance(D2) == carry.Balance(0, 0, 0) + assert carry.own_minutes(D2) == 0 + + +def test_harvest_pays_own_rows_then_one_carry_row() -> None: + project() + work(D1, 125) + work(D2, 100) + harvest.harvest(now=at(23, 0, day=D2)) + got = {r["entry_id"]: r for r in rows()} + assert got["hr-20261102-m120"]["detail"]["minutes"] == 120 + assert got["hr-20261103-carry"]["detail"]["minutes"] == 5 + assert got["hr-20261103-carry"]["detail"]["carry"] is True + assert got["hr-20261103-m100"]["detail"]["minutes"] == 100 + assert all(earned_time.verified(r, KEY) for r in got.values()) + assert ledger.paid_minutes(rows(), KEY, "hr", D2) == 105 + + +def test_pay_never_exceeds_the_daily_target() -> None: + project() + work(D1, 125) + work(D2, 130) + harvest.harvest(now=at(23, 0, day=D2)) + assert ledger.paid_minutes(rows(), KEY, "hr", D2) == 120 + assert minutes.minutes_for(D2) == 120 + + +def test_carry_is_paid_at_the_start_of_the_day_and_not_again() -> None: + project() + work(D1, 125) + harvest.harvest(now=at(0, 5, day=D2)) + before = rows() + assert [r["entry_id"] for r in before if r["day"] == "2026-11-03"] == [ + "hr-20261103-carry" + ] + harvest.harvest(now=at(0, 6, day=D2)) + assert rows() == before + + +def test_late_data_for_yesterday_pays_only_the_carry_delta() -> None: + project() + work(D1, 125) + harvest.harvest(now=at(9, 0, day=D2)) + work(D1, 130) # a late phone sync: five more minutes + harvest.harvest(now=at(9, 5, day=D2)) + paid = [r for r in rows() if r["detail"].get("carry")] + assert [r["entry_id"] for r in paid] == [ + "hr-20261103-carry", + "hr-20261103-carry-m10", + ] + assert ledger.paid_minutes(rows(), KEY, "hr", D2) == 10 + + +def test_own_rows_leave_room_for_the_carry() -> None: + project() + work(D1, 125) + work(D2, 120) + harvest.harvest(now=at(23, 0, day=D2)) + assert ledger.paid_minutes(rows(), KEY, "hr", D2) == 120 + assert "hr-20261103-m115" in {r["entry_id"] for r in rows()} diff --git a/focus_project/pyproject.toml b/focus_project/pyproject.toml index 8c00bad..3f4ccb5 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.5.0" +version = "0.6.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.