From 22fdf2c344417afb44beeb36ec7941152f589d08 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 2 Nov 2026 16:11:18 +0100 Subject: [PATCH] focus_project 0.7.1: daily floor 30/gate daily_minutes must be at least 30 per replaced gate, on create and update. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WzxdWYGW5NZL5uVpnqmNKo --- focus_project/README.md | 4 +- focus_project/focus_project/__init__.py | 2 + focus_project/focus_project/_api.py | 15 ++++- .../focus_project/tests/test_minutes.py | 7 +-- .../focus_project/tests/test_update.py | 35 +++++++----- .../tests/test_update_ratchet.py | 55 +++++++++++++++++-- focus_project/pyproject.toml | 2 +- 7 files changed, 91 insertions(+), 29 deletions(-) diff --git a/focus_project/README.md b/focus_project/README.md index 795caae..95ef7e6 100644 --- a/focus_project/README.md +++ b/focus_project/README.md @@ -31,6 +31,8 @@ on a started project holds from TOMORROW (a `history` row ends today with the ol gates removed and edits to a project that has not started hold from today. `apply_update(...).effective_from` says which. `end` cannot be before today, so an edit never uncovers today. A NEW project starting today may replace gates and set any targets: it is a -new obligation, not a reduction (`daily_minutes=1` there still pays a full day per minute). +new obligation, not a reduction (but see the floor). +Floor (0.7.1, coordinator decision for kuhy): `daily_minutes >= 30 x replaced gates` (min 30; `book` = `reading`), +enforced on create and update (`daily_floor(replaces)`), so a day cannot be made cheap. Open the work: `python3 -m focus_project.open [--no-site]` (tutor focus screen, `work_url`). Install: `./install.sh` (stdlib only, editable user install for `/usr/bin/python3`). diff --git a/focus_project/focus_project/__init__.py b/focus_project/focus_project/__init__.py index d676e6d..3f02862 100644 --- a/focus_project/focus_project/__init__.py +++ b/focus_project/focus_project/__init__.py @@ -17,6 +17,7 @@ from __future__ import annotations from focus_project._api import ( active, create_project, + daily_floor, end_project, list_projects, stands_down, @@ -36,6 +37,7 @@ __all__ = [ "active", "apply_update", "create_project", + "daily_floor", "end_project", "list_projects", "minutes_for", diff --git a/focus_project/focus_project/_api.py b/focus_project/focus_project/_api.py index cf89a9b..1ffe5e0 100644 --- a/focus_project/focus_project/_api.py +++ b/focus_project/focus_project/_api.py @@ -17,6 +17,8 @@ from focus_project._model import GATES, Project, normalize_gate _LOG = logging.getLogger(__name__) _ID = re.compile(r"[a-z0-9][a-z0-9-]*") MAX_WORK_URL = 2000 +# A project must ask real work: the focus earner pays per minute of ``daily``. +MIN_DAILY_PER_GATE = 30 def list_projects() -> list[Project]: @@ -64,6 +66,11 @@ def _validate_work_url(url: str) -> None: raise ValueError(msg) +def daily_floor(replaces: tuple[str, ...]) -> int: + """The least ``daily_minutes`` a project replacing these gates may ask.""" + return MIN_DAILY_PER_GATE * max(1, len(replaces)) + + def _validate(project: Project) -> None: _validate_work_url(project.work_url) if not _ID.fullmatch(project.id): @@ -78,8 +85,12 @@ def _validate(project: Project) -> None: if not project.replaces or not set(project.replaces) <= GATES: msg = f"replaces must be a non-empty subset of gates {sorted(GATES)}" raise ValueError(msg) - if project.daily_minutes <= 0: - msg = "daily_minutes must be positive" + floor = daily_floor(project.replaces) + if project.daily_minutes < floor: + msg = ( + f"daily_minutes must be at least {floor} " + f"({MIN_DAILY_PER_GATE} per replaced gate)" + ) raise ValueError(msg) if not 0 <= project.unlock_minutes <= project.daily_minutes: msg = "unlock_minutes must be within 0..daily_minutes" diff --git a/focus_project/focus_project/tests/test_minutes.py b/focus_project/focus_project/tests/test_minutes.py index ef26308..cd37c6c 100644 --- a/focus_project/focus_project/tests/test_minutes.py +++ b/focus_project/focus_project/tests/test_minutes.py @@ -130,13 +130,12 @@ def test_overlaps_never_double_count() -> None: def test_capped_at_daily_minutes() -> None: - project(daily=10) + project(daily=30) events( - ("timer", "start", at(9)), - ("timer", "stop", at(9, 8)), + *(("tutor", "heartbeat", at(9, 5 * k)) for k in range(1, 9)), ("tutor", "heartbeat", at(12)), ) - assert minutes.minutes_for(D1) == 10 + assert minutes.minutes_for(D1) == 30 def test_other_day_and_other_project_events_ignored() -> None: diff --git a/focus_project/focus_project/tests/test_update.py b/focus_project/focus_project/tests/test_update.py index add5eb0..6c9e15c 100644 --- a/focus_project/focus_project/tests/test_update.py +++ b/focus_project/focus_project/tests/test_update.py @@ -78,8 +78,13 @@ def test_unknown_id_and_field() -> None: ({"replaces": ["workout"]}, "gate"), ({"replaces": []}, "replace"), ({"daily_minutes": 0}, "daily_minutes"), + ({"daily_minutes": 59}, "at least 60"), + ( + {"daily_minutes": 89, "replaces": ["leetcode", "reading", "tutor"]}, + "at least 90", + ), ({"unlock_minutes": 121}, "unlock_minutes"), - ({"daily_minutes": 20}, "unlock_minutes"), + ({"daily_minutes": 60, "unlock_minutes": 61}, "unlock_minutes"), ], ) def test_update_validation(kw: dict[str, object], match: str) -> None: @@ -126,22 +131,22 @@ def test_end_in_the_past_refused() -> None: def test_daily_cut_applies_from_tomorrow(data: Path) -> None: _make() - fp.update_project("hr", today=D1 + 2 * DAY, daily_minutes=60, unlock_minutes=10) + fp.update_project("hr", today=D1 + 2 * DAY, daily_minutes=100, unlock_minutes=10) p = fp.list_projects()[0] - assert (p.daily_minutes, p.unlock_minutes) == (60, 10) + assert (p.daily_minutes, p.unlock_minutes) == (100, 10) assert p.daily_on(D1 + DAY) == 120 assert p.daily_on(D1 + 2 * DAY) == 120 # today keeps its targets assert p.unlock_on(D1 + 2 * DAY) == 30 - assert p.daily_on(D4) == 60 + assert p.daily_on(D4) == 100 assert p.unlock_on(D4) == 10 # a deeper cut the same day keeps the one preserved row, a later day adds one - fp.update_project("hr", today=D1 + 2 * DAY, daily_minutes=90) + fp.update_project("hr", today=D1 + 2 * DAY, daily_minutes=80) assert len(fp.list_projects()[0].history) == 1 - fp.update_project("hr", today=D4, daily_minutes=45, unlock_minutes=5) + fp.update_project("hr", today=D4, daily_minutes=65, unlock_minutes=5) p = fp.list_projects()[0] assert [h.until for h in p.history] == [D1 + 2 * DAY, D4] - assert p.daily_on(D4) == 90 - assert p.daily_on(D4 + DAY) == 45 + assert p.daily_on(D4) == 80 + assert p.daily_on(D4 + DAY) == 65 raw = json.loads((data / "projects.json").read_text())["projects"][0] assert raw["history"][0] == { "until": "2026-11-04", @@ -153,15 +158,15 @@ def test_daily_cut_applies_from_tomorrow(data: Path) -> None: def test_future_project_daily_change_keeps_no_history() -> None: _make() - fp.update_project("hr", today=D1 - DAY, daily_minutes=60, unlock_minutes=10) + fp.update_project("hr", today=D1 - DAY, daily_minutes=100, unlock_minutes=10) p = fp.list_projects()[0] assert p.history == () - assert p.daily_on(D1) == 60 + assert p.daily_on(D1) == 100 # on the first day a cut waits for tomorrow, like any started project - fp.update_project("hr", today=D1, daily_minutes=50) + fp.update_project("hr", today=D1, daily_minutes=70) p = fp.list_projects()[0] - assert p.daily_on(D1) == 60 - assert p.daily_on(D1 + DAY) == 50 + assert p.daily_on(D1) == 100 + assert p.daily_on(D1 + DAY) == 70 def test_same_value_edit_adds_no_history() -> None: @@ -172,11 +177,11 @@ def test_same_value_edit_adds_no_history() -> None: def test_status_and_minutes_use_the_day_values(tmp_path: Path) -> None: _make() - fp.update_project("hr", today=D1 + DAY, daily_minutes=60, unlock_minutes=10) + fp.update_project("hr", today=D1 + DAY, daily_minutes=100, unlock_minutes=10) assert fp.status(D1)["daily_minutes"] == 120 assert fp.status(D1)["unlock_minutes"] == 30 assert fp.status(D1 + DAY)["daily_minutes"] == 120 - assert fp.status(D1 + 2 * DAY)["daily_minutes"] == 60 + assert fp.status(D1 + 2 * DAY)["daily_minutes"] == 100 def test_work_url_is_editable_kept_and_clearable() -> None: diff --git a/focus_project/focus_project/tests/test_update_ratchet.py b/focus_project/focus_project/tests/test_update_ratchet.py index df21ce6..11c90f1 100644 --- a/focus_project/focus_project/tests/test_update_ratchet.py +++ b/focus_project/focus_project/tests/test_update_ratchet.py @@ -47,16 +47,16 @@ def test_red_team_unlock_zero_waits_for_tomorrow() -> None: assert fp.status(TODAY)["unlock_minutes"] == 30 -def test_one_minute_day_does_not_change_today() -> None: +def test_cut_to_the_floor_does_not_change_today() -> None: _make() fp.record_activity("tutor", "heartbeat", ts=_noon(TODAY), project="p") before = fp.status(TODAY) - fp.update_project("p", today=TODAY, daily_minutes=1, unlock_minutes=0) + fp.update_project("p", today=TODAY, daily_minutes=30, unlock_minutes=0) after = fp.status(TODAY) assert after == before assert after["daily_minutes"] == 120 assert fp.minutes_for(TODAY) == before["counted_minutes"] - assert fp.status(TOMORROW)["daily_minutes"] == 1 + assert fp.status(TOMORROW)["daily_minutes"] == 30 def test_raises_apply_today() -> None: @@ -114,15 +114,15 @@ def test_pending_cut_then_second_edits_the_same_day() -> None: def test_not_yet_started_project_is_a_new_obligation() -> None: _make(start=TOMORROW) - got = fp.apply_update("p", today=TODAY, daily_minutes=10, unlock_minutes=0) + got = fp.apply_update("p", today=TODAY, daily_minutes=30, unlock_minutes=0) assert got.effective_from == TODAY assert fp.list_projects()[0].history == () - assert _limits(TOMORROW)[:2] == (10, 0) + assert _limits(TOMORROW)[:2] == (30, 0) def test_project_starting_today_defers_a_cut() -> None: _make(start=TODAY) - got = fp.apply_update("p", today=TODAY, daily_minutes=1, unlock_minutes=0) + got = fp.apply_update("p", today=TODAY, daily_minutes=30, unlock_minutes=0) assert got.effective_from == TOMORROW assert _limits(TODAY)[:2] == (120, 30) @@ -143,3 +143,46 @@ def test_legacy_history_row_without_replaces_round_trips() -> None: assert "replaces" not in row.to_dict() with_r = fp.Limits(YESTERDAY, 5, 1, ("tutor",)) assert fp.Limits.from_dict(with_r.to_dict()) == with_r + + +@pytest.mark.parametrize( + ("gates", "daily", "ok"), + [ + (["leetcode"], 29, False), + (["leetcode"], 30, True), + (["leetcode", "reading"], 59, False), + (["leetcode", "reading", "tutor"], 89, False), + (["leetcode", "reading", "tutor"], 90, True), + (["leetcode", "book"], 60, True), # book = reading: still two gates + (["leetcode", "book", "reading"], 60, True), # one gate named twice + ], +) +def test_daily_floor_is_30_per_replaced_gate( + gates: list[str], daily: int, *, ok: bool +) -> None: + def make() -> fp.Project: + return fp.create_project( + "f", + name="F", + start=TODAY, + end=END, + replaces=gates, + daily_minutes=daily, + unlock_minutes=0, + ) + + if ok: + assert make().daily_minutes == daily + else: + with pytest.raises(ValueError, match="at least"): + make() + + +def test_edit_cannot_cut_below_the_floor_or_add_a_gate_it_cannot_pay() -> None: + _make(replaces=["leetcode", "reading", "tutor"]) + for bad in ({"daily_minutes": 89}, {"daily_minutes": 1, "unlock_minutes": 0}): + with pytest.raises(ValueError, match="at least 90"): + fp.update_project("p", today=TODAY, **bad) + assert fp.update_project("p", today=TODAY, daily_minutes=90).daily_minutes == 90 + assert fp.daily_floor(("a", "b")) == 60 + assert fp.daily_floor(()) == 30 diff --git a/focus_project/pyproject.toml b/focus_project/pyproject.toml index 3383c4c..e13e36c 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.7.0" +version = "0.7.1" 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.