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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYGW5NZL5uVpnqmNKo
This commit is contained in:
Krzysztof Rudnicki 2026-11-02 16:01:58 +01:00
parent 70c8aa8fbb
commit 640985a421
9 changed files with 268 additions and 40 deletions

View File

@ -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: `<project>-<YYYYMMDD>-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`).

View File

@ -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",

View File

@ -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:

View File

@ -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."""

View File

@ -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

View File

@ -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)),
}

View File

@ -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:

View File

@ -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

View File

@ -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.