From 98c013ea3ff127b2d60e0d4dde9bbf2f6a514846 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 2 Nov 2026 09:12:32 +0100 Subject: [PATCH] earned_time 0.20.0: focus day cuts the full 180 A focus-project day cuts the whole 180 of gaming base (LeetCode's free hour too); a full day still earns it all back. Shutdown unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WzxdWxdzckyBH7fEbPywpr --- earned_time/README.md | 6 ++-- earned_time/earned_time/_focus.py | 7 +--- earned_time/earned_time/_resolve.py | 13 +------- earned_time/earned_time/tests/test_focus.py | 37 ++++++++++++++------- earned_time/pyproject.toml | 2 +- 5 files changed, 31 insertions(+), 34 deletions(-) diff --git a/earned_time/README.md b/earned_time/README.md index e0bda07..4049ee1 100644 --- a/earned_time/README.md +++ b/earned_time/README.md @@ -189,14 +189,14 @@ How far to trust a gate is computed from its ledger, never listed by name: `workout_match` counts a `runnerup_tcx` row by `completed_at`, a `rest_day` row on its `day` only if `detail.declared_at` predates that day (else logged, no). -## The focus project (0.19.0) +## The focus project (0.19.0, cut 180 since 0.20.0) 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` = 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`: +from the start day, cutting all of it, 180 with LeetCode's formerly free hour (0.20.0); maturity never delays it. Ledger `~/.local/share/focus_project/ledger.json`: signed `credit` rows, `entry_id` `--m`, `detail.minutes` (positive int), `detail.ended_at` (else `day`). `focus_project` unusable = no project (logged). @@ -243,7 +243,7 @@ signed `credit` rows, `entry_id` `--m`, `detail.minutes` ( ## Install ```console -pip install --user --break-system-packages --no-deps "earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.19.0#subdirectory=earned_time" +pip install --user --break-system-packages --no-deps "earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.20.0#subdirectory=earned_time" ``` The root `steam-backlog-enforcer.service` sets `HOME=/home/kuhy` and imports from diff --git a/earned_time/earned_time/_focus.py b/earned_time/earned_time/_focus.py index 7705a53..ef66448 100644 --- a/earned_time/earned_time/_focus.py +++ b/earned_time/earned_time/_focus.py @@ -31,11 +31,9 @@ _warned: set[str] = set() class FocusEarner(Earner): """Counted; ``gaming_minutes``/``shutdown_minutes`` pay a FULL day. - ``cut_gaming_minutes``: the base cut, what the replaced penalised gates cost. + The gaming base cut is all of it, LeetCode's free hour too (kuhy 2026-11-02). """ - cut_gaming_minutes: int = 0 - def gaming_for(self, units: int) -> int: """Gaming minutes of ``units`` focused minutes, floored.""" return self.capped(units) * self.gaming_minutes // (self.max_units or 1) @@ -73,9 +71,6 @@ def focus_earner(project: Any, dropped: tuple[Earner, ...], day: date) -> Earner name="focus", label="Focus", gaming_minutes=sum(e.max_gaming_minutes for e in dropped), - cut_gaming_minutes=sum( - e.max_gaming_minutes for e in dropped if e.penalty_from is not None - ), shutdown_minutes=sum( shutdown_units_minutes(e, e.max_units or 1, day) for e in dropped ), diff --git a/earned_time/earned_time/_resolve.py b/earned_time/earned_time/_resolve.py index 0022892..7c257ad 100644 --- a/earned_time/earned_time/_resolve.py +++ b/earned_time/earned_time/_resolve.py @@ -25,7 +25,6 @@ from datetime import UTC, date, datetime import logging from typing import TYPE_CHECKING, Final -from earned_time._focus import FocusEarner from earned_time._ladder import on_ladder, shutdown_ceiling_for from earned_time._leisure import base_minutes, ceiling_minutes, is_leisure from earned_time._maturity import confirmed_start, penalty_start @@ -46,16 +45,6 @@ def _today() -> date: return datetime.now(tz=UTC).astimezone().date() -def _gaming_cut(item: Earner) -> int: - """The gaming base cut of a penalised earner: what it can pay. - - A focus earner cuts only what the gates it replaced cost (LeetCode's was free). - """ - if isinstance(item, FocusEarner): - return item.cut_gaming_minutes - return item.max_gaming_minutes - - def _penalised( item: Earner, day: date, first_credits: Mapping[str, date | None] | None ) -> bool: @@ -128,7 +117,7 @@ def _base( penalised = [e for e in registry if _penalised(e, target, first_credits)] return Base( gaming_minutes=base_minutes(leisure=on) - - sum(_gaming_cut(e) for e in penalised), + - sum(e.max_gaming_minutes for e in penalised), shutdown_minutes=_shutdown_floor(target, registry, penalised), ) diff --git a/earned_time/earned_time/tests/test_focus.py b/earned_time/earned_time/tests/test_focus.py index a33ef55..c49bc37 100644 --- a/earned_time/earned_time/tests/test_focus.py +++ b/earned_time/earned_time/tests/test_focus.py @@ -92,28 +92,41 @@ def test_many_small_rows_pay_what_one_row_pays( def test_resolve_prices_base_terms_and_ceiling(monkeypatch: pytest.MonkeyPatch) -> None: activate(monkeypatch, project()) nothing = resolve({"workout": 0, "focus": 0}, DAY, leisure=False) - assert nothing.base.gaming_minutes == 300 - 120 + assert nothing.base.gaming_minutes == 300 - 180 assert nothing.base.shutdown_minutes == 18 * 60 half = resolve({"workout": 0, "focus": 60}, DAY, leisure=False) - assert half.gaming_minutes == 180 + 90 + assert half.gaming_minutes == 120 + 90 assert half.shutdown_minutes == 18 * 60 + 90 full = resolve({"workout": 1, "focus": 120}, DAY, leisure=False) assert full.shutdown_minutes == 23 * 60 -def test_a_project_day_is_neutral_against_the_gates_it_replaced( +def test_a_full_project_day_pays_what_the_replaced_gates_paid( monkeypatch: pytest.MonkeyPatch, ) -> None: + """Shutdown matches the old full day; gaming loses LeetCode's free hour.""" + activate(monkeypatch, project()) + done = {"workout": 1, "leetcode": 1, "reading": 1, "tutor": 60, "focus": 120} + old = resolve(done, DAY, TUTOR_EARNERS, leisure=False) + new = resolve(done, DAY, leisure=False) + assert new.shutdown_minutes == old.shutdown_minutes + # kuhy 2026-11-02: LeetCode's bonus hour was free on top of the base; the + # project day cuts it and pays it back, so the full day nets 60 less. + assert new.gaming_minutes == old.gaming_minutes - 60 + unsolved = resolve({**done, "leetcode": 0}, DAY, TUTOR_EARNERS, leisure=False) + assert new.gaming_minutes == unsolved.gaming_minutes + + +def test_an_idle_project_day_also_cuts_the_free_leetcode_hour( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """kuhy 2026-11-02: a project day cuts all it can pay (180), not 120.""" activate(monkeypatch, project()) zero = {"workout": 0, "leetcode": 0, "reading": 0, "tutor": 0, "focus": 0} - done = {"workout": 1, "leetcode": 1, "reading": 1, "tutor": 60, "focus": 120} - for answers in (zero, done): - old = resolve(answers, DAY, TUTOR_EARNERS, leisure=False) - new = resolve(answers, DAY, leisure=False) - assert (new.gaming_minutes, new.shutdown_minutes) == ( - old.gaming_minutes, - old.shutdown_minutes, - ) + old = resolve(zero, DAY, TUTOR_EARNERS, leisure=False) + new = resolve(zero, DAY, leisure=False) + assert new.gaming_minutes == old.gaming_minutes - 60 + assert new.shutdown_minutes == old.shutdown_minutes def test_the_penalty_is_not_delayed_by_the_first_credit( @@ -125,7 +138,7 @@ def test_the_penalty_is_not_delayed_by_the_first_credit( plain = resolve(answers, START, leisure=False) paid = resolve(answers, START, first_credits={"focus": START}, leisure=False) assert paid.base == plain.base - assert plain.base.gaming_minutes == 300 - 120 + assert plain.base.gaming_minutes == 300 - 180 def test_maturity_reads_the_focus_ledger( diff --git a/earned_time/pyproject.toml b/earned_time/pyproject.toml index 7af3648..cf1ee7a 100644 --- a/earned_time/pyproject.toml +++ b/earned_time/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "earned-time" -version = "0.19.1" +version = "0.20.0" description = "Penalty-then-reward registry: what every gate app earns in gaming time and shutdown time" requires-python = ">=3.14" dependencies = []