From d45b1086c36e21c1cbc46cc7e72874fdea7fb25a Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 2 Nov 2026 08:34:32 +0100 Subject: [PATCH] focus_project: update_project, v0.3.0 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WzxdWYcmik6APnHfc9gEVy --- focus_project/focus_project/__init__.py | 5 +- focus_project/focus_project/_model.py | 43 +++++ focus_project/focus_project/_update.py | 105 +++++++++++ focus_project/focus_project/minutes.py | 2 +- focus_project/focus_project/status.py | 4 +- .../focus_project/tests/test_update.py | 173 ++++++++++++++++++ focus_project/pyproject.toml | 2 +- 7 files changed, 329 insertions(+), 5 deletions(-) create mode 100644 focus_project/focus_project/_update.py create mode 100644 focus_project/focus_project/tests/test_update.py diff --git a/focus_project/focus_project/__init__.py b/focus_project/focus_project/__init__.py index 661cce2..16c74ff 100644 --- a/focus_project/focus_project/__init__.py +++ b/focus_project/focus_project/__init__.py @@ -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", ] diff --git a/focus_project/focus_project/_model.py b/focus_project/focus_project/_model.py index 4eb846f..cc4ed83 100644 --- a/focus_project/focus_project/_model.py +++ b/focus_project/focus_project/_model.py @@ -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}" diff --git a/focus_project/focus_project/_update.py b/focus_project/focus_project/_update.py new file mode 100644 index 0000000..b8d2dc4 --- /dev/null +++ b/focus_project/focus_project/_update.py @@ -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) diff --git a/focus_project/focus_project/minutes.py b/focus_project/focus_project/minutes.py index 46a0d8b..d46c328 100644 --- a/focus_project/focus_project/minutes.py +++ b/focus_project/focus_project/minutes.py @@ -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)) diff --git a/focus_project/focus_project/status.py b/focus_project/focus_project/status.py index 114e61b..158c4bd 100644 --- a/focus_project/focus_project/status.py +++ b/focus_project/focus_project/status.py @@ -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), } diff --git a/focus_project/focus_project/tests/test_update.py b/focus_project/focus_project/tests/test_update.py new file mode 100644 index 0000000..54f6c06 --- /dev/null +++ b/focus_project/focus_project/tests/test_update.py @@ -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 diff --git a/focus_project/pyproject.toml b/focus_project/pyproject.toml index 9ed1b69..84ec045 100644 --- a/focus_project/pyproject.toml +++ b/focus_project/pyproject.toml @@ -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.