earned_time 0.12.0: shutdown rungs equal gaming pay from 2026-10-18 (workout 120, LeetCode 60, reading 60, tutor 60; floor 18:00)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-10-17 16:47:02 +02:00
parent d3f01c36e1
commit ef244889fd
5 changed files with 98 additions and 13 deletions

View File

@ -29,16 +29,8 @@ never a same-day cut):
earner name; all of a counted earner's capped units), so doing everything
lands exactly on the ceiling. Since 0.6.1 `TUTOR_FROM == LADDER_FROM`, so
every real ladder day uses `TUTOR_LADDER` (below); `LADDER`, the pre-tutor
split, is never in force on a real day:
| done | shutdown |
|---|---|
| nothing | 19:00 |
| workout (110) | 20:50 |
| + LeetCode (50) | 21:40 |
| + reading (30) | 22:10 |
| + Anki (25) | 22:35 |
| + Automation (25) | 23:00 |
split (workout 110, LeetCode 50, reading 30, Anki 25, Automation 25; floor
19:00), is never in force on a real day.
A second workout earns nothing more (`Rung.extra` defaults to 0).
Move the alarm (`WAKE_MINUTES = 6 * 60`) and the whole ladder shifts an hour
@ -48,6 +40,11 @@ never a same-day cut):
The two floors are derived separately (`_resolve._shutdown_floor`), so the
ladder cannot move a gaming minute.
**Unified ladder (0.12.0, `UNIFIED_FROM` 2026-10-18):** each rung pays what
its earner pays in gaming (workout 120, LeetCode 60, reading 60, tutor 1/unit
to 60), so the floor drops 18:50 -> 18:00 under the same 23:00 ceiling:
nothing 3 h / 18:00, workout 5 h / 20:00, +LeetCode 21:00, +reading 22:00.
**Sick days (0.11.0, `_sick.py`).** `resolve(..., sick=True)` floors the
*final* shutdown at 20:00 (`SICK_SHUTDOWN_FLOOR_MINUTES`), capped at the
ceiling: 18:50 becomes 20:00, 21:30 stays 21:30 (nothing stacks on the floor).
@ -243,7 +240,7 @@ on the day itself is logged and does not count.
## Install
```console
pip install --user --break-system-packages --no-deps "earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.11.0#subdirectory=earned_time"
pip install --user --break-system-packages --no-deps "earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.12.0#subdirectory=earned_time"
```
The root `steam-backlog-enforcer.service` sets `HOME=/home/kuhy`, so it imports

View File

