From ba4dd5854509d3de52f144d89003c53cfe6df27b Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 2 Nov 2026 15:22:10 +0100 Subject: [PATCH] earned_time 0.21.0: focus day pays 240 gaming kuhy 2026-11-02: keep the 180 base cut, pay 2 gaming min per focus min (240 at 120/120), so a full focus day is 8 h again; an idle day stays 2 h. FocusEarner.cut_gaming_minutes holds the cut (_resolve._gaming_cut) so the pay (cut + pure bonuses again) can exceed it. Shutdown unchanged. Carry rows (focus_project 0.6.0, --carry) pay like own rows; test added. Failure modes unchanged: focus_project unusable = no project (fail-open, logged); unreadable ledger = None, earns nothing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WzxdWYGW5NZL5uVpnqmNKo --- earned_time/README.md | 6 +-- earned_time/earned_time/_focus.py | 17 +++++-- earned_time/earned_time/_resolve.py | 13 ++++- earned_time/earned_time/tests/test_focus.py | 48 +++++++++++++------ .../earned_time/tests/test_focus_edges.py | 4 +- earned_time/pyproject.toml | 2 +- 6 files changed, 65 insertions(+), 25 deletions(-) diff --git a/earned_time/README.md b/earned_time/README.md index 4049ee1..1aa92d2 100644 --- a/earned_time/README.md +++ b/earned_time/README.md @@ -189,12 +189,12 @@ 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, cut 180 since 0.20.0) +## The focus project (0.19.0, cut 180 since 0.20.0, pays 240 since 0.21.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): +(`max_units` = the day's `daily_on`). Shutdown pays their rungs (180/120 min); gaming +their `max_gaming_minutes` + pure bonuses again (240/120 min = 2/min; full day = 8 h): `floor(summed minutes * pay / daily_minutes)`, never per row. The penalty runs 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), diff --git a/earned_time/earned_time/_focus.py b/earned_time/earned_time/_focus.py index ef66448..afdaa37 100644 --- a/earned_time/earned_time/_focus.py +++ b/earned_time/earned_time/_focus.py @@ -31,9 +31,13 @@ _warned: set[str] = set() class FocusEarner(Earner): """Counted; ``gaming_minutes``/``shutdown_minutes`` pay a FULL day. - The gaming base cut is all of it, LeetCode's free hour too (kuhy 2026-11-02). + ``cut_gaming_minutes``: the gaming base cut, all the replaced gates paid, + LeetCode's free hour too (0.20.0). The full-day gaming pay adds that free + hour once more, so a full day matches the old one (kuhy 2026-11-02, 0.21.0). """ + 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) @@ -66,11 +70,18 @@ def daily_on(project: Any, day: date) -> int: # noqa: ANN401 def focus_earner(project: Any, dropped: tuple[Earner, ...], day: date) -> Earner: # noqa: ANN401 - """The ``focus`` earner paying what ``dropped`` paid, over ``project``.""" + """The ``focus`` earner paying what ``dropped`` paid, over ``project``. + + Gaming: the cut is every dropped gate's full pay; a full day pays that plus + the pure bonuses (no ``penalty_from``) again: 180 + 60 = 240 at 120 min. + """ + cut = sum(e.max_gaming_minutes for e in dropped) + free = tuple(e for e in dropped if e.penalty_from is None) return FocusEarner( name="focus", label="Focus", - gaming_minutes=sum(e.max_gaming_minutes for e in dropped), + gaming_minutes=cut + sum(e.max_gaming_minutes for e in free), + cut_gaming_minutes=cut, 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 7c257ad..5722060 100644 --- a/earned_time/earned_time/_resolve.py +++ b/earned_time/earned_time/_resolve.py @@ -25,6 +25,7 @@ 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 @@ -45,6 +46,16 @@ def _today() -> date: return datetime.now(tz=UTC).astimezone().date() +def _gaming_cut(item: Earner) -> int: + """What a penalty in force takes off the gaming base: all ``item`` can pay. + + A focus earner cuts what the gates it replaced paid; it pays more (0.21.0). + """ + 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: @@ -117,7 +128,7 @@ def _base( penalised = [e for e in registry if _penalised(e, target, first_credits)] return Base( gaming_minutes=base_minutes(leisure=on) - - sum(e.max_gaming_minutes for e in penalised), + - sum(_gaming_cut(e) 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 c49bc37..2c14c89 100644 --- a/earned_time/earned_time/tests/test_focus.py +++ b/earned_time/earned_time/tests/test_focus.py @@ -22,7 +22,7 @@ from earned_time import ( ) from earned_time._focus import focus_earner from earned_time.tests._focus_fakes import DAY, START, activate, focus, project, row -from earned_time.tests._maturity_rows import ledger_file +from earned_time.tests._maturity_rows import ledger_file, signed if TYPE_CHECKING: from pathlib import Path @@ -61,20 +61,24 @@ def test_a_project_that_changes_nothing_leaves_the_registry( assert earners_for(DAY) is TUTOR_EARNERS -def test_a_full_day_pays_what_the_replaced_gates_paid( +def test_a_full_day_pays_the_cut_plus_the_free_leetcode_hour( monkeypatch: pytest.MonkeyPatch, ) -> None: + """kuhy 2026-11-02 (0.21.0): 2 gaming minutes per focus minute, 240 a day.""" item = focus(monkeypatch) assert item.max_units == 120 - assert (item.gaming_for(120), item.shutdown_for(120, DAY)) == (180, 180) - assert (item.gaming_for(999), item.shutdown_for(999, DAY)) == (180, 180) - assert (item.max_gaming_minutes, item.gaming_for(-5)) == (180, 0) + assert (item.gaming_for(120), item.shutdown_for(120, DAY)) == (240, 180) + assert (item.gaming_for(999), item.shutdown_for(999, DAY)) == (240, 180) + assert (item.max_gaming_minutes, item.gaming_for(-5)) == (240, 0) + assert item.cut_gaming_minutes == 180 def test_pay_is_floored_on_the_summed_minutes(monkeypatch: pytest.MonkeyPatch) -> None: item = focus(monkeypatch) - assert [item.gaming_for(n) for n in (1, 2, 3, 7)] == [1, 3, 4, 10] + assert [item.gaming_for(n) for n in (1, 2, 3, 7)] == [2, 4, 6, 14] assert [item.shutdown_for(n, DAY) for n in (1, 2, 3, 7)] == [1, 3, 4, 10] + odd = focus(monkeypatch, daily_minutes=90) + assert [odd.gaming_for(n) for n in (1, 2, 90)] == [2, 5, 240] def test_many_small_rows_pay_what_one_row_pays( @@ -86,7 +90,21 @@ def test_many_small_rows_pay_what_one_row_pays( one.write_text(ledger_file(tmp_path, [row(7, 7)]).read_text(encoding="utf-8")) units = credit_units(item, many, key_file, DAY) assert units == credit_units(item, one, key_file, DAY) == 7 - assert item.gaming_for(units or 0) == 10 + assert item.gaming_for(units or 0) == 14 + + +def test_carry_rows_pay_like_own_rows( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, key_file: Path +) -> None: + """focus_project 0.6.0 banks overtime as ``--carry`` rows.""" + item = focus(monkeypatch) + carry = row(30, 0, carry=True) + carry["entry_id"] = f"hr-{DAY:%Y%m%d}-carry" + top_up = row(5, 0, carry=True) + top_up["entry_id"] = f"hr-{DAY:%Y%m%d}-carry-m35" + rows = [signed(carry), signed(top_up), row(85, 85)] + ledger = ledger_file(tmp_path, rows) + assert credit_units(item, ledger, key_file, DAY) == 120 def test_resolve_prices_base_terms_and_ceiling(monkeypatch: pytest.MonkeyPatch) -> None: @@ -95,26 +113,25 @@ def test_resolve_prices_base_terms_and_ceiling(monkeypatch: pytest.MonkeyPatch) 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 == 120 + 90 + assert half.gaming_minutes == 120 + 120 assert half.shutdown_minutes == 18 * 60 + 90 full = resolve({"workout": 1, "focus": 120}, DAY, leisure=False) assert full.shutdown_minutes == 23 * 60 + assert full.gaming_minutes == 300 - 180 + 240 + 120 == 8 * 60 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.""" + """A full project day equals the old full day, LeetCode's free hour too.""" 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 + # kuhy 2026-11-02 (0.21.0): the day cuts 180 but pays 240, so a full day + # is the old full day (8 h); only an idle day is worse. + assert new.gaming_minutes == old.gaming_minutes == 8 * 60 def test_an_idle_project_day_also_cuts_the_free_leetcode_hour( @@ -166,4 +183,5 @@ 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) + assert (item.gaming_minutes, item.shutdown_minutes) == (120, 60) + assert item.cut_gaming_minutes == 60 diff --git a/earned_time/earned_time/tests/test_focus_edges.py b/earned_time/earned_time/tests/test_focus_edges.py index 4c05f38..6f89d89 100644 --- a/earned_time/earned_time/tests/test_focus_edges.py +++ b/earned_time/earned_time/tests/test_focus_edges.py @@ -70,8 +70,8 @@ def test_a_target_edit_never_reprices_past_days( 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.max_units, before.gaming_for(60)) == (120, 120) + assert (after.max_units, after.gaming_for(60)) == (60, 240) assert (before.shutdown_for(60, DAY), after.shutdown_for(30, DAY)) == (90, 90) diff --git a/earned_time/pyproject.toml b/earned_time/pyproject.toml index cf1ee7a..d1fdd5c 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.20.0" +version = "0.21.0" description = "Penalty-then-reward registry: what every gate app earns in gaming time and shutdown time" requires-python = ">=3.14" dependencies = []