focus_project: update_project, v0.3.0
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
earned_time pre-commit / pre-commit (push) Waiting to run
earned_time tests / test (push) Waiting to run
file length / file-length (push) Waiting to run
focus-project pre-commit / pre-commit (push) Waiting to run
focus-project tests / test (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYcmik6APnHfc9gEVy
This commit is contained in:
Krzysztof Rudnicki 2026-11-02 08:34:32 +01:00
parent 8398ca4823
commit d45b1086c3
7 changed files with 329 additions and 5 deletions

View File

@ -22,12 +22,14 @@ from focus_project._api import (
stands_down,
)
from focus_project._errors import RegistryError
from focus_project._model import Project
from focus_project._model import Limits, Project
from focus_project._update import update_project
from focus_project.activity import record_activity
from focus_project.minutes import minutes_for
from focus_project.status import status
__all__ = [
"Limits",
"Project",
"RegistryError",
"active",
@ -38,4 +40,5 @@ __all__ = [
"record_activity",
"stands_down",
"status",
"update_project",
]

View File

@ -18,6 +18,32 @@ def normalize_gate(gate: str) -> str:
return _ALIASES.get(gate, gate)
@dataclass(frozen=True)
class Limits:
"""The minute targets that held up to and including ``until``."""
until: date
daily_minutes: int
unlock_minutes: int
def to_dict(self) -> dict[str, Any]:
"""The history row in projects.json."""
return {
"until": self.until.isoformat(),
"daily_minutes": self.daily_minutes,
"unlock_minutes": self.unlock_minutes,
}
@classmethod
def from_dict(cls, row: Any) -> Limits: # noqa: ANN401
"""Parse a history row (errors surface as RegistryError upstream)."""
return cls(
until=date.fromisoformat(row["until"]),
daily_minutes=int(row["daily_minutes"]),
unlock_minutes=int(row["unlock_minutes"]),
)
@dataclass(frozen=True)
class Project:
"""One time-boxed focus project; at most one is active per day."""
@ -33,6 +59,21 @@ class Project:
site_patterns: tuple[str, ...] = ()
ended_on: date | None = None
created_ms: int = 0
# Earlier targets, oldest first: an edit applies from its day, never back.
history: tuple[Limits, ...] = ()
def _limits_on(self, day: date) -> Limits | None:
return next((h for h in self.history if day <= h.until), None)
def daily_on(self, day: date) -> int:
"""The daily target that held on ``day``."""
past = self._limits_on(day)
return self.daily_minutes if past is None else past.daily_minutes
def unlock_on(self, day: date) -> int:
"""The unlock target that held on ``day``."""
past = self._limits_on(day)
return self.unlock_minutes if past is None else past.unlock_minutes
@property
def last_day(self) -> date:
@ -61,6 +102,7 @@ class Project:
"site_patterns": list(self.site_patterns),
"ended_on": self.ended_on.isoformat() if self.ended_on else None,
"created_ms": self.created_ms,
"history": [h.to_dict() for h in self.history],
}
@classmethod
@ -80,6 +122,7 @@ class Project:
site_patterns=tuple(row.get("site_patterns", ())),
ended_on=date.fromisoformat(ended) if ended else None,
created_ms=int(row.get("created_ms", 0)),
history=tuple(Limits.from_dict(h) for h in row.get("history", ())),
)
except (KeyError, TypeError, ValueError, AttributeError) as exc:
msg = f"bad project row {row!r}: {exc}"

View File

