sick day: shutdown = 20:00 + gaming pay of today's credits (earned_time 0.13.1); credits raise to it, no absorb
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-10-17 17:38:24 +02:00
parent 9e23ebae4f
commit f2ddf3a491
9 changed files with 101 additions and 64 deletions

View File

@ -136,7 +136,7 @@ repos:
# than merely an old one.
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.9.0#subdirectory=gatelock
- crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync
- earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.12.0#subdirectory=earned_time
- earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.13.1#subdirectory=earned_time
- freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays
- sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.3.0#subdirectory=sickdays
- mcp>=2,<3
@ -158,7 +158,7 @@ repos:
# than merely an old one.
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.9.0#subdirectory=gatelock
- crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync
- earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.12.0#subdirectory=earned_time
- earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.13.1#subdirectory=earned_time
- freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays
- sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.3.0#subdirectory=sickdays
- mcp>=2,<3

View File

@ -10,7 +10,7 @@ dependencies = [
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.9.0#subdirectory=gatelock",
"crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync",
"freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays",
"earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.12.0#subdirectory=earned_time",
"earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.13.1#subdirectory=earned_time",
"sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.3.0#subdirectory=sickdays",
]

View File

@ -4,7 +4,7 @@ bandit==1.9.4
codespell==2.4.3
coverage==7.16.2
crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync
earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.12.0#subdirectory=earned_time
earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.13.1#subdirectory=earned_time
freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays
gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.9.0#subdirectory=gatelock
mcp==2.3.0

View File

@ -16,6 +16,7 @@ from screen_locker._constants import (
from screen_locker._day import today_str
from screen_locker._earned import ceiling, first_minutes, hhmm, span
from screen_locker._grace_floor import GraceFloorMixin, plan_absorb, save_lift
from screen_locker._sick import is_sick
from screen_locker._sick_floor import SickFloorMixin
if TYPE_CHECKING:
@ -76,6 +77,8 @@ class ShutdownMixin(GraceFloorMixin, SickFloorMixin):
Returns True if successful, False otherwise.
"""
day = date.fromisoformat(today_str())
if is_sick(day):
return self._raise_on_sick_day()
bonus, lift = plan_absorb(first_minutes(earned_time.WORKOUT, day))
try:
config_values = self._read_shutdown_config()
@ -104,6 +107,8 @@ class ShutdownMixin(GraceFloorMixin, SickFloorMixin):
Returns True if successful, False otherwise.
"""
if is_sick(date.fromisoformat(today_str())):
return self._raise_on_sick_day()
# Paid out of the grace lift first, so the floor is never stacked on.
moved, lift = plan_absorb(extra_minutes)
try:
@ -126,6 +131,17 @@ class ShutdownMixin(GraceFloorMixin, SickFloorMixin):
save_lift(lift)
return ok
def _raise_on_sick_day(self) -> bool:
"""A sick day's credit: raise to the derived target, never add.
The credit's row is already in the target (``_sick_floor``), so adding
its minutes on top would pay it twice. True even when nothing moved
(capped, or already there): the credit is accounted for, and a failed
write is retried by the next tick's sick pass.
"""
self._apply_sick_floor()
return True
def _read_shutdown_config(self) -> tuple[int, int, int] | None:
"""Read shutdown config as (mon_wed, thu_sun, morning_end) minutes, or None."""
return read_shutdown_config(SHUTDOWN_CONFIG_FILE)

View File

@ -78,7 +78,7 @@ def base_minutes(day: date | None = None) -> int:
else earned_time.base_for(target, earners, first_credits=starts)
).shutdown_minutes
if is_sick(target):
return earned_time.sick_shutdown_minutes(base, target)
return max(base, earned_time.sick_shutdown_from(0, target))
return base

View File

