mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-11 17:14:03 +01:00
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:
parent
c25ae4e0d1
commit
637428585c
@ -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`).
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
17
focus_project/focus_project/_clock.py
Normal file
17
focus_project/focus_project/_clock.py
Normal 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)
|
||||||
@ -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
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -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)."""
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
65
focus_project/focus_project/tests/test_harvest_backfill.py
Normal file
65
focus_project/focus_project/tests/test_harvest_backfill.py
Normal 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
|
||||||
@ -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()
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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")
|
||||||
|
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user