focus_project: carry overtime, v0.6.0
Some checks are pending
code-runner pre-commit / pre-commit (push) Waiting to run
code-runner tests / test (push) Waiting to run
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
focus-project pre-commit / pre-commit (push) Waiting to run
focus-project tests / test (push) Waiting to run
leetcode-coach pre-commit / pre-commit (push) Waiting to run
leetcode-coach tests / test (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
sql-drills pre-commit / pre-commit (push) Waiting to run
sql-drills tests / test (push) Waiting to run

Minutes past a day's target bank (max 60) and pay on the next project day; status shows real minutes (121/120) plus carried_in/carry_out; carry ledger rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYGW5NZL5uVpnqmNKo
This commit is contained in:
Krzysztof Rudnicki 2026-11-02 15:14:59 +01:00
parent 737bf624ca
commit 88e4be5042
10 changed files with 410 additions and 42 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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: ``<project>-<YYYYMMDD>-carry``, or
``-carry-m<total>`` 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

View File

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

View File

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

View File

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

View File

@ -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()}

View File

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