mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 18:07:24 +01:00
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
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:
parent
b50e245ceb
commit
ba4dd58545
@ -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),
|
||||||
|
|||||||
@ -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
|
||||||
),
|
),
|
||||||
|
|||||||
@ -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),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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 = []
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user