mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 20:07:18 +01:00
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
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:
parent
8398ca4823
commit
d45b1086c3
@ -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",
|
||||
]
|
||||
|
||||
@ -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}"
|
||||
|
||||
105
focus_project/focus_project/_update.py
Normal file
105
focus_project/focus_project/_update.py
Normal 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)
|
||||
@ -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))
|
||||
|
||||
@ -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),
|
||||
}
|
||||
|
||||
173
focus_project/focus_project/tests/test_update.py
Normal file
173
focus_project/focus_project/tests/test_update.py
Normal 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
|
||||
@ -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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user