mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 19:07:17 +01:00
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:
parent
70c8aa8fbb
commit
640985a421
@ -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`).
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)),
|
||||
}
|
||||
|
||||
@ -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:
|
||||
|
||||
145
focus_project/focus_project/tests/test_update_ratchet.py
Normal file
145
focus_project/focus_project/tests/test_update_ratchet.py
Normal 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
|
||||
@ -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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user