diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2a60032..3624c06 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 5934ff8..707d192 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", ] diff --git a/requirements.txt b/requirements.txt index 8c6149a..10cf91d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/screen_locker/_shutdown.py b/screen_locker/_shutdown.py index 5b5617d..f60fbbb 100644 --- a/screen_locker/_shutdown.py +++ b/screen_locker/_shutdown.py @@ -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) diff --git a/screen_locker/_shutdown_base.py b/screen_locker/_shutdown_base.py index 746044a..cab3acf 100644 --- a/screen_locker/_shutdown_base.py +++ b/screen_locker/_shutdown_base.py @@ -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 diff --git a/screen_locker/_shutdown_target.py b/screen_locker/_shutdown_target.py index 9f3c395..2416c1c 100644 --- a/screen_locker/_shutdown_target.py +++ b/screen_locker/_shutdown_target.py @@ -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 diff --git a/screen_locker/_sick_floor.py b/screen_locker/_sick_floor.py index 4d7171b..17d32a8 100644 --- a/screen_locker/_sick_floor.py +++ b/screen_locker/_sick_floor.py @@ -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) ) diff --git a/screen_locker/tests/test_shutdown_target.py b/screen_locker/tests/test_shutdown_target.py index 28c44c2..caa92c6 100644 --- a/screen_locker/tests/test_shutdown_target.py +++ b/screen_locker/tests/test_shutdown_target.py @@ -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: diff --git a/screen_locker/tests/test_sick_floor.py b/screen_locker/tests/test_sick_floor.py index 4dd1130..a3d2d71 100644 --- a/screen_locker/tests/test_sick_floor.py +++ b/screen_locker/tests/test_sick_floor.py @@ -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)