@ -64,6 +64,8 @@ class Target:
Saved as the day's lift (``_grace_floor.save_lift``), so a credit that
lands later is paid out of it first instead of stacking on 20:00.
"""
if self.resolution.sick:
return 0 # earned_time 0.13.0: credits stack on 20:00
return self.minutes - self.earned

View File

@ -1,18 +1,18 @@
"""Sick-day shutdown floor, applied live: 20:00 the moment today turns sick.
"""Sick-day shutdown, applied live: 20:00 plus the day's gaming pay.
The daily reset already resolves a sick day with ``earned_time.resolve(...,
sick=True)``. But a day is usually marked sick *after* that reset ran --
at 10:00 from the lock screen or the phone -- and the reset is stamped once
per day. This pass closes the gap: on every 5-minute locker tick, every
``--sync-only`` / ``--apply-bonuses`` pass and right after the lock screen's
"I'm sick" tap, it lifts tonight's shutdown to
``earned_time.sick_shutdown_minutes`` (``max(20:00, current)``, capped at the
day's ceiling).
"I'm sick" tap, it raises tonight's shutdown to the derived sick target
(:func:`sick_target`: 20:00 plus every credit's gaming pay, earned_time 0.13.0).
The lift is a floor, not a bonus, exactly like the grace floor: it is added
to the same ``grace_floor.json`` lift, so a workout credited later is paid
out of it first (``_grace_floor.plan_absorb``) and lands on
``max(20:00, ladder)`` -- 20:40, not 20:00 + the workout's whole reward.
Credits stack on 20:00 (kuhy, 2026-10-17: LeetCode + reading is 22:00, not
the 20:10 that 0.11.0's non-stacking floor gave). The target is absolute,
re-derived from the ledgers, so on a sick day a live credit raises to it
instead of adding (``ShutdownMixin``): the new row is already in the sum.
No lift is recorded, so nothing is absorbed.
Raise-only. Un-marking today does not lower the config until the next
daily reset re-derives it.
@ -24,18 +24,25 @@ from datetime import date
import logging
from typing import TYPE_CHECKING
import earned_time
from screen_locker._bonus_lock import bonus_lock
from screen_locker._constants import GRACE_STATE_FILE
from screen_locker._day import today_str
from screen_locker._earned import hhmm
from screen_locker._grace_floor import load_lift, save_lift, tonight_minutes
from screen_locker._grace_floor import save_lift, tonight_minutes
from screen_locker._shutdown_target import gather
from screen_locker._sick import is_sick
if TYPE_CHECKING:
from pathlib import Path
_logger = logging.getLogger(__name__)
def sick_target(day: date, log_file: Path | None) -> int:
"""Tonight's sick-day shutdown, re-derived from the ledgers and the log."""
return gather(day, log_file).minutes
class SickFloorMixin:
"""Lifts tonight's shutdown to the sick floor; composed into ShutdownMixin."""
@ -48,7 +55,7 @@ class SickFloorMixin:
) -> bool: ...
def _apply_sick_floor(self) -> bool:
"""Raise tonight's shutdown to the sick floor if today is sick.
"""Raise tonight's shutdown to the sick target if today is sick.
Returns True when the config was raised on this call.
"""
@ -62,7 +69,7 @@ class SickFloorMixin:
_logger.warning("Sick floor: shutdown config unreadable; not applied")
return False
current = tonight_minutes(config, day)
floor = earned_time.sick_shutdown_minutes(current, day)
floor = sick_target(day, getattr(self, "log_file", None))
if floor <= current:
return False
mon_wed, thu_sun, morning = config
@ -71,8 +78,7 @@ class SickFloorMixin:
):
_logger.warning("Sick floor: failed to write shutdown config")
return False
lift = load_lift(GRACE_STATE_FILE, today) + floor - current
save_lift(lift, GRACE_STATE_FILE)
save_lift(0, GRACE_STATE_FILE) # stacked: nothing to absorb
_logger.info(
"Sick floor: shutdown %s -> %s (sick day).", hhmm(current), hhmm(floor)
)

View File

@ -59,16 +59,19 @@ class TestTarget:
target = derive(DayInputs(_DAY, _FLAT, first_done=at("18:55"), sick_day=True))
assert target.grace is None
assert target.resolution.sick is True
assert target.minutes == max(SICK_FLOOR, target.earned)
# The lift is the sick floor's, so later credits absorb it first.
assert target.lift == target.resolution.sick_lift_minutes
assert target.lift == SICK_FLOOR - target.earned
assert target.minutes == SICK_FLOOR + sum(
t.gaming_minutes for t in target.resolution.terms
)
# earned_time 0.13.0: credits stack on 20:00, so there is no lift.
assert target.lift == 0
def test_a_sick_day_above_20_00_keeps_the_ladder_value(self) -> None:
def test_a_sick_day_stacks_gaming_pay_on_20_00(self) -> None:
busy = dict.fromkeys(_FLAT, True)
target = derive(DayInputs(_DAY, busy, workout_credits=1, sick_day=True))
assert target.minutes == target.earned
assert target.minutes > SICK_FLOOR
assert target.minutes >= target.earned
assert target.minutes == min(
23 * 60, SICK_FLOOR + sum(t.gaming_minutes for t in target.resolution.terms)
)
assert target.lift == 0
def test_gather_keeps_the_sick_floor_when_the_workout_lock_is_kept(self) -> None:

View File

@ -1,8 +1,9 @@
"""The live sick floor: a day marked sick after its reset is lifted to 20:00.
"""The live sick day: tonight is raised to 20:00 plus the day's gaming pay.
The lift is recorded like the grace lift, so a workout credited afterwards is
paid out of it first and lands on max(20:00, ladder) -- not 20:00 plus the
workout's whole reward.
Since earned_time 0.13.0 credits stack on 20:00 at gaming pay (kuhy,
2026-10-17: LeetCode + reading on a sick day is 22:00, not 20:10). The pass
raises to the derived target (``_sick_floor.sick_target``) and records no
lift, so nothing is absorbed.
"""
from __future__ import annotations
@ -17,7 +18,6 @@ import pytest
import sickdays
from screen_locker import _grace_floor, _shutdown, _sick_floor
from screen_locker._earned import first_minutes
from screen_locker._grace_floor import plan_absorb
from screen_locker._shutdown import ShutdownMixin
from screen_locker.tests._sick_helpers import mark_sick
@ -60,6 +60,10 @@ def _on_the_day() -> Iterator[Any]:
yield
def _target(minutes: int) -> Any:
return patch.object(_sick_floor, "sick_target", return_value=minutes)
class TestApplySickFloor:
def test_not_sick_does_nothing(self) -> None:
host = Host((18 * 60, 18 * 60, 300))
@ -70,58 +74,64 @@ class TestApplySickFloor:
"""The 20:00 floor is the day's, not the workout lock's: keep is moot."""
mark_sick(_DAY, keep=[sickdays.LOCKER_WORKOUT])
host = Host((18 * 60, 18 * 60 + 30, 300))
assert host._apply_sick_floor() is True
with _target(_FLOOR):
assert host._apply_sick_floor() is True
assert host.config == (_FLOOR, _FLOOR, 300)
def test_marked_sick_lifts_to_20_00_and_records_the_lift(self) -> None:
def test_raises_to_the_derived_target_and_records_no_lift(self) -> None:
mark_sick(_DAY)
host = Host((18 * 60, 18 * 60 + 30, 300))
assert host._apply_sick_floor() is True
# Saturday reads the Thu-Sun band (18:30): lifted by 90 minutes.
assert host.config == (_FLOOR, _FLOOR, 300)
assert plan_absorb(0) == (0, 90)
host = Host((18 * 60, 20 * 60 + 10, 300))
with _target(22 * 60):
assert host._apply_sick_floor() is True
assert host.config == (22 * 60, 22 * 60, 300)
assert plan_absorb(60) == (60, 0)
def test_already_at_or_above_the_floor_is_left_alone(self) -> None:
def test_already_at_or_above_the_target_is_left_alone(self) -> None:
mark_sick(_DAY)
host = Host((21 * 60, 21 * 60, 300))
assert host._apply_sick_floor() is False
with _target(21 * 60):
assert host._apply_sick_floor() is False
assert host.config == (21 * 60, 21 * 60, 300)
def test_unreadable_config_is_logged(
self, caplog: pytest.LogCaptureFixture
) -> None:
mark_sick(_DAY)
with caplog.at_level(logging.WARNING):
with caplog.at_level(logging.WARNING), _target(_FLOOR):
assert Host(None)._apply_sick_floor() is False
assert "config unreadable" in caplog.text
def test_failed_write_records_no_lift(
self, caplog: pytest.LogCaptureFixture
) -> None:
def test_failed_write_is_logged(self, caplog: pytest.LogCaptureFixture) -> None:
mark_sick(_DAY)
with caplog.at_level(logging.WARNING):
with caplog.at_level(logging.WARNING), _target(_FLOOR):
assert Host((18 * 60, 18 * 60, 300), ok=False)._apply_sick_floor() is False
assert "failed to write" in caplog.text
assert plan_absorb(0) == (0, 0)
class TestNoStackingOnTop:
def test_a_workout_after_the_mark_lands_on_the_ladder_not_above_it(self) -> None:
"""Mark sick at 10:00 (18:50 -> 20:00), then a workout: the ladder value."""
start = 18 * 60 + 50
host = Host((start, start, 300))
def test_the_target_is_the_derived_sick_resolution(self) -> None:
mark_sick(_DAY)
host._apply_sick_floor()
host._adjust_shutdown_time_later()
ladder = start + first_minutes(earned_time.WORKOUT, _DAY)
assert host.config is not None
assert host.config[1] == max(_FLOOR, ladder)
with patch.object(_sick_floor, "gather") as gather:
gather.return_value.minutes = 22 * 60
assert _sick_floor.sick_target(_DAY, None) == 22 * 60
gather.assert_called_once_with(_DAY, None)
def test_a_small_credit_is_absorbed_entirely(self) -> None:
host = Host((18 * 60, 18 * 60, 300))
class TestStackingAtGamingPay:
def test_a_workout_on_a_sick_day_lands_on_the_target(self) -> None:
host = Host((_FLOOR, _FLOOR, 300))
mark_sick(_DAY)
host._apply_sick_floor()
with _target(22 * 60):
assert host._adjust_shutdown_time_later() is True
assert host.config == (22 * 60, 22 * 60, 300)
def test_a_flat_credit_is_not_added_on_top_of_the_target(self) -> None:
"""The ledger row is already in the derived sum: adding would pay twice."""
host = Host((21 * 60, 21 * 60, 300))
mark_sick(_DAY)
with _target(22 * 60):
assert host._adjust_shutdown_time_by(60) is True
assert host.config == (22 * 60, 22 * 60, 300)
def test_not_sick_still_adds(self) -> None:
host = Host((19 * 60, 19 * 60, 300))
assert host._adjust_shutdown_time_by(60) is True
assert host.config is not None
assert host.config[1] == _FLOOR
assert plan_absorb(0) == (0, 60)
assert host.config == (20 * 60, 20 * 60, 300)