From c98da93339ca0cb5692c1c4e88e967f3af81ae3b Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 2 Nov 2026 08:46:13 +0100 Subject: [PATCH] earned_time 0.19.1: per-day focus target Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WzxdWYcmik6APnHfc9gEVy --- earned_time/README.md | 2 +- earned_time/earned_time/_focus.py | 10 +- earned_time/earned_time/tests/_focus_fakes.py | 60 ++++++++++++ earned_time/earned_time/tests/test_focus.py | 98 +------------------ .../earned_time/tests/test_focus_edges.py | 82 ++++++++++++++++ earned_time/pyproject.toml | 2 +- 6 files changed, 155 insertions(+), 99 deletions(-) create mode 100644 earned_time/earned_time/tests/_focus_fakes.py create mode 100644 earned_time/earned_time/tests/test_focus_edges.py diff --git a/earned_time/README.md b/earned_time/README.md index 8297f0b..e0bda07 100644 --- a/earned_time/README.md +++ b/earned_time/README.md @@ -193,7 +193,7 @@ on its `day` only if `detail.declared_at` predates that day (else logged, no). While `focus_project.active(day)` returns a project, `earners_for` drops the gates it `replaces` (`book` = `reading`) and adds one counted `focus` earner -(`max_units` = `daily_minutes`) paying what they paid (shutdown by their rungs, +(`max_units` = the day's `daily_on`) paying what they paid (shutdown by their rungs, gaming by `max_gaming_minutes`: 60+60+60 over 120 min = 1.5/min): `floor(summed minutes * pay / daily_minutes)`, never per row. The penalty runs from the start day, cutting what the replaced gates cost; maturity never delays it. Ledger `~/.local/share/focus_project/ledger.json`: diff --git a/earned_time/earned_time/_focus.py b/earned_time/earned_time/_focus.py index 8a274e7..7705a53 100644 --- a/earned_time/earned_time/_focus.py +++ b/earned_time/earned_time/_focus.py @@ -61,6 +61,12 @@ def _active(day: date) -> Any: # noqa: ANN401 return None +def daily_on(project: Any, day: date) -> int: # noqa: ANN401 + """``day``'s target: ``project.daily_on(day)`` (a later edit never re-prices it).""" + pick = getattr(project, "daily_on", None) + return int(pick(day) if pick is not None else project.daily_minutes) + + def focus_earner(project: Any, dropped: tuple[Earner, ...], day: date) -> Earner: # noqa: ANN401 """The ``focus`` earner paying what ``dropped`` paid, over ``project``.""" return FocusEarner( @@ -74,7 +80,7 @@ def focus_earner(project: Any, dropped: tuple[Earner, ...], day: date) -> Earner shutdown_units_minutes(e, e.max_units or 1, day) for e in dropped ), kind="counted", - max_units=int(project.daily_minutes), + max_units=daily_on(project, day), penalty_from=project.start, confirmed_on=project.start - timedelta(days=1), ledger=FOCUS_LEDGER, @@ -103,7 +109,7 @@ def with_focus(registry: tuple[Earner, ...], day: date) -> tuple[Earner, ...]: return registry gates = {_SYNONYMS.get(g, g) for g in project.replaces} dropped = tuple(e for e in registry if e.name in gates) - if not dropped or int(project.daily_minutes) < 1: + if not dropped or daily_on(project, day) < 1: return registry kept = tuple(e for e in registry if e not in dropped) return (*kept, focus_earner(project, dropped, day)) diff --git a/earned_time/earned_time/tests/_focus_fakes.py b/earned_time/earned_time/tests/_focus_fakes.py new file mode 100644 index 0000000..383964a --- /dev/null +++ b/earned_time/earned_time/tests/_focus_fakes.py @@ -0,0 +1,60 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""Fakes for the focus tests: a fake ``focus_project`` and signed focus rows.""" + +from __future__ import annotations + +from datetime import date, timedelta +import sys +from types import SimpleNamespace +from typing import TYPE_CHECKING + +from earned_time import FocusEarner, earners_for +from earned_time.tests._maturity_rows import at, signed + +if TYPE_CHECKING: + import pytest + +START = date(2026, 11, 2) +DAY = START + timedelta(days=1) + + +def project(**changes: object) -> SimpleNamespace: + """A fake ``focus_project.Project`` (the contract's fields).""" + fields: dict[str, object] = { + "start": START, + "end": START + timedelta(days=3), + "replaces": ("leetcode", "reading", "tutor"), + "daily_minutes": 120, + } + return SimpleNamespace(**{**fields, **changes}) + + +def activate(monkeypatch: pytest.MonkeyPatch, active: object) -> None: + """Make ``focus_project.active`` return ``active`` on project days only.""" + + def on(day: date) -> object: + return active if START <= day <= START + timedelta(days=3) else None + + monkeypatch.setitem(sys.modules, "focus_project", SimpleNamespace(active=on)) + + +def row( + minutes: object, n: int, day: date = DAY, **detail: object +) -> dict[str, object]: + """A signed focus credit row ending at noon on ``day``.""" + return signed( + { + "kind": "credit", + "entry_id": f"hr-{day:%Y%m%d}-m{n}", + "day": day.isoformat(), + "detail": {"minutes": minutes, "ended_at": at(day), **detail}, + } + ) + + +def focus(monkeypatch: pytest.MonkeyPatch, **changes: object) -> FocusEarner: + """Today's focus earner under a fake project.""" + activate(monkeypatch, project(**changes)) + (found,) = (e for e in earners_for(DAY) if e.name == "focus") + assert isinstance(found, FocusEarner) + return found diff --git a/earned_time/earned_time/tests/test_focus.py b/earned_time/earned_time/tests/test_focus.py index 35b7a43..a33ef55 100644 --- a/earned_time/earned_time/tests/test_focus.py +++ b/earned_time/earned_time/tests/test_focus.py @@ -1,12 +1,9 @@ # Copyright (c) 2026 Krzysztof Rudnicki -"""The focus project earner: registry swap, pay, rounding, penalty, fail mode.""" +"""The focus project earner: registry swap, pay, rounding, penalty.""" from __future__ import annotations from datetime import date, timedelta -import logging -import sys -from types import SimpleNamespace from typing import TYPE_CHECKING import pytest @@ -14,7 +11,6 @@ import pytest import earned_time from earned_time import ( TUTOR_EARNERS, - FocusEarner, all_earners, credit_units, earners_for, @@ -25,57 +21,12 @@ from earned_time import ( shutdown_minutes_for, ) from earned_time._focus import focus_earner -from earned_time._match import focus_match, focus_minutes -from earned_time.tests._maturity_rows import at, ledger_file, signed +from earned_time.tests._focus_fakes import DAY, START, activate, focus, project, row +from earned_time.tests._maturity_rows import ledger_file if TYPE_CHECKING: from pathlib import Path -START = date(2026, 11, 2) -DAY = START + timedelta(days=1) - - -def project(**changes: object) -> SimpleNamespace: - """A fake ``focus_project.Project`` (the contract's fields).""" - fields: dict[str, object] = { - "start": START, - "end": START + timedelta(days=3), - "replaces": ("leetcode", "reading", "tutor"), - "daily_minutes": 120, - } - return SimpleNamespace(**{**fields, **changes}) - - -def activate(monkeypatch: pytest.MonkeyPatch, active: object) -> None: - """Make ``focus_project.active`` return ``active`` on project days only.""" - - def on(day: date) -> object: - return active if START <= day <= START + timedelta(days=3) else None - - monkeypatch.setitem(sys.modules, "focus_project", SimpleNamespace(active=on)) - - -def row( - minutes: object, n: int, day: date = DAY, **detail: object -) -> dict[str, object]: - """A signed focus credit row ending at noon on ``day``.""" - return signed( - { - "kind": "credit", - "entry_id": f"hr-{day:%Y%m%d}-m{n}", - "day": day.isoformat(), - "detail": {"minutes": minutes, "ended_at": at(day), **detail}, - } - ) - - -def focus(monkeypatch: pytest.MonkeyPatch, **changes: object) -> FocusEarner: - """Today's focus earner under a fake project.""" - activate(monkeypatch, project(**changes)) - (found,) = (e for e in earners_for(DAY) if e.name == "focus") - assert isinstance(found, FocusEarner) - return found - def test_the_replaced_gates_leave_and_focus_joins( monkeypatch: pytest.MonkeyPatch, @@ -203,46 +154,3 @@ def test_the_path_unit_watches_the_focus_ledger() -> None: def test_focus_pays_only_replaced_gates_that_exist() -> None: item = focus_earner(project(), (earned_time.LEETCODE,), DAY) assert (item.gaming_minutes, item.shutdown_minutes) == (60, 60) - - -@pytest.mark.parametrize("failure", [ImportError("gone"), ValueError("bad json")]) -def test_a_broken_focus_project_means_no_project_and_logs_once( - monkeypatch: pytest.MonkeyPatch, - caplog: pytest.LogCaptureFixture, - failure: Exception, -) -> None: - def broken(_day: date) -> object: - raise failure - - monkeypatch.setitem(sys.modules, "focus_project", SimpleNamespace(active=broken)) - monkeypatch.setattr("earned_time._focus._warned", set()) - with caplog.at_level(logging.WARNING): - assert earners_for(DAY) is TUTOR_EARNERS - assert earners_for(DAY) is TUTOR_EARNERS - assert sum("behaving as if no project" in m for m in caplog.messages) == 1 - - -def test_a_missing_package_is_no_project(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setitem(sys.modules, "focus_project", None) - assert earners_for(DAY) is TUTOR_EARNERS - - -def test_rows_count_by_ended_at_then_by_day(caplog: pytest.LogCaptureFixture) -> None: - window = earned_time.day_window(DAY) - assert focus_match(row(5, 5), window) - assert not focus_match(row(5, 5, START), window) - nostamp = signed( - {"kind": "credit", "day": DAY.isoformat(), "detail": {"minutes": 5}} - ) - assert focus_match(nostamp, window) - assert not focus_match({**nostamp, "day": START.isoformat()}, window) - with caplog.at_level(logging.WARNING): - assert not focus_match(row(0, 1), window) - assert "bad minutes" in caplog.text - - -@pytest.mark.parametrize("bad", [0, -1, True, 1.5, "5", None]) -def test_minutes_must_be_a_positive_int(bad: object) -> None: - assert focus_minutes({"detail": {"minutes": bad}}) is None - assert focus_minutes({"detail": "x"}) is None - assert focus_minutes({"detail": {"minutes": 4}}) == 4 diff --git a/earned_time/earned_time/tests/test_focus_edges.py b/earned_time/earned_time/tests/test_focus_edges.py new file mode 100644 index 0000000..4c05f38 --- /dev/null +++ b/earned_time/earned_time/tests/test_focus_edges.py @@ -0,0 +1,82 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""Focus project: fail mode, row matching, per-day target.""" + +from __future__ import annotations + +from datetime import date, timedelta +import logging +import sys +from types import SimpleNamespace + +import pytest + +from earned_time import TUTOR_EARNERS, day_window, earners_for +from earned_time._match import focus_match, focus_minutes +from earned_time.tests._focus_fakes import DAY, START, activate, project, row +from earned_time.tests._maturity_rows import signed + + +@pytest.mark.parametrize("failure", [ImportError("gone"), ValueError("bad json")]) +def test_a_broken_focus_project_means_no_project_and_logs_once( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + failure: Exception, +) -> None: + def broken(_day: date) -> object: + raise failure + + monkeypatch.setitem(sys.modules, "focus_project", SimpleNamespace(active=broken)) + monkeypatch.setattr("earned_time._focus._warned", set()) + with caplog.at_level(logging.WARNING): + assert earners_for(DAY) is TUTOR_EARNERS + assert earners_for(DAY) is TUTOR_EARNERS + assert sum("behaving as if no project" in m for m in caplog.messages) == 1 + + +def test_a_missing_package_is_no_project(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setitem(sys.modules, "focus_project", None) + assert earners_for(DAY) is TUTOR_EARNERS + + +def test_rows_count_by_ended_at_then_by_day(caplog: pytest.LogCaptureFixture) -> None: + window = day_window(DAY) + assert focus_match(row(5, 5), window) + assert not focus_match(row(5, 5, START), window) + nostamp = signed( + {"kind": "credit", "day": DAY.isoformat(), "detail": {"minutes": 5}} + ) + assert focus_match(nostamp, window) + assert not focus_match({**nostamp, "day": START.isoformat()}, window) + with caplog.at_level(logging.WARNING): + assert not focus_match(row(0, 1), window) + assert "bad minutes" in caplog.text + + +@pytest.mark.parametrize("bad", [0, -1, True, 1.5, "5", None]) +def test_minutes_must_be_a_positive_int(bad: object) -> None: + assert focus_minutes({"detail": {"minutes": bad}}) is None + assert focus_minutes({"detail": "x"}) is None + assert focus_minutes({"detail": {"minutes": 4}}) == 4 + + +def edited(day: date) -> int: + """A target cut from 120 to 60 minutes after ``DAY``.""" + return 120 if day <= DAY else 60 + + +def test_a_target_edit_never_reprices_past_days( + monkeypatch: pytest.MonkeyPatch, +) -> None: + activate(monkeypatch, project(daily_on=edited, daily_minutes=60)) + before = earners_for(DAY)[-1] + after = earners_for(DAY + timedelta(days=1))[-1] + assert (before.max_units, before.gaming_for(60)) == (120, 90) + assert (after.max_units, after.gaming_for(60)) == (60, 180) + assert (before.shutdown_for(60, DAY), after.shutdown_for(30, DAY)) == (90, 90) + + +def test_a_zero_target_on_a_day_changes_nothing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + activate(monkeypatch, project(daily_on=lambda _day: 0)) + assert earners_for(DAY) is TUTOR_EARNERS diff --git a/earned_time/pyproject.toml b/earned_time/pyproject.toml index 4d5b7d5..7af3648 100644 --- a/earned_time/pyproject.toml +++ b/earned_time/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "earned-time" -version = "0.19.0" +version = "0.19.1" description = "Penalty-then-reward registry: what every gate app earns in gaming time and shutdown time" requires-python = ">=3.14" dependencies = []