@ -0,0 +1,105 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""update_project: edit a project with create's validation, never the past."""
from __future__ import annotations
from dataclasses import replace
from datetime import date, timedelta
from typing import Any
from focus_project import _store
from focus_project._api import _validate
from focus_project._model import Limits, Project, normalize_gate
_EDITABLE = frozenset(
{
"name",
"start",
"end",
"replaces",
"daily_minutes",
"unlock_minutes",
"subjects",
"site_patterns",
}
)
_TUPLES = ("subjects", "site_patterns")
def _apply(project: Project, changes: dict[str, Any], today: date) -> Project:
"""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}"
raise ValueError(msg)
if project.last_day < today:
msg = f"project {project.id!r} is over (ended {project.last_day})"
raise ValueError(msg)
fields = {k: v for k, v in changes.items() if k not in _TUPLES}
if "replaces" in changes:
fields["replaces"] = tuple(
dict.fromkeys(map(normalize_gate, changes["replaces"]))
)
for key in _TUPLES:
if key in changes:
fields[key] = tuple(changes[key])
new_start = changes.get("start", project.start)
if new_start != project.start and today > min(project.start, new_start):
msg = f"cannot move start of a started project ({project.start})"
raise ValueError(msg)
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)
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) != (
new.daily_minutes,
new.unlock_minutes,
)
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))
def update_project(
project_id: str,
*,
today: date | None = None,
**changes: Any, # noqa: ANN401
) -> Project:
"""Edit ``project_id``; ValueError on bad input, overlap or a past edit.
Editable: name, start, end, replaces, daily_minutes, unlock_minutes,
subjects, site_patterns. 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.
"""
unknown = sorted(set(changes) - _EDITABLE)
if unknown:
msg = f"cannot change {unknown}"
raise ValueError(msg)
today = today or date.today() # noqa: DTZ011
with _store.locked():
existing = _store.read_all()
for i, project in enumerate(existing):
if project.id != project_id:
continue
edited = _apply(project, changes, today)
_validate(edited)
for other in existing:
if other.id != project_id and (
edited.start <= other.last_day and other.start <= edited.last_day
):
msg = f"project overlaps {other.id!r}"
raise ValueError(msg)
existing[i] = edited
_store.write_all(existing)
return edited
raise KeyError(project_id)

View File

@ -39,4 +39,4 @@ def minutes_for(day: date) -> int:
if project is None:
return 0
seconds = union_seconds(sources.collect(day, project))
return min(int(seconds // 60), project.daily_minutes)
return min(int(seconds // 60), project.daily_on(day))

View File

@ -26,8 +26,8 @@ def status(day: date) -> dict[str, Any]:
"project": project.id,
"name": project.name,
"minutes": minutes.minutes_for(day),
"daily_minutes": project.daily_minutes,
"unlock_minutes": project.unlock_minutes,
"daily_minutes": project.daily_on(day),
"unlock_minutes": project.unlock_on(day),
"ends": project.last_day.isoformat(),
"replaces": list(project.replaces),
}

View File

@ -0,0 +1,173 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""update_project: same validation as create, no rewriting of the past."""
from __future__ import annotations
from datetime import date, timedelta
import json
from typing import TYPE_CHECKING
import pytest
import focus_project as fp
if TYPE_CHECKING:
from pathlib import Path
D1 = date(2026, 11, 2)
D4 = date(2026, 11, 5)
DAY = timedelta(days=1)
def _make(pid: str = "hr", start: date = D1, end: date = D4) -> fp.Project:
return fp.create_project(
pid,
name="HackerRank prep",
start=start,
end=end,
replaces=["leetcode", "reading"],
daily_minutes=120,
unlock_minutes=30,
)
def test_update_changes_fields_and_normalizes() -> None:
_make()
got = fp.update_project(
"hr",
today=D1,
name="Interview",
end=D4 + DAY,
replaces=["book", "tutor", "book"],
subjects=["a"],
site_patterns=["x.com"],
)
assert fp.list_projects() == [got]
assert got.name == "Interview"
assert got.end == D4 + DAY
assert got.replaces == ("reading", "tutor")
assert got.subjects == ("a",)
assert got.site_patterns == ("x.com",)
assert got.created_ms == fp.list_projects()[0].created_ms
def test_update_nothing_is_a_noop() -> None:
before = _make()
assert fp.update_project("hr", today=D1) == before
def test_update_defaults_today_to_real_today() -> None:
now = date.today() # noqa: DTZ011
_make("now", start=now, end=now + DAY)
assert fp.update_project("now", name="X").name == "X"
def test_unknown_id_and_field() -> None:
_make()
with pytest.raises(KeyError):
fp.update_project("nope", today=D1, name="x")
with pytest.raises(ValueError, match="cannot change"):
fp.update_project("hr", today=D1, ended_on=D4)
@pytest.mark.parametrize(
("kw", "match"),
[
({"name": " "}, "name"),
({"end": D1 - DAY}, "end"),
({"replaces": ["workout"]}, "gate"),
({"replaces": []}, "replace"),
({"daily_minutes": 0}, "daily_minutes"),
({"unlock_minutes": 121}, "unlock_minutes"),
({"daily_minutes": 20}, "unlock_minutes"),
],
)
def test_update_validation(kw: dict[str, object], match: str) -> None:
before = _make()
with pytest.raises(ValueError, match=match):
fp.update_project("hr", today=D1, **kw)
assert fp.list_projects() == [before]
def test_update_overlap_rejected_self_is_not_overlap() -> None:
_make()
_make("next", start=D4 + DAY, end=D4 + 3 * DAY)
with pytest.raises(ValueError, match="overlap"):
fp.update_project("hr", today=D1, end=D4 + DAY)
fp.update_project("hr", today=D1, end=D4) # own range is fine
assert fp.update_project("hr", today=D1, start=D1, end=D4).end == D4
def test_past_start_refused_future_start_editable() -> None:
_make()
with pytest.raises(ValueError, match="start"):
fp.update_project("hr", today=D1 + DAY, start=D1 + 2 * DAY)
assert fp.update_project("hr", today=D1 + DAY, start=D1).start == D1
fp.update_project("hr", today=D1 - DAY, start=D1 + DAY)
assert fp.list_projects()[0].start == D1 + DAY
with pytest.raises(ValueError, match="start"):
fp.update_project("hr", today=D1, start=D1 - 2 * DAY)
def test_ended_and_finished_projects_refused() -> None:
_make()
with pytest.raises(ValueError, match="over"):
fp.update_project("hr", today=D4 + DAY, name="late")
fp.end_project("hr", D1)
with pytest.raises(ValueError, match="ended"):
fp.update_project("hr", today=D1, name="x")
def test_end_in_the_past_refused() -> None:
_make()
with pytest.raises(ValueError, match="past"):
fp.update_project("hr", today=D1 + 2 * DAY, end=D1 + DAY)
def test_daily_change_applies_from_today(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.unlock_on(D4) == 10
# a second edit the same day keeps the preserved past, a later day adds a row
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
raw = json.loads((data / "projects.json").read_text())["projects"][0]
assert raw["history"][0] == {
"until": "2026-11-03",
"daily_minutes": 120,
"unlock_minutes": 30,
}
def test_future_project_daily_change_keeps_no_history() -> None:
_make()
fp.update_project("hr", today=D1 - DAY, daily_minutes=60, unlock_minutes=10)
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 == ()
def test_same_value_edit_adds_no_history() -> None:
_make()
fp.update_project("hr", today=D1 + DAY, daily_minutes=120, name="n")
assert fp.list_projects()[0].history == ()
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)
assert fp.status(D1)["daily_minutes"] == 120
assert fp.status(D1)["unlock_minutes"] == 30
assert fp.status(D1 + DAY)["daily_minutes"] == 60

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "focus_project"
version = "0.2.0"
version = "0.3.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.