focus_project: late days, past end, clock, v0.4.0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-11-02 10:01:08 +01:00
parent c25ae4e0d1
commit 637428585c
14 changed files with 279 additions and 27 deletions

View File

@ -14,4 +14,9 @@ fp.record_activity("timer", "start")
Data: `$FOCUS_PROJECT_DATA` or `~/.local/share/focus_project/` (`projects.json`, Data: `$FOCUS_PROJECT_DATA` or `~/.local/share/focus_project/` (`projects.json`,
`activity.jsonl`). `minutes.py` is owned by the minutes/harvester agent. `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`
(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).
Install: `./install.sh` (stdlib only, editable user install for `/usr/bin/python3`). Install: `./install.sh` (stdlib only, editable user install for `/usr/bin/python3`).

View File

@ -9,7 +9,7 @@ import logging
import re import re
import time import time
from focus_project import _store from focus_project import _clock, _store
from focus_project._errors import RegistryError from focus_project._errors import RegistryError
from focus_project._model import GATES, Project, normalize_gate from focus_project._model import GATES, Project, normalize_gate
@ -108,7 +108,11 @@ def create_project( # noqa: PLR0913
def end_project(project_id: str, day: date) -> Project: def end_project(project_id: str, day: date) -> Project:
"""End early: ``day`` becomes the last active day (clamped to ``end``).""" """End early: ``day`` becomes the last active day (clamped to ``end``).
``day`` must be today or later: ending a past day would retroactively
erase that day's shortfall (and the lock it causes).
"""
with _store.locked(): with _store.locked():
existing = _store.read_all() existing = _store.read_all()
for i, project in enumerate(existing): for i, project in enumerate(existing):
@ -120,6 +124,9 @@ def end_project(project_id: str, day: date) -> Project:
if day < project.start: if day < project.start:
msg = f"cannot end {project_id!r} before its start {project.start}" msg = f"cannot end {project_id!r} before its start {project.start}"
raise ValueError(msg) raise ValueError(msg)
if day < _clock.today():
msg = f"cannot end {project_id!r} in the past ({day})"
raise ValueError(msg)
ended = replace(project, ended_on=min(day, project.end)) ended = replace(project, ended_on=min(day, project.end))
existing[i] = ended existing[i] = ended
_store.write_all(existing) _store.write_all(existing)

View File

@ -0,0 +1,17 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""The one wall-clock seam: tests replace :func:`now` instead of sleeping."""
from __future__ import annotations
from datetime import date
import time
def now() -> float:
"""Unix seconds right now."""
return time.time()
def today() -> date:
"""The local calendar date right now."""
return date.fromtimestamp(now()) # noqa: DTZ012 (local day by contract)

View File

@ -7,15 +7,17 @@ from datetime import date
import fcntl import fcntl
import json import json
import os import os
import time
from typing import Any from typing import Any
from focus_project import _api from focus_project import _api, _clock
from focus_project._paths import activity_path, data_dir from focus_project._paths import activity_path, data_dir
SOURCES = frozenset({"timer", "tutor", "sql_drills", "leetcode", "browser"}) SOURCES = frozenset({"timer", "tutor", "sql_drills", "leetcode", "browser"})
KINDS = frozenset({"start", "checkin", "stop", "heartbeat"}) KINDS = frozenset({"start", "checkin", "stop", "heartbeat"})
DEVICES = frozenset({"pc", "phone"}) DEVICES = frozenset({"pc", "phone"})
# Clock skew tolerated on a stamped event; a bigger lead is rejected. Whatever
# is accepted is still clipped at "now" by ``minutes_for``.
MAX_SKEW_S = 120.0
def record_activity( # noqa: PLR0913, PLR0917 def record_activity( # noqa: PLR0913, PLR0917
@ -26,7 +28,12 @@ def record_activity( # noqa: PLR0913, PLR0917
dev: str = "pc", dev: str = "pc",
meta: dict[str, Any] | None = None, meta: dict[str, Any] | None = None,
) -> None: ) -> None:
"""Append one event. ``project`` defaults to the project active at ``ts``.""" """Append one event. ``project`` defaults to the project active at ``ts``.
Raises:
ValueError: unknown source/kind/dev, or ``ts`` more than
:data:`MAX_SKEW_S` ahead of the clock (nothing is written).
"""
for value, allowed, label in ( for value, allowed, label in (
(source, SOURCES, "source"), (source, SOURCES, "source"),
(kind, KINDS, "kind"), (kind, KINDS, "kind"),
@ -35,7 +42,11 @@ def record_activity( # noqa: PLR0913, PLR0917
if value not in allowed: if value not in allowed:
msg = f"unknown {label} {value!r}; expected one of {sorted(allowed)}" msg = f"unknown {label} {value!r}; expected one of {sorted(allowed)}"
raise ValueError(msg) raise ValueError(msg)
when = time.time() if ts is None else ts now = _clock.now()
when = now if ts is None else ts
if when > now + MAX_SKEW_S:
msg = f"ts is {when - now:.0f} s ahead of the clock (max {MAX_SKEW_S:.0f} s)"
raise ValueError(msg)
if project is None: if project is None:
found = _api.active(date.fromtimestamp(when)) # noqa: DTZ012 found = _api.active(date.fromtimestamp(when)) # noqa: DTZ012
project = found.id if found else None project = found.id if found else None

View File

@ -2,8 +2,10 @@
"""The harvester: turn credited focus minutes into signed ledger rows. """The harvester: turn credited focus minutes into signed ledger rows.
Run by ``focus-project-harvest.service`` (``.path`` on activity.jsonl plus a Run by ``focus-project-harvest.service`` (``.path`` on activity.jsonl plus a
one-minute ``.timer``). Harvests yesterday too, so work recorded just before one-minute ``.timer``). Harvests every project day of the last
midnight is paid once the next run sees it. Failure modes, by path: :data:`BACKFILL_DAYS` days, so work recorded late (a phone synced days
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:
* a source that errors: logged, contributes 0, the run continues (fail closed); * a source that errors: logged, contributes 0, the run continues (fail closed);
* an unreadable registry, signing key or ledger: logged, exit status 1, so * an unreadable registry, signing key or ledger: logged, exit status 1, so
@ -15,21 +17,33 @@ from __future__ import annotations
from datetime import date, timedelta from datetime import date, timedelta
import logging import logging
import sys import sys
import time
from focus_project import _api, ledger from focus_project import _api, _clock, ledger
from focus_project._errors import RegistryError from focus_project._errors import RegistryError
from focus_project.minutes import minutes_for 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") _LOG = logging.getLogger("focus_project.harvest")
def project_days(today: date) -> list[date]:
"""Every project day in the backfill window up to ``today``, oldest first."""
oldest = today - timedelta(days=BACKFILL_DAYS - 1)
days: set[date] = set()
for project in _api.list_projects():
day = max(project.start, oldest)
while day <= min(project.last_day, today):
days.add(day)
day += timedelta(days=1)
return sorted(days)
def harvest_day(day: date, now: float) -> int: def harvest_day(day: date, now: float) -> int:
"""Credit ``day``; return the minutes added (0 with no project).""" """Credit ``day``; return the minutes added (0 with no project)."""
project = _api.active(day) project = _api.active(day)
if project is None: if project is None:
return 0 return 0
total = minutes_for(day) total = minutes_for(day, now=now)
added = ledger.credit_to(project, day, total, now=now) added = ledger.credit_to(project, day, total, now=now)
if added: if added:
_LOG.info( _LOG.info(
@ -39,11 +53,16 @@ def harvest_day(day: date, now: float) -> int:
def harvest(now: float | None = None) -> int: def harvest(now: float | None = None) -> int:
"""Harvest yesterday and today; 0 on success, 1 if any path failed.""" """Harvest the backfill window; 0 on success, 1 if any path failed."""
moment = time.time() if now is None else now moment = _clock.now() if now is None else now
today = date.fromtimestamp(moment) # noqa: DTZ012 (local day) today = date.fromtimestamp(moment) # noqa: DTZ012 (local day)
try:
days = project_days(today)
except (RegistryError, OSError) as exc:
_LOG.error("harvest registry unreadable: %s", exc)
return 1
status = 0 status = 0
for day in (today - timedelta(days=1), today): for day in days:
try: try:
harvest_day(day, moment) harvest_day(day, moment)
except (RegistryError, ledger.LedgerError, OSError) as exc: except (RegistryError, ledger.LedgerError, OSError) as exc:

View File

@ -3,7 +3,10 @@
Contract: union of activity intervals on the local calendar day, capped at Contract: union of activity intervals on the local calendar day, capped at
the active project's ``daily_minutes``; overlaps never double count; whole the active project's ``daily_minutes``; overlaps never double count; whole
minutes (floor). An unreadable registry reads as 0 (logged): fail closed. 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.
""" """
from __future__ import annotations from __future__ import annotations
@ -11,7 +14,7 @@ from __future__ import annotations
from datetime import date # noqa: TC003 from datetime import date # noqa: TC003
import logging import logging
from focus_project import _api, sources from focus_project import _api, _clock, sources
from focus_project._errors import RegistryError from focus_project._errors import RegistryError
_LOG = logging.getLogger(__name__) _LOG = logging.getLogger(__name__)
@ -29,8 +32,11 @@ def union_seconds(spans: list[sources.Interval]) -> float:
return total return total
def minutes_for(day: date) -> int: def minutes_for(day: date, now: float | None = None) -> int:
"""Minutes of focus work credited on ``day`` (0 with no project active).""" """Minutes of focus work credited on ``day`` (0 with no project active).
``now`` (default: the clock) is the moment after which nothing counts.
"""
try: try:
project = _api.active(day) project = _api.active(day)
except (RegistryError, OSError) as exc: except (RegistryError, OSError) as exc:
@ -38,5 +44,7 @@ def minutes_for(day: date) -> int:
return 0 return 0
if project is None: if project is None:
return 0 return 0
seconds = union_seconds(sources.collect(day, project)) 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 min(int(seconds // 60), project.daily_on(day))

View File

@ -11,6 +11,8 @@ from typing import TYPE_CHECKING
import pytest import pytest
from focus_project import _clock
if TYPE_CHECKING: if TYPE_CHECKING:
from pathlib import Path from pathlib import Path
@ -25,6 +27,15 @@ def _redirect_state(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.setenv("FOCUS_PROJECT_KEY", str(tmp_path / "hmac.key")) monkeypatch.setenv("FOCUS_PROJECT_KEY", str(tmp_path / "hmac.key"))
@pytest.fixture(autouse=True)
def _far_future_clock(monkeypatch: pytest.MonkeyPatch) -> None:
"""Pin "now" to 2030 so fixture days of 2026 are past, never in the future.
Tests about the clock itself set ``_clock.now`` explicitly.
"""
monkeypatch.setattr(_clock, "now", lambda: 1_893_456_000.0)
@pytest.fixture @pytest.fixture
def data(tmp_path: Path) -> Path: def data(tmp_path: Path) -> Path:
"""The redirected data directory (may not exist yet).""" """The redirected data directory (may not exist yet)."""

View File

@ -4,14 +4,16 @@
from __future__ import annotations from __future__ import annotations
from datetime import date from datetime import date
import importlib
import json import json
from pathlib import Path # noqa: TC003 from pathlib import Path # noqa: TC003
import threading import threading
import time
import pytest import pytest
import focus_project as fp import focus_project as fp
from focus_project import minutes from focus_project import _clock, minutes
from focus_project._paths import data_dir from focus_project._paths import data_dir
D1 = date(2026, 11, 2) D1 = date(2026, 11, 2)
@ -54,7 +56,10 @@ def test_record_activity_line(data: Path) -> None:
} }
def test_record_activity_defaults_now_and_project(data: Path) -> None: def test_record_activity_defaults_now_and_project(
data: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(_clock, "now", time.time) # the real clock, once
today = date.today() # noqa: DTZ011 today = date.today() # noqa: DTZ011
fp.create_project( fp.create_project(
"now", "now",
@ -145,3 +150,21 @@ def test_status_inactive() -> None:
def test_minutes_for_exported() -> None: def test_minutes_for_exported() -> None:
assert fp.minutes_for(D1) == 0 assert fp.minutes_for(D1) == 0
def test_a_timestamp_far_ahead_is_rejected(
data: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(_clock, "now", lambda: 10_000.0)
with pytest.raises(ValueError, match="ahead"):
fp.record_activity("timer", "start", ts=10_000.0 + 3 * 3600)
assert not (data / "activity.jsonl").exists()
fp.record_activity("timer", "start", ts=10_000.0 + 120) # small skew is kept
fp.record_activity("timer", "stop", ts=9_000.0)
assert len((data / "activity.jsonl").read_text().splitlines()) == 2
def test_the_real_clock_seam_reads_wall_time() -> None:
importlib.reload(_clock) # undo the autouse pin on the module attribute
assert abs(_clock.now() - time.time()) < 5
assert _clock.today() == date.today() # noqa: DTZ011

View File

@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import date, timedelta from datetime import date, datetime, timedelta
import json import json
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from unittest import mock from unittest import mock
@ -11,6 +11,7 @@ from unittest import mock
import pytest import pytest
import focus_project as fp import focus_project as fp
from focus_project import _clock
from focus_project._errors import RegistryError from focus_project._errors import RegistryError
if TYPE_CHECKING: if TYPE_CHECKING:
@ -86,6 +87,24 @@ def test_overlap_rejected_and_adjacent_ok() -> None:
assert len(fp.list_projects()) == 2 assert len(fp.list_projects()) == 2
@pytest.fixture
def _on_d1(monkeypatch: pytest.MonkeyPatch) -> None:
"""The clock reads D1 noon: ``end_project`` days are judged against it."""
noon = datetime(D1.year, D1.month, D1.day, 12).astimezone().timestamp()
monkeypatch.setattr(_clock, "now", lambda: noon)
@pytest.mark.usefixtures("_on_d1")
def test_end_project_rejects_a_past_day() -> None:
"""Ending yesterday would erase yesterday's shortfall and lift the lock."""
_make("late", start=D1 - timedelta(days=3), end=D4)
with pytest.raises(ValueError, match="past"):
fp.end_project("late", D1 - timedelta(days=1))
assert fp.list_projects()[0].ended_on is None
assert fp.end_project("late", D1).ended_on == D1 # today is allowed
@pytest.mark.usefixtures("_on_d1")
def test_end_early_frees_later_days() -> None: def test_end_early_frees_later_days() -> None:
_make() _make()
ended = fp.end_project("hr", D1 + timedelta(days=1)) ended = fp.end_project("hr", D1 + timedelta(days=1))
@ -96,6 +115,7 @@ def test_end_early_frees_later_days() -> None:
_make("again", start=D1 + timedelta(days=2), end=D4) _make("again", start=D1 + timedelta(days=2), end=D4)
@pytest.mark.usefixtures("_on_d1")
def test_end_project_errors() -> None: def test_end_project_errors() -> None:
_make() _make()
with pytest.raises(KeyError): with pytest.raises(KeyError):

View File

@ -0,0 +1,65 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Harvester backfill: late-synced events for older project days are paid."""
from __future__ import annotations
from datetime import date, timedelta
from pathlib import Path # noqa: TC003
import pytest
import focus_project as fp
from focus_project import harvest, ledger
from focus_project.tests.test_ledger_harvest import KEY, rows
from focus_project.tests.test_minutes import D1, at, events
@pytest.fixture(autouse=True)
def _key(tmp_path: Path) -> None:
(tmp_path / "hmac.key").write_bytes(KEY)
def _late_phone_batch(start: date) -> None:
"""A phone offline since ``start`` delivers a full timer run late."""
fp.create_project(
"hr",
name="HR",
start=start,
end=start + timedelta(days=5),
replaces=["leetcode"],
daily_minutes=120,
unlock_minutes=30,
)
t = at(10, day=start)
events(
("timer", "start", t),
*(("timer", "checkin", t + i * 420) for i in range(1, 18)),
("timer", "stop", t + 17 * 420 + 60),
)
def test_late_events_for_an_older_project_day_are_paid() -> None:
"""Friday's work, synced Monday, used to be skipped with exit 0."""
fri = date(2026, 10, 30)
_late_phone_batch(fri)
assert harvest.harvest(now=at(10, day=D1)) == 0
(row,) = rows()
assert row["day"] == "2026-10-30"
assert row["detail"]["minutes"] == 120
assert row["detail"]["ended_at"] < at(0, day=fri + timedelta(days=1))
assert harvest.harvest(now=at(10, 1, day=D1)) == 0 # idempotent
assert len(rows()) == 1
def test_backfill_covers_a_finished_project_and_stops_at_its_last_day() -> None:
first = date(2026, 10, 20) # ran 20..25 Oct, long over
_late_phone_batch(first)
harvest.harvest(now=at(10, day=D1))
assert [r["day"] for r in rows()] == ["2026-10-20"]
def test_backfill_is_bounded() -> None:
old = D1 - timedelta(days=harvest.BACKFILL_DAYS + 5)
_late_phone_batch(old)
harvest.harvest(now=at(10, day=D1))
assert not ledger.ledger_path().exists() # beyond BACKFILL_DAYS

View File

@ -108,14 +108,31 @@ def test_ledger_without_entries_array_is_an_error() -> None:
def test_broken_registry_fails_loudly(monkeypatch: pytest.MonkeyPatch) -> None: def test_broken_registry_fails_loudly(monkeypatch: pytest.MonkeyPatch) -> None:
def boom(_day: date) -> None: def boom(*_day: date) -> None:
msg = "corrupt" msg = "corrupt"
raise fp.RegistryError(msg) raise fp.RegistryError(msg)
monkeypatch.setattr("focus_project._api.active", boom) monkeypatch.setattr("focus_project._api.list_projects", boom)
assert harvest.harvest(now=at(9, 31)) == 1 assert harvest.harvest(now=at(9, 31)) == 1
def test_a_day_that_fails_does_not_stop_the_others(
monkeypatch: pytest.MonkeyPatch,
) -> None:
project()
beats(at(9, 30))
real = harvest.harvest_day
def flaky(day: date, now: float) -> int:
if day == D1:
msg = "disk"
raise OSError(msg)
return real(day, now)
monkeypatch.setattr(harvest, "harvest_day", flaky)
assert harvest.harvest(now=at(9, 31, day=D1 + timedelta(days=1))) == 1
def test_empty_key_file_counts_as_unreadable(tmp_path: Path) -> None: def test_empty_key_file_counts_as_unreadable(tmp_path: Path) -> None:
(tmp_path / "hmac.key").write_bytes(b"\n") (tmp_path / "hmac.key").write_bytes(b"\n")
assert ledger.read_key() is None assert ledger.read_key() is None
@ -179,3 +196,9 @@ def test_main_exits_with_status(monkeypatch: pytest.MonkeyPatch) -> None:
with pytest.raises(SystemExit) as info: with pytest.raises(SystemExit) as info:
harvest.main() harvest.main()
assert info.value.code == 7 assert info.value.code == 7
def test_harvest_day_without_a_project_pays_nothing() -> None:
beats(at(9, 30))
assert harvest.harvest_day(D1, at(9, 31)) == 0
assert not ledger.ledger_path().exists()

View File

@ -8,7 +8,7 @@ import json
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import focus_project as fp import focus_project as fp
from focus_project import minutes from focus_project import _clock, minutes
from focus_project._paths import activity_path from focus_project._paths import activity_path
if TYPE_CHECKING: if TYPE_CHECKING:
@ -200,3 +200,46 @@ def test_non_timer_kinds_from_timer_source_ignored() -> None:
project() project()
events(("timer", "heartbeat", at(9))) events(("timer", "heartbeat", at(9)))
assert minutes.minutes_for(D1) == 0 assert minutes.minutes_for(D1) == 0
def _timer_run(start: float, checkins: int) -> None:
events(
("timer", "start", start),
*(("timer", "checkin", start + i * 420) for i in range(1, checkins + 1)),
("timer", "stop", start + checkins * 420 + 60),
)
def test_events_ahead_of_the_clock_earn_nothing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A phone clock 3 h fast must not pay before the work happens."""
project()
_timer_run(at(11), 17) # 119 min of "work", all after the real 08:00
monkeypatch.setattr(_clock, "now", lambda: at(8))
assert minutes.minutes_for(D1) == 0
def test_an_interval_straddling_now_counts_only_up_to_now(
monkeypatch: pytest.MonkeyPatch,
) -> None:
project()
_timer_run(at(11), 17)
monkeypatch.setattr(_clock, "now", lambda: at(11, 30))
assert minutes.minutes_for(D1) == 30
monkeypatch.setattr(_clock, "now", lambda: at(12, 0))
assert minutes.minutes_for(D1) == 60
def test_a_heartbeat_ahead_of_now_is_clipped(monkeypatch: pytest.MonkeyPatch) -> None:
project()
events(("tutor", "heartbeat", at(12, 2))) # vouches for 11:57..12:02
monkeypatch.setattr(_clock, "now", lambda: at(12, 0))
assert minutes.minutes_for(D1) == 3
def test_an_explicit_now_overrides_the_clock() -> None:
project()
_timer_run(at(11), 17)
assert minutes.minutes_for(D1, now=at(11, 10)) == 10
assert minutes.minutes_for(D1) == 120 # the pinned 2030 clock: all past

View File

@ -113,7 +113,7 @@ def test_ended_and_finished_projects_refused() -> None:
_make() _make()
with pytest.raises(ValueError, match="over"): with pytest.raises(ValueError, match="over"):
fp.update_project("hr", today=D4 + DAY, name="late") fp.update_project("hr", today=D4 + DAY, name="late")
fp.end_project("hr", D1) fp.end_project("hr", date(2031, 1, 1))
with pytest.raises(ValueError, match="ended"): with pytest.raises(ValueError, match="ended"):
fp.update_project("hr", today=D1, name="x") fp.update_project("hr", today=D1, name="x")

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "focus_project" name = "focus_project"
version = "0.3.0" version = "0.4.0"
description = "Time-boxed focus projects: replace chosen gates, pay a daily-minutes gate" description = "Time-boxed focus projects: replace chosen gates, pay a daily-minutes gate"
requires-python = ">=3.14" requires-python = ">=3.14"
# Stdlib only on purpose: guards import it from the system python. # Stdlib only on purpose: guards import it from the system python.