diff --git a/focus_project/README.md b/focus_project/README.md index 980ef38..3bed107 100644 --- a/focus_project/README.md +++ b/focus_project/README.md @@ -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`). diff --git a/focus_project/focus_project/_api.py b/focus_project/focus_project/_api.py index c08731d..f131d55 100644 --- a/focus_project/focus_project/_api.py +++ b/focus_project/focus_project/_api.py @@ -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) diff --git a/focus_project/focus_project/_clock.py b/focus_project/focus_project/_clock.py new file mode 100644 index 0000000..769ac03 --- /dev/null +++ b/focus_project/focus_project/_clock.py @@ -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) diff --git a/focus_project/focus_project/activity.py b/focus_project/focus_project/activity.py index 3904b79..511a937 100644 --- a/focus_project/focus_project/activity.py +++ b/focus_project/focus_project/activity.py @@ -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 diff --git a/focus_project/focus_project/harvest.py b/focus_project/focus_project/harvest.py index fccbd11..45da69f 100644 --- a/focus_project/focus_project/harvest.py +++ b/focus_project/focus_project/harvest.py @@ -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: diff --git a/focus_project/focus_project/minutes.py b/focus_project/focus_project/minutes.py index d46c328..05db91f 100644 --- a/focus_project/focus_project/minutes.py +++ b/focus_project/focus_project/minutes.py @@ -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)) diff --git a/focus_project/focus_project/tests/conftest.py b/focus_project/focus_project/tests/conftest.py index 83bcb66..123e843 100644 --- a/focus_project/focus_project/tests/conftest.py +++ b/focus_project/focus_project/tests/conftest.py @@ -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).""" diff --git a/focus_project/focus_project/tests/test_activity_status.py b/focus_project/focus_project/tests/test_activity_status.py index bf2bd7f..df95587 100644 --- a/focus_project/focus_project/tests/test_activity_status.py +++ b/focus_project/focus_project/tests/test_activity_status.py @@ -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 diff --git a/focus_project/focus_project/tests/test_api.py b/focus_project/focus_project/tests/test_api.py index 6af82f3..adc799a 100644 --- a/focus_project/focus_project/tests/test_api.py +++ b/focus_project/focus_project/tests/test_api.py @@ -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): diff --git a/focus_project/focus_project/tests/test_harvest_backfill.py b/focus_project/focus_project/tests/test_harvest_backfill.py new file mode 100644 index 0000000..0510bbc --- /dev/null +++ b/focus_project/focus_project/tests/test_harvest_backfill.py @@ -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 diff --git a/focus_project/focus_project/tests/test_ledger_harvest.py b/focus_project/focus_project/tests/test_ledger_harvest.py index 25c4349..ad52c1f 100644 --- a/focus_project/focus_project/tests/test_ledger_harvest.py +++ b/focus_project/focus_project/tests/test_ledger_harvest.py @@ -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() diff --git a/focus_project/focus_project/tests/test_minutes.py b/focus_project/focus_project/tests/test_minutes.py index aba0847..ef26308 100644 --- a/focus_project/focus_project/tests/test_minutes.py +++ b/focus_project/focus_project/tests/test_minutes.py @@ -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 diff --git a/focus_project/focus_project/tests/test_update.py b/focus_project/focus_project/tests/test_update.py index 54f6c06..a89ca44 100644 --- a/focus_project/focus_project/tests/test_update.py +++ b/focus_project/focus_project/tests/test_update.py @@ -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") diff --git a/focus_project/pyproject.toml b/focus_project/pyproject.toml index 84ec045..30310c0 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.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.