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

View File

@ -9,7 +9,7 @@ import logging
import re
import time
from focus_project import _store
from focus_project import _clock, _store
from focus_project._errors import RegistryError
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:
"""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():
existing = _store.read_all()
for i, project in enumerate(existing):
@ -120,6 +124,9 @@ def end_project(project_id: str, day: date) -> Project:
if day < project.start:
msg = f"cannot end {project_id!r} before its start {project.start}"
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))
existing[i] = ended
_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 json
import os
import time
from typing import Any
from focus_project import _api
from focus_project import _api, _clock
from focus_project._paths import activity_path, data_dir
SOURCES = frozenset({"timer", "tutor", "sql_drills", "leetcode", "browser"})
KINDS = frozenset({"start", "checkin", "stop", "heartbeat"})
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
@ -26,7 +28,12 @@ def record_activity( # noqa: PLR0913, PLR0917
dev: str = "pc",
meta: dict[str, Any] | 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 (
(source, SOURCES, "source"),
(kind, KINDS, "kind"),
@ -35,7 +42,11 @@ def record_activity( # noqa: PLR0913, PLR0917
if value not in allowed:
msg = f"unknown {label} {value!r}; expected one of {sorted(allowed)}"
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:
found = _api.active(date.fromtimestamp(when)) # noqa: DTZ012
project = found.id if found else None

View File

@ -2,8 +2,10 @@
"""The harvester: turn credited focus minutes into signed ledger rows.
Run by ``focus-project-harvest.service`` (``.path`` on activity.jsonl plus a
one-minute ``.timer``). Harvests yesterday too, so work recorded just before
midnight is paid once the next run sees it. Failure modes, by path:
one-minute ``.timer``). Harvests every project day of the last
: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);
* 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
import logging
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.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")
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:
"""Credit ``day``; return the minutes added (0 with no project)."""
project = _api.active(day)
if project is None:
return 0
total = minutes_for(day)
total = minutes_for(day, now=now)
added = ledger.credit_to(project, day, total, now=now)
if added:
_LOG.info(
@ -39,11 +53,16 @@ def harvest_day(day: date, now: float) -> int:
def harvest(now: float | None = None) -> int:
"""Harvest yesterday and today; 0 on success, 1 if any path failed."""
moment = time.time() if now is None else now
"""Harvest the backfill window; 0 on success, 1 if any path failed."""
moment = _clock.now() if now is None else now
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
for day in (today - timedelta(days=1), today):
for day in days:
try:
harvest_day(day, moment)
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
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
@ -11,7 +14,7 @@ from __future__ import annotations
from datetime import date # noqa: TC003
import logging
from focus_project import _api, sources
from focus_project import _api, _clock, sources
from focus_project._errors import RegistryError
_LOG = logging.getLogger(__name__)
@ -29,8 +32,11 @@ def union_seconds(spans: list[sources.Interval]) -> float:
return total
def minutes_for(day: date) -> int:
"""Minutes of focus work credited on ``day`` (0 with no project active)."""
def minutes_for(day: date, now: float | None = None) -> int:
"""Minutes of focus work credited on ``day`` (0 with no project active).
``now`` (default: the clock) is the moment after which nothing counts.
"""
try:
project = _api.active(day)
except (RegistryError, OSError) as exc:
@ -38,5 +44,7 @@ def minutes_for(day: date) -> int:
return 0
if project is None:
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))

View File

@ -11,6 +11,8 @@ from typing import TYPE_CHECKING
import pytest
from focus_project import _clock
if TYPE_CHECKING:
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"))
@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
def data(tmp_path: Path) -> Path:
"""The redirected data directory (may not exist yet)."""

View File

@ -4,14 +4,16 @@
from __future__ import annotations
from datetime import date
import importlib
import json
from pathlib import Path # noqa: TC003
import threading
import time
import pytest
import focus_project as fp
from focus_project import minutes
from focus_project import _clock, minutes
from focus_project._paths import data_dir
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
fp.create_project(
"now",
@ -145,3 +150,21 @@ def test_status_inactive() -> None:
def test_minutes_for_exported() -> None:
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 datetime import date, timedelta
from datetime import date, datetime, timedelta
import json
from typing import TYPE_CHECKING
from unittest import mock
@ -11,6 +11,7 @@ from unittest import mock
import pytest
import focus_project as fp
from focus_project import _clock
from focus_project._errors import RegistryError
if TYPE_CHECKING:
@ -86,6 +87,24 @@ def test_overlap_rejected_and_adjacent_ok() -> None:
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:
_make()
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)
@pytest.mark.usefixtures("_on_d1")
def test_end_project_errors() -> None:
_make()
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 boom(_day: date) -> None:
def boom(*_day: date) -> None:
msg = "corrupt"
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
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:
(tmp_path / "hmac.key").write_bytes(b"\n")
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:
harvest.main()
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
import focus_project as fp
from focus_project import minutes
from focus_project import _clock, minutes
from focus_project._paths import activity_path
if TYPE_CHECKING:
@ -200,3 +200,46 @@ def test_non_timer_kinds_from_timer_source_ignored() -> None:
project()
events(("timer", "heartbeat", at(9)))
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()
with pytest.raises(ValueError, match="over"):
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"):
fp.update_project("hr", today=D1, name="x")

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[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"
requires-python = ">=3.14"
# Stdlib only on purpose: guards import it from the system python.