focus_project 0.7.1: daily floor 30/gate
Some checks failed
code-runner pre-commit / pre-commit (push) Has been cancelled
code-runner tests / test (push) Has been cancelled
dependency freshness / dependencies on newest stable (push) Has been cancelled
file length / file-length (push) Has been cancelled
focus-project pre-commit / pre-commit (push) Has been cancelled
focus-project tests / test (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
palette drift / palette (push) Has been cancelled
repo-contract / repo contract (push) Has been cancelled
shared gate tests / gate unit tests (push) Has been cancelled
sql-drills pre-commit / pre-commit (push) Has been cancelled
sql-drills tests / test (push) Has been cancelled

daily_minutes must be at least 30 per replaced gate,
on create and update.

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:11:18 +01:00
parent de7d5d17eb
commit 22fdf2c344
7 changed files with 91 additions and 29 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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