From 640985a42194ccf5f575598e5438c6c25d8a3dc6 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 2 Nov 2026 16:01:58 +0100 Subject: [PATCH] focus_project 0.7.0: cuts wait a day An edit that lowers a target or adds a replaced gate holds from tomorrow, never today. apply_update returns effective_from. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WzxdWYGW5NZL5uVpnqmNKo --- focus_project/README.md | 8 + focus_project/focus_project/__init__.py | 4 +- focus_project/focus_project/_api.py | 2 +- focus_project/focus_project/_model.py | 20 ++- focus_project/focus_project/_update.py | 97 +++++++++--- focus_project/focus_project/status.py | 2 +- .../focus_project/tests/test_update.py | 28 ++-- .../tests/test_update_ratchet.py | 145 ++++++++++++++++++ focus_project/pyproject.toml | 2 +- 9 files changed, 268 insertions(+), 40 deletions(-) create mode 100644 focus_project/focus_project/tests/test_update_ratchet.py diff --git a/focus_project/README.md b/focus_project/README.md index e92bf20..795caae 100644 --- a/focus_project/README.md +++ b/focus_project/README.md @@ -24,5 +24,13 @@ next project day; the bank never crosses the project's end. `minutes_for(day)` i capped pay figure, `status(day)["minutes"]` the real count (121/120) with `carried_in`, `carry_out`, `own_minutes`, `counted_minutes`. Carry ledger rows: `--carry` (`detail.carry`), never over `daily_minutes` together with own rows. +Edits only ratchet (0.7.0): `update_project`/`apply_update(id, today=...)` never lowers +today's obligation. A lower `daily_minutes`/`unlock_minutes`, or a gate added to `replaces`, +on a started project holds from TOMORROW (a `history` row ends today with the old values, +`replaces_on(day)`/`daily_on`/`unlock_on` read it); a mixed edit defers as a whole. Raises, +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). 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 16c74ff..d676e6d 100644 --- a/focus_project/focus_project/__init__.py +++ b/focus_project/focus_project/__init__.py @@ -23,7 +23,7 @@ from focus_project._api import ( ) from focus_project._errors import RegistryError from focus_project._model import Limits, Project -from focus_project._update import update_project +from focus_project._update import Update, apply_update, update_project from focus_project.activity import record_activity from focus_project.minutes import minutes_for from focus_project.status import status @@ -32,7 +32,9 @@ __all__ = [ "Limits", "Project", "RegistryError", + "Update", "active", + "apply_update", "create_project", "end_project", "list_projects", diff --git a/focus_project/focus_project/_api.py b/focus_project/focus_project/_api.py index 23cf9de..cf89a9b 100644 --- a/focus_project/focus_project/_api.py +++ b/focus_project/focus_project/_api.py @@ -46,7 +46,7 @@ def stands_down(gate: str, day: date) -> bool: "focus_project registry unreadable, gate %s stays on: %s", gate, exc ) return False - return project is not None and project.replaces_gate(gate) + return project is not None and project.replaces_gate(gate, day) def _validate_work_url(url: str) -> None: diff --git a/focus_project/focus_project/_model.py b/focus_project/focus_project/_model.py index 8d3a931..97c5ab9 100644 --- a/focus_project/focus_project/_model.py +++ b/focus_project/focus_project/_model.py @@ -25,22 +25,29 @@ class Limits: until: date daily_minutes: int unlock_minutes: int + # None = a row from before 0.7.0: the project's current gates apply. + replaces: tuple[str, ...] | None = None def to_dict(self) -> dict[str, Any]: """The history row in projects.json.""" - return { + row: dict[str, Any] = { "until": self.until.isoformat(), "daily_minutes": self.daily_minutes, "unlock_minutes": self.unlock_minutes, } + if self.replaces is not None: + row["replaces"] = list(self.replaces) + return row @classmethod def from_dict(cls, row: Any) -> Limits: # noqa: ANN401 """Parse a history row (errors surface as RegistryError upstream).""" + gates = row.get("replaces") return cls( until=date.fromisoformat(row["until"]), daily_minutes=int(row["daily_minutes"]), unlock_minutes=int(row["unlock_minutes"]), + replaces=None if gates is None else tuple(gates), ) @@ -77,6 +84,11 @@ class Project: past = self._limits_on(day) return self.unlock_minutes if past is None else past.unlock_minutes + def replaces_on(self, day: date) -> tuple[str, ...]: + """The gates this project switched off on ``day``.""" + past = self._limits_on(day) + return self.replaces if past is None or past.replaces is None else past.replaces + @property def last_day(self) -> date: """The last active day: the early-end day if set, else ``end``.""" @@ -86,9 +98,9 @@ class Project: """Whether this project is active on ``day``.""" return self.start <= day <= self.last_day - def replaces_gate(self, gate: str) -> bool: - """Whether this project switches ``gate`` off.""" - return normalize_gate(gate) in self.replaces + def replaces_gate(self, gate: str, day: date) -> bool: + """Whether this project switches ``gate`` off on ``day``.""" + return normalize_gate(gate) in self.replaces_on(day) def to_dict(self) -> dict[str, Any]: """The projects.json row.""" diff --git a/focus_project/focus_project/_update.py b/focus_project/focus_project/_update.py index ff0d29d..1b5ad85 100644 --- a/focus_project/focus_project/_update.py +++ b/focus_project/focus_project/_update.py @@ -3,7 +3,7 @@ from __future__ import annotations -from dataclasses import replace +from dataclasses import dataclass, replace from datetime import date, timedelta from typing import Any @@ -27,7 +27,7 @@ _EDITABLE = frozenset( _TUPLES = ("subjects", "site_patterns") -def _apply(project: Project, changes: dict[str, Any], today: date) -> Project: +def _apply(project: Project, changes: dict[str, Any], today: date) -> Update: """The edited project; ValueError for what an edit may not touch.""" if project.ended_on is not None: msg = f"project {project.id!r} ended on {project.ended_on}" @@ -50,37 +50,81 @@ def _apply(project: Project, changes: dict[str, Any], today: date) -> Project: if "end" in changes and changes["end"] < today: msg = f"end {changes['end']} is in the past" raise ValueError(msg) - edited = replace(project, **fields) - return _keep_past_limits(project, edited, today) + return _schedule(project, replace(project, **fields), today) -def _keep_past_limits(old: Project, new: Project, today: date) -> Project: - """Freeze the old targets for the days before ``today`` if they changed.""" - changed = (old.daily_minutes, old.unlock_minutes) != ( +def _held(project: Project, day: date) -> Limits: + """What held on ``day``: both targets and the replaced gates.""" + return Limits( + day, + project.daily_on(day), + project.unlock_on(day), + project.replaces_on(day), + ) + + +def _reduces(held: Limits, new: Project) -> bool: + """Whether ``new`` asks less than ``held``: lower target or a gate more.""" + return ( + new.daily_minutes < held.daily_minutes + or new.unlock_minutes < held.unlock_minutes + or not set(new.replaces) <= set(held.replaces or ()) + ) + + +def _schedule(old: Project, new: Project, today: date) -> Update: + """Date the edit: a reduction of a started project waits for tomorrow. + + A raise (or a project that has not started) applies from ``today``; the + days before keep what held. Anything that lowers a target or switches a + gate off defers the whole edit one day, so no edit can undo a lock or + cheapen the pay of a day already running. + """ + if (old.daily_minutes, old.unlock_minutes, old.replaces) == ( new.daily_minutes, new.unlock_minutes, + new.replaces, + ): + return Update(new, today) + deferred = today >= old.start and _reduces(_held(old, today), new) + first = today + timedelta(days=1) if deferred else today + last = first - timedelta(days=1) + history = [h for h in old.history if h.until < first] + held = _held(old, last) + same = (held.daily_minutes, held.unlock_minutes, held.replaces) == ( + new.daily_minutes, + new.unlock_minutes, + new.replaces, ) - cutoff = today - timedelta(days=1) - if not changed or cutoff < old.start: - return new - if old.history and old.history[-1].until == cutoff: - return new - row = Limits(cutoff, old.daily_minutes, old.unlock_minutes) - return replace(new, history=(*old.history, row)) + if last >= old.start and not same and not (history and history[-1].until == last): + history.append(held) + return Update(replace(new, history=tuple(history)), first) -def update_project( +@dataclass(frozen=True) +class Update: + """An edited project and the first day its new targets and gates hold.""" + + project: Project + effective_from: date + + +def apply_update( project_id: str, *, today: date | None = None, **changes: Any, # noqa: ANN401 -) -> Project: +) -> Update: """Edit ``project_id``; ValueError on bad input, overlap or a past edit. Editable: name, start, end, replaces, daily_minutes, unlock_minutes, - subjects, site_patterns, work_url. A started project's start cannot move; changed - minute targets apply from ``today`` (default: now) on, earlier days keep - theirs. KeyError for an unknown id. + subjects, site_patterns, work_url. A started project's start cannot move. + An edit never lowers today's obligation (0.7.0): a lower daily or unlock + target, or a gate added to ``replaces``, holds from tomorrow, the earlier + days (today included) keep theirs. Raises, gates removed and edits to a + project that has not started hold from ``today`` (default: now). The + result says which: ``effective_from``. A mixed edit defers as a whole. + KeyError for an unknown id. """ unknown = sorted(set(changes) - _EDITABLE) if unknown: @@ -92,7 +136,8 @@ def update_project( for i, project in enumerate(existing): if project.id != project_id: continue - edited = _apply(project, changes, today) + done = _apply(project, changes, today) + edited = done.project _validate(edited) for other in existing: if other.id != project_id and ( @@ -102,5 +147,15 @@ def update_project( raise ValueError(msg) existing[i] = edited _store.write_all(existing) - return edited + return done raise KeyError(project_id) + + +def update_project( + project_id: str, + *, + today: date | None = None, + **changes: Any, # noqa: ANN401 +) -> Project: + """:func:`apply_update` returning just the project.""" + return apply_update(project_id, today=today, **changes).project diff --git a/focus_project/focus_project/status.py b/focus_project/focus_project/status.py index b25f9e8..c1cbc73 100644 --- a/focus_project/focus_project/status.py +++ b/focus_project/focus_project/status.py @@ -46,5 +46,5 @@ def status(day: date) -> dict[str, Any]: "daily_minutes": daily, "unlock_minutes": project.unlock_on(day), "ends": project.last_day.isoformat(), - "replaces": list(project.replaces), + "replaces": list(project.replaces_on(day)), } diff --git a/focus_project/focus_project/tests/test_update.py b/focus_project/focus_project/tests/test_update.py index 85ffb14..add5eb0 100644 --- a/focus_project/focus_project/tests/test_update.py +++ b/focus_project/focus_project/tests/test_update.py @@ -124,28 +124,30 @@ def test_end_in_the_past_refused() -> None: fp.update_project("hr", today=D1 + 2 * DAY, end=D1 + DAY) -def test_daily_change_applies_from_today(data: Path) -> 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) p = fp.list_projects()[0] assert (p.daily_minutes, p.unlock_minutes) == (60, 10) assert p.daily_on(D1 + DAY) == 120 - assert p.unlock_on(D1 + DAY) == 30 - assert p.daily_on(D1 + 2 * DAY) == 60 + 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.unlock_on(D4) == 10 - # a second edit the same day keeps the preserved past, a later day adds a row + # 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) assert len(fp.list_projects()[0].history) == 1 fp.update_project("hr", today=D4, daily_minutes=45, unlock_minutes=5) p = fp.list_projects()[0] - assert [h.until for h in p.history] == [D1 + DAY, D4 - DAY] - assert p.daily_on(D1 + 2 * DAY) == 90 - assert p.daily_on(D4) == 45 + 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 raw = json.loads((data / "projects.json").read_text())["projects"][0] assert raw["history"][0] == { - "until": "2026-11-03", + "until": "2026-11-04", "daily_minutes": 120, "unlock_minutes": 30, + "replaces": ["leetcode", "reading"], } @@ -155,8 +157,11 @@ def test_future_project_daily_change_keeps_no_history() -> None: p = fp.list_projects()[0] assert p.history == () assert p.daily_on(D1) == 60 - fp.update_project("hr", today=D1, daily_minutes=50) # first day: nothing past - assert fp.list_projects()[0].history == () + # on the first day a cut waits for tomorrow, like any started project + fp.update_project("hr", today=D1, daily_minutes=50) + p = fp.list_projects()[0] + assert p.daily_on(D1) == 60 + assert p.daily_on(D1 + DAY) == 50 def test_same_value_edit_adds_no_history() -> None: @@ -170,7 +175,8 @@ def test_status_and_minutes_use_the_day_values(tmp_path: Path) -> None: fp.update_project("hr", today=D1 + DAY, daily_minutes=60, unlock_minutes=10) assert fp.status(D1)["daily_minutes"] == 120 assert fp.status(D1)["unlock_minutes"] == 30 - assert fp.status(D1 + DAY)["daily_minutes"] == 60 + assert fp.status(D1 + DAY)["daily_minutes"] == 120 + assert fp.status(D1 + 2 * DAY)["daily_minutes"] == 60 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 new file mode 100644 index 0000000..df21ce6 --- /dev/null +++ b/focus_project/focus_project/tests/test_update_ratchet.py @@ -0,0 +1,145 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""An edit never lowers today's obligation: reductions apply from tomorrow.""" + +from __future__ import annotations + +from datetime import date, datetime, time, timedelta + +import pytest + +import focus_project as fp + +START = date(2026, 11, 2) +TODAY = date(2026, 11, 4) +TOMORROW = TODAY + timedelta(days=1) +YESTERDAY = TODAY - timedelta(days=1) +END = date(2026, 11, 20) + + +def _make(start: date = START, replaces: list[str] | None = None) -> fp.Project: + return fp.create_project( + "p", + name="P", + start=start, + end=END, + replaces=replaces or ["leetcode"], + daily_minutes=120, + unlock_minutes=30, + ) + + +def _noon(day: date) -> float: + return datetime.combine(day, time(12)).timestamp() + + +def _limits(day: date) -> tuple[int, int, tuple[str, ...]]: + p = fp.list_projects()[0] + return p.daily_on(day), p.unlock_on(day), p.replaces_on(day) + + +def test_red_team_unlock_zero_waits_for_tomorrow() -> None: + _make() + got = fp.apply_update("p", today=TODAY, unlock_minutes=0) + assert got.effective_from == TOMORROW + assert _limits(TODAY)[1] == 30 + assert _limits(YESTERDAY)[1] == 30 + assert _limits(TOMORROW)[1] == 0 + assert fp.status(TODAY)["unlock_minutes"] == 30 + + +def test_one_minute_day_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) + 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 + + +def test_raises_apply_today() -> None: + _make() + got = fp.apply_update("p", today=TODAY, daily_minutes=180, unlock_minutes=40) + assert got.effective_from == TODAY + assert _limits(TODAY)[:2] == (180, 40) + assert _limits(YESTERDAY)[:2] == (120, 30) + + +def test_mixed_edit_defers_whole_edit() -> None: + _make() + got = fp.apply_update("p", today=TODAY, daily_minutes=180, unlock_minutes=10) + assert got.effective_from == TOMORROW + assert _limits(TODAY)[:2] == (120, 30) + assert _limits(TOMORROW)[:2] == (180, 10) + + +def test_added_gate_waits_removed_gate_is_immediate() -> None: + _make(replaces=["leetcode", "reading"]) + got = fp.apply_update("p", today=TODAY, replaces=["leetcode", "reading", "tutor"]) + assert got.effective_from == TOMORROW + assert not fp.stands_down("tutor", TODAY) + assert fp.stands_down("tutor", TOMORROW) + assert fp.status(TODAY)["replaces"] == ["leetcode", "reading"] + got = fp.apply_update("p", today=TODAY, replaces=["leetcode"]) + assert got.effective_from == TODAY + assert not fp.stands_down("reading", TODAY) + assert not fp.stands_down("reading", TOMORROW) + assert fp.stands_down("reading", YESTERDAY) + + +def test_pending_cut_then_second_edits_the_same_day() -> None: + _make() + fp.update_project("p", today=TODAY, daily_minutes=60, unlock_minutes=10) + assert _limits(TODAY)[:2] == (120, 30) + # a smaller cut than E is still a cut; a deeper cut still waits + fp.update_project("p", today=TODAY, daily_minutes=50) + assert _limits(TODAY)[:2] == (120, 30) + assert _limits(TOMORROW)[:2] == (50, 10) + assert len(fp.list_projects()[0].history) == 1 + # a name-only edit leaves the pending cut alone + assert fp.apply_update("p", today=TODAY, name="Q").effective_from == TODAY + assert _limits(TOMORROW)[:2] == (50, 10) + # back up to (or over) today's targets cancels the pending cut, applies now + got = fp.apply_update("p", today=TODAY, daily_minutes=120, unlock_minutes=30) + assert got.effective_from == TODAY + assert fp.list_projects()[0].history == () + got = fp.apply_update("p", today=TODAY, daily_minutes=60) + got = fp.apply_update("p", today=TODAY, daily_minutes=200) + assert got.effective_from == TODAY + assert _limits(TODAY)[:2] == (200, 30) + assert _limits(YESTERDAY)[:2] == (120, 30) + + +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) + assert got.effective_from == TODAY + assert fp.list_projects()[0].history == () + assert _limits(TOMORROW)[:2] == (10, 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) + assert got.effective_from == TOMORROW + assert _limits(TODAY)[:2] == (120, 30) + + +def test_early_end_keeps_today_covered() -> None: + _make() + fp.update_project("p", today=TODAY, end=TODAY) + assert fp.active(TODAY) is not None + assert fp.active(TOMORROW) is None + with pytest.raises(ValueError, match="past"): + fp.update_project("p", today=TODAY, end=YESTERDAY) + + +def test_legacy_history_row_without_replaces_round_trips() -> None: + old = {"until": "2026-11-03", "daily_minutes": 5, "unlock_minutes": 1} + row = fp.Limits.from_dict(old) + assert row.replaces is 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 diff --git a/focus_project/pyproject.toml b/focus_project/pyproject.toml index 3f4ccb5..3383c4c 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.6.0" +version = "0.7.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.