earned_time 0.19.1: per-day focus target
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
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:46:13 +01:00
parent d45b1086c3
commit c98da93339
6 changed files with 155 additions and 99 deletions

View File

@ -193,7 +193,7 @@ on its `day` only if `detail.declared_at` predates that day (else logged, no).
While `focus_project.active(day)` returns a project, `earners_for` drops the
gates it `replaces` (`book` = `reading`) and adds one counted `focus` earner
(`max_units` = `daily_minutes`) paying what they paid (shutdown by their rungs,
(`max_units` = the day's `daily_on`) paying what they paid (shutdown by their rungs,
gaming by `max_gaming_minutes`: 60+60+60 over 120 min = 1.5/min):
`floor(summed minutes * pay / daily_minutes)`, never per row. The penalty runs
from the start day, cutting what the replaced gates cost; maturity never delays it. Ledger `~/.local/share/focus_project/ledger.json`:

View File

@ -61,6 +61,12 @@ def _active(day: date) -> Any: # noqa: ANN401
return None
def daily_on(project: Any, day: date) -> int: # noqa: ANN401
"""``day``'s target: ``project.daily_on(day)`` (a later edit never re-prices it)."""
pick = getattr(project, "daily_on", None)
return int(pick(day) if pick is not None else project.daily_minutes)
def focus_earner(project: Any, dropped: tuple[Earner, ...], day: date) -> Earner: # noqa: ANN401
"""The ``focus`` earner paying what ``dropped`` paid, over ``project``."""
return FocusEarner(
@ -74,7 +80,7 @@ def focus_earner(project: Any, dropped: tuple[Earner, ...], day: date) -> Earner
shutdown_units_minutes(e, e.max_units or 1, day) for e in dropped
),
kind="counted",
max_units=int(project.daily_minutes),
max_units=daily_on(project, day),
penalty_from=project.start,
confirmed_on=project.start - timedelta(days=1),
ledger=FOCUS_LEDGER,
@ -103,7 +109,7 @@ def with_focus(registry: tuple[Earner, ...], day: date) -> tuple[Earner, ...]:
return registry
gates = {_SYNONYMS.get(g, g) for g in project.replaces}
dropped = tuple(e for e in registry if e.name in gates)
if not dropped or int(project.daily_minutes) < 1:
if not dropped or daily_on(project, day) < 1:
return registry
kept = tuple(e for e in registry if e not in dropped)
return (*kept, focus_earner(project, dropped, day))

View File

@ -0,0 +1,60 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Fakes for the focus tests: a fake ``focus_project`` and signed focus rows."""
from __future__ import annotations
from datetime import date, timedelta
import sys
from types import SimpleNamespace
from typing import TYPE_CHECKING
from earned_time import FocusEarner, earners_for
from earned_time.tests._maturity_rows import at, signed
if TYPE_CHECKING:
import pytest
START = date(2026, 11, 2)
DAY = START + timedelta(days=1)
def project(**changes: object) -> SimpleNamespace:
"""A fake ``focus_project.Project`` (the contract's fields)."""
fields: dict[str, object] = {
"start": START,
"end": START + timedelta(days=3),
"replaces": ("leetcode", "reading", "tutor"),
"daily_minutes": 120,
}
return SimpleNamespace(**{**fields, **changes})
def activate(monkeypatch: pytest.MonkeyPatch, active: object) -> None:
"""Make ``focus_project.active`` return ``active`` on project days only."""
def on(day: date) -> object:
return active if START <= day <= START + timedelta(days=3) else None
monkeypatch.setitem(sys.modules, "focus_project", SimpleNamespace(active=on))
def row(
minutes: object, n: int, day: date = DAY, **detail: object
) -> dict[str, object]:
"""A signed focus credit row ending at noon on ``day``."""
return signed(
{
"kind": "credit",
"entry_id": f"hr-{day:%Y%m%d}-m{n}",
"day": day.isoformat(),
"detail": {"minutes": minutes, "ended_at": at(day), **detail},
}
)
def focus(monkeypatch: pytest.MonkeyPatch, **changes: object) -> FocusEarner:
"""Today's focus earner under a fake project."""
activate(monkeypatch, project(**changes))
(found,) = (e for e in earners_for(DAY) if e.name == "focus")
assert isinstance(found, FocusEarner)
return found

View File

@ -1,12 +1,9 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""The focus project earner: registry swap, pay, rounding, penalty, fail mode."""
"""The focus project earner: registry swap, pay, rounding, penalty."""
from __future__ import annotations
from datetime import date, timedelta
import logging
import sys
from types import SimpleNamespace
from typing import TYPE_CHECKING
import pytest
@ -14,7 +11,6 @@ import pytest
import earned_time
from earned_time import (
TUTOR_EARNERS,
FocusEarner,
all_earners,
credit_units,
earners_for,
@ -25,57 +21,12 @@ from earned_time import (
shutdown_minutes_for,
)
from earned_time._focus import focus_earner
from earned_time._match import focus_match, focus_minutes
from earned_time.tests._maturity_rows import at, ledger_file, signed
from earned_time.tests._focus_fakes import DAY, START, activate, focus, project, row
from earned_time.tests._maturity_rows import ledger_file
if TYPE_CHECKING:
from pathlib import Path
START = date(2026, 11, 2)
DAY = START + timedelta(days=1)
def project(**changes: object) -> SimpleNamespace:
"""A fake ``focus_project.Project`` (the contract's fields)."""
fields: dict[str, object] = {
"start": START,
"end": START + timedelta(days=3),
"replaces": ("leetcode", "reading", "tutor"),
"daily_minutes": 120,
}
return SimpleNamespace(**{**fields, **changes})
def activate(monkeypatch: pytest.MonkeyPatch, active: object) -> None:
"""Make ``focus_project.active`` return ``active`` on project days only."""
def on(day: date) -> object:
return active if START <= day <= START + timedelta(days=3) else None
monkeypatch.setitem(sys.modules, "focus_project", SimpleNamespace(active=on))
def row(
minutes: object, n: int, day: date = DAY, **detail: object
) -> dict[str, object]:
"""A signed focus credit row ending at noon on ``day``."""
return signed(
{
"kind": "credit",
"entry_id": f"hr-{day:%Y%m%d}-m{n}",
"day": day.isoformat(),
"detail": {"minutes": minutes, "ended_at": at(day), **detail},
}
)
def focus(monkeypatch: pytest.MonkeyPatch, **changes: object) -> FocusEarner:
"""Today's focus earner under a fake project."""
activate(monkeypatch, project(**changes))
(found,) = (e for e in earners_for(DAY) if e.name == "focus")
assert isinstance(found, FocusEarner)
return found
def test_the_replaced_gates_leave_and_focus_joins(
monkeypatch: pytest.MonkeyPatch,
@ -203,46 +154,3 @@ def test_the_path_unit_watches_the_focus_ledger() -> None:
def test_focus_pays_only_replaced_gates_that_exist() -> None:
item = focus_earner(project(), (earned_time.LEETCODE,), DAY)
assert (item.gaming_minutes, item.shutdown_minutes) == (60, 60)
@pytest.mark.parametrize("failure", [ImportError("gone"), ValueError("bad json")])
def test_a_broken_focus_project_means_no_project_and_logs_once(
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
failure: Exception,
) -> None:
def broken(_day: date) -> object:
raise failure
monkeypatch.setitem(sys.modules, "focus_project", SimpleNamespace(active=broken))
monkeypatch.setattr("earned_time._focus._warned", set())
with caplog.at_level(logging.WARNING):
assert earners_for(DAY) is TUTOR_EARNERS
assert earners_for(DAY) is TUTOR_EARNERS
assert sum("behaving as if no project" in m for m in caplog.messages) == 1
def test_a_missing_package_is_no_project(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setitem(sys.modules, "focus_project", None)
assert earners_for(DAY) is TUTOR_EARNERS
def test_rows_count_by_ended_at_then_by_day(caplog: pytest.LogCaptureFixture) -> None:
window = earned_time.day_window(DAY)
assert focus_match(row(5, 5), window)
assert not focus_match(row(5, 5, START), window)
nostamp = signed(
{"kind": "credit", "day": DAY.isoformat(), "detail": {"minutes": 5}}
)
assert focus_match(nostamp, window)
assert not focus_match({**nostamp, "day": START.isoformat()}, window)
with caplog.at_level(logging.WARNING):
assert not focus_match(row(0, 1), window)
assert "bad minutes" in caplog.text
@pytest.mark.parametrize("bad", [0, -1, True, 1.5, "5", None])
def test_minutes_must_be_a_positive_int(bad: object) -> None:
assert focus_minutes({"detail": {"minutes": bad}}) is None
assert focus_minutes({"detail": "x"}) is None
assert focus_minutes({"detail": {"minutes": 4}}) == 4

View File

@ -0,0 +1,82 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Focus project: fail mode, row matching, per-day target."""
from __future__ import annotations
from datetime import date, timedelta
import logging
import sys
from types import SimpleNamespace
import pytest
from earned_time import TUTOR_EARNERS, day_window, earners_for
from earned_time._match import focus_match, focus_minutes
from earned_time.tests._focus_fakes import DAY, START, activate, project, row
from earned_time.tests._maturity_rows import signed
@pytest.mark.parametrize("failure", [ImportError("gone"), ValueError("bad json")])
def test_a_broken_focus_project_means_no_project_and_logs_once(
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
failure: Exception,
) -> None:
def broken(_day: date) -> object:
raise failure
monkeypatch.setitem(sys.modules, "focus_project", SimpleNamespace(active=broken))
monkeypatch.setattr("earned_time._focus._warned", set())
with caplog.at_level(logging.WARNING):
assert earners_for(DAY) is TUTOR_EARNERS
assert earners_for(DAY) is TUTOR_EARNERS
assert sum("behaving as if no project" in m for m in caplog.messages) == 1
def test_a_missing_package_is_no_project(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setitem(sys.modules, "focus_project", None)
assert earners_for(DAY) is TUTOR_EARNERS
def test_rows_count_by_ended_at_then_by_day(caplog: pytest.LogCaptureFixture) -> None:
window = day_window(DAY)
assert focus_match(row(5, 5), window)
assert not focus_match(row(5, 5, START), window)
nostamp = signed(
{"kind": "credit", "day": DAY.isoformat(), "detail": {"minutes": 5}}
)
assert focus_match(nostamp, window)
assert not focus_match({**nostamp, "day": START.isoformat()}, window)
with caplog.at_level(logging.WARNING):
assert not focus_match(row(0, 1), window)
assert "bad minutes" in caplog.text
@pytest.mark.parametrize("bad", [0, -1, True, 1.5, "5", None])
def test_minutes_must_be_a_positive_int(bad: object) -> None:
assert focus_minutes({"detail": {"minutes": bad}}) is None
assert focus_minutes({"detail": "x"}) is None
assert focus_minutes({"detail": {"minutes": 4}}) == 4
def edited(day: date) -> int:
"""A target cut from 120 to 60 minutes after ``DAY``."""
return 120 if day <= DAY else 60
def test_a_target_edit_never_reprices_past_days(
monkeypatch: pytest.MonkeyPatch,
) -> None:
activate(monkeypatch, project(daily_on=edited, daily_minutes=60))
before = earners_for(DAY)[-1]
after = earners_for(DAY + timedelta(days=1))[-1]
assert (before.max_units, before.gaming_for(60)) == (120, 90)
assert (after.max_units, after.gaming_for(60)) == (60, 180)
assert (before.shutdown_for(60, DAY), after.shutdown_for(30, DAY)) == (90, 90)
def test_a_zero_target_on_a_day_changes_nothing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
activate(monkeypatch, project(daily_on=lambda _day: 0))
assert earners_for(DAY) is TUTOR_EARNERS

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "earned-time"
version = "0.19.0"
version = "0.19.1"
description = "Penalty-then-reward registry: what every gate app earns in gaming time and shutdown time"
requires-python = ">=3.14"
dependencies = []