mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 20:07:18 +01:00
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
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:
parent
de7d5d17eb
commit
22fdf2c344
@ -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`).
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user