@ -46,6 +46,8 @@ from earned_time._ladder import (
SHUTDOWN_CEILING_MINUTES,
TUTOR_FROM,
TUTOR_LADDER,
UNIFIED_FROM,
UNIFIED_LADDER,
WAKE_MINUTES,
Rung,
extra_shutdown_minutes_for,
@ -106,6 +108,8 @@ __all__ = [
"TUTOR_LADDER",
"TUTOR_QUOTA_FROM",
"TUTOR_QUOTA_MINUTES",
"UNIFIED_FROM",
"UNIFIED_LADDER",
"WAKE_MINUTES",
"WORKOUT",
"Base",

View File

@ -9,7 +9,9 @@ frozen ceiling, so the switch never rewrites how an old day resolved. Gaming
is not on the ladder.
From ``TUTOR_FROM`` the rungs are :data:`TUTOR_LADDER`: Anki is retired and
its 25 minutes fold into the tutor's, paid per active minute.
its 25 minutes fold into the tutor's, paid per active minute. From
``UNIFIED_FROM`` they are :data:`UNIFIED_LADDER`: each rung equals its
earner's gaming pay.
"""
from __future__ import annotations
@ -47,6 +49,10 @@ TUTOR_FROM: Final = date(2026, 10, 10)
# floor rises by exactly what the two could have paid back), so it may land
# on its deploy day: 0.5.0 landed 2026-10-09.
ANKI_WAIVED_FROM: Final = date(2026, 10, 9)
# From this day every rung pays exactly the gaming time its earner pays
# (:data:`UNIFIED_LADDER`, 0.12.0). It lowers the floor 18:50 -> 18:00, so it
# lands the day after the deploy (2026-10-17), never on it.
UNIFIED_FROM: Final = date(2026, 10, 18)
_SLEEP_MINUTES: Final = 8 * 60
_DAY_MINUTES: Final = 24 * 60
@ -109,6 +115,19 @@ TUTOR_LADDER: Final[Mapping[str, Rung]] = MappingProxyType(
)
# From ``UNIFIED_FROM``: shutdown and gaming pay the same -- workout 120,
# LeetCode 60, reading 60, tutor 1 per unit up to 60. The rungs sum to 300:
# the ceiling stays 23:00 and the floor drops to 18:00.
UNIFIED_LADDER: Final[Mapping[str, Rung]] = MappingProxyType(
{
"workout": Rung(120),
"leetcode": Rung(60),
"reading": Rung(60),
"tutor": Rung(1, extra=1),
}
)
def on_ladder(day: date) -> bool:
"""Whether ``day`` resolves shutdown on the sleep ladder."""
return day >= LADDER_FROM
@ -118,6 +137,8 @@ def ladder_for(day: date) -> Mapping[str, Rung] | None:
"""The rungs in force on ``day``; ``None`` before the ladder."""
if not on_ladder(day):
return None
if day >= UNIFIED_FROM:
return UNIFIED_LADDER
return TUTOR_LADDER if day >= TUTOR_FROM else LADDER

View File

@ -0,0 +1,63 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""The unified ladder (0.12.0): every shutdown rung equals its gaming pay."""
from __future__ import annotations
from datetime import date, timedelta
from earned_time import (
TUTOR_EARNERS,
TUTOR_LADDER,
UNIFIED_FROM,
UNIFIED_LADDER,
ladder_for,
resolve,
shutdown_ceiling_for,
)
EVE = UNIFIED_FROM - timedelta(days=1)
def _hhmm(minutes: int) -> str:
return f"{minutes // 60:02d}:{minutes % 60:02d}"
def _day(day: date, **done: int) -> tuple[int, str]:
answers = {e.name: done.get(e.name, 0) for e in TUTOR_EARNERS}
out = resolve(answers, day, TUTOR_EARNERS)
return out.gaming_minutes, _hhmm(out.shutdown_minutes)
def test_switch_is_the_day_after_the_deploy() -> None:
assert date(2026, 10, 18) == UNIFIED_FROM
assert ladder_for(EVE) is TUTOR_LADDER # deploy day keeps the old rungs
assert ladder_for(UNIFIED_FROM) is UNIFIED_LADDER
def test_each_rung_pays_what_gaming_pays() -> None:
for e in TUTOR_EARNERS:
units = e.max_units or 1
assert UNIFIED_LADDER[e.name].minutes(units) == e.gaming_for(units), e.name
assert {n: r.minutes(60) for n, r in UNIFIED_LADDER.items()} == {
"workout": 120,
"leetcode": 60,
"reading": 60,
"tutor": 60,
}
def test_ceiling_stays_and_floor_drops_to_18() -> None:
assert shutdown_ceiling_for(UNIFIED_FROM) == 23 * 60
assert _day(UNIFIED_FROM) == (180, "18:00")
assert _day(UNIFIED_FROM, workout=1) == (300, "20:00")
assert _day(UNIFIED_FROM, workout=2) == (300, "20:00") # 2nd unit pays 0
assert _day(UNIFIED_FROM, workout=1, leetcode=1) == (360, "21:00")
assert _day(UNIFIED_FROM, workout=1, leetcode=1, reading=1) == (420, "22:00")
full = _day(UNIFIED_FROM, workout=1, leetcode=1, reading=1, tutor=60)
assert full == (480, "23:00")
assert _day(UNIFIED_FROM, tutor=30) == (210, "18:30") # per unit, 1:1
def test_old_days_keep_the_tutor_ladder() -> None:
assert _day(EVE) == (180, "18:50")
assert _day(EVE, workout=1) == (300, "20:40")

View File

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