earned_time 0.21.0: focus day pays 240 gaming
Some checks failed
dependency freshness / dependencies on newest stable (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
earned_time pre-commit / pre-commit (push) Has been cancelled
earned_time tests / test (push) Has been cancelled
leetcode-coach pre-commit / pre-commit (push) Has been cancelled
leetcode-coach tests / test (push) Has been cancelled

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, <project>-<YYYYMMDD>-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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYGW5NZL5uVpnqmNKo
This commit is contained in:
Krzysztof Rudnicki 2026-11-02 15:22:10 +01:00
parent b50e245ceb
commit ba4dd58545
6 changed files with 65 additions and 25 deletions

View File

@ -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 `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). 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 While `focus_project.active(day)` returns a project, `earners_for` drops the
gates it `replaces` (`book` = `reading`) and adds one counted `focus` earner 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, (`max_units` = the day's `daily_on`). Shutdown pays their rungs (180/120 min); gaming
gaming by `max_gaming_minutes`: 60+60+60 over 120 min = 1.5/min): 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 `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`: 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` `<project>-<YYYYMMDD>-m<n>`, `detail.minutes` (positive int), signed `credit` rows, `entry_id` `<project>-<YYYYMMDD>-m<n>`, `detail.minutes` (positive int),

View File

@ -31,9 +31,13 @@ _warned: set[str] = set()
class FocusEarner(Earner): class FocusEarner(Earner):
"""Counted; ``gaming_minutes``/``shutdown_minutes`` pay a FULL day. """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: def gaming_for(self, units: int) -> int:
"""Gaming minutes of ``units`` focused minutes, floored.""" """Gaming minutes of ``units`` focused minutes, floored."""
return self.capped(units) * self.gaming_minutes // (self.max_units or 1) 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 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( return FocusEarner(
name="focus", name="focus",
label="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_minutes=sum(
shutdown_units_minutes(e, e.max_units or 1, day) for e in dropped shutdown_units_minutes(e, e.max_units or 1, day) for e in dropped
), ),

View File

@ -25,6 +25,7 @@ from datetime import UTC, date, datetime
import logging import logging
from typing import TYPE_CHECKING, Final 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._ladder import on_ladder, shutdown_ceiling_for
from earned_time._leisure import base_minutes, ceiling_minutes, is_leisure from earned_time._leisure import base_minutes, ceiling_minutes, is_leisure
from earned_time._maturity import confirmed_start, penalty_start from earned_time._maturity import confirmed_start, penalty_start
@ -45,6 +46,16 @@ def _today() -> date:
return datetime.now(tz=UTC).astimezone().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( def _penalised(
item: Earner, day: date, first_credits: Mapping[str, date | None] | None item: Earner, day: date, first_credits: Mapping[str, date | None] | None
) -> bool: ) -> bool:
@ -117,7 +128,7 @@ def _base(
penalised = [e for e in registry if _penalised(e, target, first_credits)] penalised = [e for e in registry if _penalised(e, target, first_credits)]
return Base( return Base(
gaming_minutes=base_minutes(leisure=on) 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), shutdown_minutes=_shutdown_floor(target, registry, penalised),
) )

View File

@ -22,7 +22,7 @@ from earned_time import (
) )
from earned_time._focus import focus_earner from earned_time._focus import focus_earner
from earned_time.tests._focus_fakes import DAY, START, activate, focus, project, row 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: if TYPE_CHECKING:
from pathlib import Path 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 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, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
"""kuhy 2026-11-02 (0.21.0): 2 gaming minutes per focus minute, 240 a day."""
item = focus(monkeypatch) item = focus(monkeypatch)
assert item.max_units == 120 assert item.max_units == 120
assert (item.gaming_for(120), item.shutdown_for(120, DAY)) == (180, 180) assert (item.gaming_for(120), item.shutdown_for(120, DAY)) == (240, 180)
assert (item.gaming_for(999), item.shutdown_for(999, DAY)) == (180, 180) assert (item.gaming_for(999), item.shutdown_for(999, DAY)) == (240, 180)
assert (item.max_gaming_minutes, item.gaming_for(-5)) == (180, 0) 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: def test_pay_is_floored_on_the_summed_minutes(monkeypatch: pytest.MonkeyPatch) -> None:
item = focus(monkeypatch) 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] 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( 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")) one.write_text(ledger_file(tmp_path, [row(7, 7)]).read_text(encoding="utf-8"))
units = credit_units(item, many, key_file, DAY) units = credit_units(item, many, key_file, DAY)
assert units == credit_units(item, one, key_file, DAY) == 7 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 ``<project>-<YYYYMMDD>-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: 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.gaming_minutes == 300 - 180
assert nothing.base.shutdown_minutes == 18 * 60 assert nothing.base.shutdown_minutes == 18 * 60
half = resolve({"workout": 0, "focus": 60}, DAY, leisure=False) 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 assert half.shutdown_minutes == 18 * 60 + 90
full = resolve({"workout": 1, "focus": 120}, DAY, leisure=False) full = resolve({"workout": 1, "focus": 120}, DAY, leisure=False)
assert full.shutdown_minutes == 23 * 60 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( def test_a_full_project_day_pays_what_the_replaced_gates_paid(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> 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()) activate(monkeypatch, project())
done = {"workout": 1, "leetcode": 1, "reading": 1, "tutor": 60, "focus": 120} done = {"workout": 1, "leetcode": 1, "reading": 1, "tutor": 60, "focus": 120}
old = resolve(done, DAY, TUTOR_EARNERS, leisure=False) old = resolve(done, DAY, TUTOR_EARNERS, leisure=False)
new = resolve(done, DAY, leisure=False) new = resolve(done, DAY, leisure=False)
assert new.shutdown_minutes == old.shutdown_minutes assert new.shutdown_minutes == old.shutdown_minutes
# kuhy 2026-11-02: LeetCode's bonus hour was free on top of the base; the # kuhy 2026-11-02 (0.21.0): the day cuts 180 but pays 240, so a full day
# project day cuts it and pays it back, so the full day nets 60 less. # is the old full day (8 h); only an idle day is worse.
assert new.gaming_minutes == old.gaming_minutes - 60 assert new.gaming_minutes == old.gaming_minutes == 8 * 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( 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: def test_focus_pays_only_replaced_gates_that_exist() -> None:
item = focus_earner(project(), (earned_time.LEETCODE,), DAY) 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

View File

@ -70,8 +70,8 @@ def test_a_target_edit_never_reprices_past_days(
activate(monkeypatch, project(daily_on=edited, daily_minutes=60)) activate(monkeypatch, project(daily_on=edited, daily_minutes=60))
before = earners_for(DAY)[-1] before = earners_for(DAY)[-1]
after = earners_for(DAY + timedelta(days=1))[-1] after = earners_for(DAY + timedelta(days=1))[-1]
assert (before.max_units, before.gaming_for(60)) == (120, 90) assert (before.max_units, before.gaming_for(60)) == (120, 120)
assert (after.max_units, after.gaming_for(60)) == (60, 180) assert (after.max_units, after.gaming_for(60)) == (60, 240)
assert (before.shutdown_for(60, DAY), after.shutdown_for(30, DAY)) == (90, 90) assert (before.shutdown_for(60, DAY), after.shutdown_for(30, DAY)) == (90, 90)

View File

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