diff --git a/AGENTS.md b/AGENTS.md index d9033e3..14cfded 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,12 +59,25 @@ made the code's default dead. A sick day is a date in the shared `sickdays` pool, read only through `_sick.py`. It may keep lockers: the workout lock asks `_sick.stands_down_workout` (keep-aware), while everything that only computes -shutdown (this floor, `_grace_floor`, `_shutdown_base`, `_shutdown_target`, -the preview scripts) asks `is_sick` and ignores keep. The reset resolves it with `earned_time.resolve(..., sick=True)` -(shutdown >= 20:00) and saves the lift above the earned sum like the grace -lift; a day marked after the reset is lifted by `_sick_floor` on the next -live pass. Both are floors, never terms: later credits are paid out of the -lift first (`_grace_floor.plan_absorb`), so nothing stacks on 20:00. +shutdown (`_sick_floor`, `_shutdown_base`, `_shutdown_target`, the preview +scripts) asks `is_sick` and ignores keep. A sick day's shutdown is absolute: +20:00 plus the GAMING pay of everything credited that day, capped at 23:00 +(`earned_time.sick_shutdown_from`, 0.13). The reset writes it, `_sick_floor` +re-derives it on every live pass from the ledgers, and a sick-day credit only +raises to it (`_raise_on_sick_day`), never adds -- its row is already in the +sum. No lift is saved on a sick day. + +## A shutdown never blocks submitting a finished task + +A credit timed at or after tonight's shutdown moves tonight to +`min(23:00, the credit's OWN time + its reward)` (`_late_credit.late_tonight`): +only the 23:00 ceiling is hard. The anchor is the credit's own time (the run's +end via `_workout_ledger.entry_done_at`, the ledger row via +`_earned.first_credit_time`), never the sync time, so a late upload buys +nothing. It is live-only: the next day's reset re-derives from scratch. This +replaced the first-credit +60 grace floor (gone 2026-10-17); `_grace_floor` +keeps only `first_done_at`, `tonight_minutes` and the lift file, which +`plan_absorb` spends once if a stale one is left. Flat earners are read from their gate's ledger by `earned_time.done_today` via `_earned.py`: HMAC-verified `credit` rows matched by the earner's own diff --git a/README.md b/README.md index d24f3f9..01b0d4d 100644 --- a/README.md +++ b/README.md @@ -10,13 +10,15 @@ A sick day is a date in the fleet-wide [`sickdays`](https://github.com/kuhyx/uti pool, nothing else: no budget, countdown, justification or debt. The lock screen's **I'm sick** button (retry screen, no-phone screen and the Tue-Thu prompt) marks today with one tap and unlocks; `sickdays add` or the phone do -the same. On a sick day the workout lock and the grace floor stand down, -wake-alarm's workday penalty is void, and shutdown is -`max(20:00, the normal ladder)` capped at 23:00 (`earned_time.resolve(..., -sick=True)`). A day marked after its daily reset is lifted to 20:00 by the -next 5-minute pass (`_sick_floor`); a workout that day still earns normally -and is paid out of that lift first, so it lands on the ladder value (e.g. -20:40), never 20:00 plus its whole reward. `install.sh` migrates the old +the same. On a sick day the workout lock stands down, +wake-alarm's workday penalty is void, and shutdown is 20:00 plus the gaming +pay of everything credited that day, capped at 23:00 +(`earned_time.sick_shutdown_from`). A day marked after its daily reset is +raised to it by the next live pass (`_sick_floor`), and every later credit +that day raises it again. + +A credit landing at or after tonight's shutdown is never lost: tonight +becomes `min(23:00, the credit's own time + its reward)` (`_late_credit`). `install.sh` migrates the old `sick_history.json` dates into the pool once (`scripts/migrate_sick_history.py`). A sick day may *keep* lockers (`sickdays add --keep workout`). A kept workout diff --git a/screen_locker/_constants.py b/screen_locker/_constants.py index 4489012..4a8df8c 100644 --- a/screen_locker/_constants.py +++ b/screen_locker/_constants.py @@ -105,7 +105,6 @@ SHUTDOWN_BASE_FILE = Path(__file__).resolve().parent / "shutdown_base.json" REST_DAY_FILE = Path(__file__).resolve().parent / "rest_days.json" REST_DAYS_PER_ISO_WEEK = 2 GRACE_STATE_FILE = Path(__file__).resolve().parent / "grace_floor.json" -GRACE_MINUTES = 60 # A day's RunnerUp walks (commute legs) count as the workout once their # MOVING time sums to this; moving = trackpoint pairs at walking speed. WALK_MIN_MOVING_MINUTES = 40 diff --git a/screen_locker/_earned.py b/screen_locker/_earned.py index 3c0b663..5b5923d 100644 --- a/screen_locker/_earned.py +++ b/screen_locker/_earned.py @@ -173,7 +173,7 @@ def first_credit_time(earner: earned_time.Earner, day: date) -> float | None: ``None`` when there is none, when it cannot be checked, and on an ``earned_time`` without ``first_credit_at`` (0.3.0) -- logged, because - then the grace floor sees no ledger earner at all. + then a late ledger credit counts from now, not from its row. """ fn = getattr(earned_time, "first_credit_at", None) if fn is None: @@ -187,8 +187,8 @@ def first_credit_time(earner: earned_time.Earner, day: date) -> float | None: def _warn_no_first_credit() -> None: """Once per process: the installed earned_time predates first_credit_at.""" _logger.warning( - "earned_time has no first_credit_at (needs >= 0.4); the grace floor " - "sees no ledger earner, only the workout log" + "earned_time has no first_credit_at (needs >= 0.4); a late ledger " + "credit counts from now, not from its own time" ) diff --git a/screen_locker/_grace_floor.py b/screen_locker/_grace_floor.py index e8dd65f..9a77201 100644 --- a/screen_locker/_grace_floor.py +++ b/screen_locker/_grace_floor.py @@ -1,22 +1,15 @@ -"""Grace floor: shutdown is never earlier than the day's first task + 60 min. +"""When the day's first task was done, and the day's floor lift. -Someone who gets home at 18:10 and finishes their first task at 18:55 is not -cut off at 19:00. The floor is ``first done + GRACE_MINUTES``, capped at the -day's ceiling. "First done" is the earliest of every gate earner's first -verified credit, flat or counted (``earned_time.first_credit_at``), and the -workout's own completion time -- the end of the run or walk, never the -moment a sync happened to log it, so a late upload does not move the floor -later. +The grace floor (first task + 60 min) is gone since 2026-10-17: a credit at +or after tonight's shutdown now counts from its own time +(:mod:`screen_locker._late_credit`), which is what the floor approximated, +and the floor's lift then absorbed minutes the late credit had already +restarted from (LeetCode late at 19:00, reading at 19:30: 20:10, not 20:20). -**A floor, not a bonus.** The config is otherwise read-add-write: every -earner adds its minutes to whatever is there. Lifting it to the floor and -then letting the next earner add on top would pay the lift twice (anki at -21:00 lifts to 22:00; reading then makes 22:30 where max(earned, floor) is -22:00). So the lift is remembered for the day in ``grace_floor.json`` and -later additions are paid out of it first: with ``E`` earned, floor ``G`` and -lift ``L = max(0, G - E)``, adding ``m`` moves the config by -``max(0, m - L)`` and leaves ``L' = max(0, L - m)`` -- which keeps the config -at ``max(E + m, G)`` without ever needing to know ``E``. +What stays: :func:`first_done_at` (shown by the preview and the reset log) +and the lift file. The lift is ``0`` on every day now (no floor lifts the +config above the earned sum), but ``plan_absorb`` still spends a stale one +recorded before the change, once. """ from __future__ import annotations @@ -28,13 +21,10 @@ from typing import TYPE_CHECKING import earned_time -from screen_locker._bonus_lock import bonus_lock -from screen_locker._constants import GRACE_MINUTES, GRACE_STATE_FILE +from screen_locker._constants import GRACE_STATE_FILE from screen_locker._day import today_str -from screen_locker._earned import ceiling, first_credit_time, gate_earners, hhmm +from screen_locker._earned import first_credit_time, gate_earners from screen_locker._log_io import load_workout_log -from screen_locker._rest_day import is_rest_day -from screen_locker._sick import is_sick from screen_locker._weekly_check import credit_key, day_workout_index from screen_locker._workout_ledger import entry_done_at @@ -96,17 +86,6 @@ def first_done_at( return min(on_day, default=None) -def grace_for(first_done: float | None, day: date) -> int | None: - """The floor for ``day`` in minutes after midnight, or ``None`` if none.""" - if first_done is None: - return None - moment = datetime.fromtimestamp(first_done).astimezone() - if moment.date() != day: - return None - done = moment.hour * 60 + moment.minute - return min(ceiling(day), done + GRACE_MINUTES) - - def load_lift(state_file: Path, today: str) -> int: """Today's recorded floor lift (grace or sick), 0 when none or unreadable.""" if not state_file.exists(): @@ -146,57 +125,3 @@ def tonight_minutes(config: tuple[int, int, int], day: date) -> int: """``day``'s shutdown from a (mon_wed, thu_sun, morning_end) config.""" mon_wed, thu_sun, _ = config return mon_wed if day.weekday() in _MON_WED else thu_sun - - -class GraceFloorMixin: - """Lifts tonight's shutdown to the grace floor; composed into ShutdownMixin.""" - - if TYPE_CHECKING: - - def _read_shutdown_config(self) -> tuple[int, int, int] | None: ... - - def _write_shutdown_config( - self, mon_wed: int, thu_sun: int, morning_end: int, *, restore: bool = ... - ) -> bool: ... - - def _apply_grace_floor( - self, log_file: Path | None, *, first_done: float | None = None - ) -> bool: - """Lift tonight's shutdown to the grace floor if it sits below it. - - ``first_done`` overrides the ledgers' answer (the preview script's - simulations). Returns True when the config was raised on this call. - """ - day = date.fromisoformat(today_str()) - if is_sick(day): - # The sick floor (20:00, _sick_floor) stands in for it that day. - _logger.info("Grace floor: skipped, today is a sick day") - return False - if first_done is None: - first_done = first_done_at(day, log_file, rest_day=is_rest_day(day)) - floor = grace_for(first_done, day) - if floor is None: - return False - with bonus_lock(GRACE_STATE_FILE): - config = self._read_shutdown_config() - if config is None: - _logger.warning("Grace floor: shutdown config unreadable; not applied") - return False - current = tonight_minutes(config, day) - if current >= floor: - return False - mon_wed, thu_sun, morning = config - if not self._write_shutdown_config( - max(mon_wed, floor), max(thu_sun, floor), morning, restore=True - ): - _logger.warning("Grace floor: failed to write shutdown config") - return False - lift = load_lift(GRACE_STATE_FILE, today_str()) + floor - current - save_lift(lift, GRACE_STATE_FILE) - _logger.info( - "Grace floor: shutdown %s -> %s (first task done + %d min).", - hhmm(current), - hhmm(floor), - GRACE_MINUTES, - ) - return True diff --git a/screen_locker/_late_credit.py b/screen_locker/_late_credit.py new file mode 100644 index 0000000..3a882c9 --- /dev/null +++ b/screen_locker/_late_credit.py @@ -0,0 +1,103 @@ +"""A credit at or after tonight's shutdown counts from its own time. + +Shutting down must never make a finished task impossible to submit (kuhy, +2026-10-17). The config is read-add-write, so a credit landing after the +shutdown used to add its minutes to a time already past: LeetCode at 19:00 +after an 18:00 shutdown made 19:00, and the machine stayed locked. Now such +a credit moves tonight to ``its own time + its reward``, capped at the +ceiling -- only the ceiling (23:00) is hard. + +The anchor is the credit's own time (a run's end, a ledger row's), never the +moment a sync applied it, so a late upload does not buy extra time. Past the +shutdown the grace and sick lifts are spent: the caller clears the lift. +""" + +from __future__ import annotations + +from datetime import date, datetime +import logging +import time +from typing import TYPE_CHECKING + +from screen_locker._day import today_str +from screen_locker._earned import ceiling, hhmm +from screen_locker._grace_floor import save_lift, tonight_minutes + +_logger = logging.getLogger(__name__) + + +def _now() -> float: + """The clock a credit without its own time is stamped with.""" + return time.time() + + +def late_tonight( + config: tuple[int, int, int], day: date, at: float, minutes: int +) -> int | None: + """Tonight's new shutdown for a late credit, or ``None`` if it is not late. + + Late means timed on ``day`` at or after tonight's shutdown: the check + script locks at ``now >= shutdown``, so a credit exactly on it is late. + """ + moment = datetime.fromtimestamp(at).astimezone() + if moment.date() != day: + return None + done = moment.hour * 60 + moment.minute + if done < tonight_minutes(config, day): + return None + return min(ceiling(day), done + minutes) + + +class LateCreditMixin: + """Late credits and sick-day credits; composed into ShutdownMixin.""" + + if TYPE_CHECKING: + + def _read_shutdown_config(self) -> tuple[int, int, int] | None: ... + + def _write_shutdown_config( + self, mon_wed: int, thu_sun: int, morning_end: int, *, restore: bool = ... + ) -> bool: ... + + def _apply_sick_floor(self) -> bool: ... + + def _raise_on_sick_day(self, minutes: int, at: float | None) -> 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; a late one still counts from + its own time. 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() + late = self._late_write(minutes, at) + return True if late is None else late + + def _late_write(self, minutes: int, at: float | None) -> bool | None: + """Write a late credit's shutdown; ``None`` when it is not late.""" + day = date.fromisoformat(today_str()) + try: + config = self._read_shutdown_config() + except (OSError, ValueError) as exc: + # The add path reads it again inside its own guard and fails there. + _logger.warning("Late credit: cannot read shutdown config (%s)", exc) + return None + if config is None: + return None # the add path reads it again and reports it + target = late_tonight(config, day, _now() if at is None else at, minutes) + if target is None: + return None + mon_wed, thu_sun, morning = config + if not self._write_shutdown_config( + max(mon_wed, target), max(thu_sun, target), morning, restore=True + ): + _logger.warning("Late credit: failed to write shutdown config") + return False + save_lift(0) # past the shutdown, the floors' lift is spent + _logger.info( + "Late credit: shutdown %s -> %s (earned at or after tonight's).", + hhmm(tonight_minutes(config, day)), + hhmm(target), + ) + return True diff --git a/screen_locker/_shutdown.py b/screen_locker/_shutdown.py index f60fbbb..5623189 100644 --- a/screen_locker/_shutdown.py +++ b/screen_locker/_shutdown.py @@ -15,7 +15,8 @@ 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._grace_floor import plan_absorb, save_lift +from screen_locker._late_credit import LateCreditMixin from screen_locker._sick import is_sick from screen_locker._sick_floor import SickFloorMixin @@ -68,18 +69,23 @@ def read_shutdown_config(path: Path) -> tuple[int, int, int] | None: return mon_wed, thu_sun, morning_end -class ShutdownMixin(GraceFloorMixin, SickFloorMixin): +class ShutdownMixin(SickFloorMixin, LateCreditMixin): """Mixin providing shutdown schedule adjustment functionality.""" - def _adjust_shutdown_time_later(self) -> bool: + def _adjust_shutdown_time_later(self, at: float | None = None) -> bool: """Push shutdown later by the first workout's reward, capped at the ceiling. - Returns True if successful, False otherwise. + ``at`` is when the workout ended (default now): a late one counts from + it (:mod:`._late_credit`). Returns True if successful, False otherwise. """ day = date.fromisoformat(today_str()) + reward = first_minutes(earned_time.WORKOUT, day) if is_sick(day): - return self._raise_on_sick_day() - bonus, lift = plan_absorb(first_minutes(earned_time.WORKOUT, day)) + return self._raise_on_sick_day(reward, at) + late = self._late_write(reward, at) + if late is not None: + return late + bonus, lift = plan_absorb(reward) try: config_values = self._read_shutdown_config() if config_values is None: @@ -98,18 +104,24 @@ class ShutdownMixin(GraceFloorMixin, SickFloorMixin): save_lift(lift) return ok - def _adjust_shutdown_time_by(self, extra_minutes: int) -> bool: + def _adjust_shutdown_time_by( + self, extra_minutes: int, at: float | None = None + ) -> bool: """Push shutdown later by *extra_minutes*, capped at 24:00 (midnight). Used for earner and extra-workout bonuses. A cap of midnight works because ``day-specific-shutdown-check.sh`` fires at 00:00 and catches it via the morning-window condition; the helper clamps it to 23:00 anyway. - Returns True if successful, False otherwise. + ``at`` is when the credit was earned (default now): a late one counts + from it (:mod:`._late_credit`). Returns True if successful. """ 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. + return self._raise_on_sick_day(extra_minutes, at) + late = self._late_write(extra_minutes, at) + if late is not None: + return late + # A stale lift recorded before the grace floor went is spent first. moved, lift = plan_absorb(extra_minutes) try: config_values = self._read_shutdown_config() @@ -131,17 +143,6 @@ 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 cab3acf..ae57089 100644 --- a/screen_locker/_shutdown_base.py +++ b/screen_locker/_shutdown_base.py @@ -18,10 +18,9 @@ followed -- on 2026-09-13 that left the bar at 21:00 with a counted workout on disk. Any bonus source that is not a term of this derivation has that bug; registering it in ``earned_time`` makes it one. -A sick day (``sickdays``) is resolved with ``sick=True``: shutdown is at least -20:00, and the lift above the earned sum is saved like the grace lift. A day -marked sick after the reset is lifted by the live pass -(:mod:`screen_locker._sick_floor`). +A sick day (``sickdays``) is resolved with ``sick=True``: 20:00 plus the +gaming pay of the day's credits, with no lift saved. A day marked sick after +the reset is raised by the live pass (:mod:`screen_locker._sick_floor`). """ from __future__ import annotations @@ -38,6 +37,7 @@ from screen_locker._day import today_str from screen_locker._earned import ( counted_gate_earners, earned_today, + first_credit_time, first_credits, first_minutes, flat_earners, @@ -166,7 +166,7 @@ def _reset( _logger.warning("Daily base reset: failed to write shutdown config.") return False - # Later additions are paid out of the floor lift (grace or sick) first. + # 0 now that no floor lifts the sum; overwrites a stale lift from before. save_lift(derived.lift) # The bonuses the reset already included are stamped, so the live pass @@ -179,13 +179,12 @@ def _reset( f"{span(t.shutdown_minutes)} {t.earner.label}" for t in day.terms ) _logger.info( - "Daily base reset: %s (base %s + %s already earned today%s%s%s).", + "Daily base reset: %s (base %s + %s already earned today%s%s).", hhmm(target), hhmm(day.base.shutdown_minutes), earned, "; rest day" if derived.rest_day else "", "; sick day" if day.sick else "", - f"; grace floor {hhmm(derived.grace)}" if derived.grace is not None else "", ) return True @@ -218,8 +217,10 @@ def _apply_flat_bonus( return False if not answer: return False - minutes = first_minutes(earner, date.fromisoformat(today)) - if not mixin._adjust_shutdown_time_by(minutes): + day = date.fromisoformat(today) + minutes = first_minutes(earner, day) + # Anchored on the credit's own row, so a late solve counts from its time. + if not mixin._adjust_shutdown_time_by(minutes, at=first_credit_time(earner, day)): _logger.warning("%s bonus: failed to write shutdown config.", earner.label) return False state[stamp] = today @@ -242,7 +243,6 @@ def apply_flat_bonuses_if_new(state_file: Path, mixin: object) -> None: state = _load_state(state_file) if apply_counted_bonus(state, adjust, earner, today_str()): _save_state(state_file, state) - # Last, so the floors compare against everything earned so far. The - # sick floor covers a day marked sick after today's reset. + # Last, so the sick target sees everything earned so far; it covers a + # day marked sick after today's reset. mixin._apply_sick_floor() - mixin._apply_grace_floor(getattr(mixin, "log_file", None)) diff --git a/screen_locker/_shutdown_target.py b/screen_locker/_shutdown_target.py index 2416c1c..d076f46 100644 --- a/screen_locker/_shutdown_target.py +++ b/screen_locker/_shutdown_target.py @@ -1,12 +1,14 @@ -"""Tonight's shutdown, derived from scratch: earned time, rest day, grace floor. +"""Tonight's shutdown, derived from scratch: earned time and rest day. :func:`derive` is pure, and is the one formula behind both the daily reset (:mod:`screen_locker._shutdown_base`) and the read-only preview (``scripts/preview_shutdown.py``), so a dry run exercises the real sum:: - earned = earned_time.resolve(answers, sick=...) # base + every earner, capped; - # sick: at least 20:00 - target = min(ceiling, max(earned, grace floor)) # no grace floor when sick + target = earned_time.resolve(answers, sick=...) # base + every earner, capped; + # sick: 20:00 + gaming pay + +A late credit (``_late_credit``) is live-only: it restarts tonight from its own +time, and the next day's reset re-derives from scratch. A declared rest day answers the workout with at least one unit, i.e. its first-unit bonus; a real workout on top of it earns only what a further unit @@ -20,8 +22,8 @@ from typing import TYPE_CHECKING import earned_time -from screen_locker._earned import ceiling, first_credits, gate_answers, registry -from screen_locker._grace_floor import first_done_at, grace_for +from screen_locker._earned import first_credits, gate_answers, registry +from screen_locker._grace_floor import first_done_at from screen_locker._log_io import load_workout_log from screen_locker._rest_day import is_rest_day from screen_locker._sick import is_sick @@ -42,14 +44,12 @@ class Target: day raised to the 20:00 sick floor). rest_day: Whether the day is a declared rest day. first_done_at: Unix time the day's first earner was done, if any. - grace: The grace floor in minutes, or ``None``. minutes: The shutdown to write, minutes after midnight. """ resolution: earned_time.Resolution rest_day: bool first_done_at: float | None - grace: int | None minutes: int @property @@ -59,7 +59,7 @@ class Target: @property def lift(self) -> int: - """How far the floors (grace or sick) lift the earned shutdown. + """How far a floor lifts the earned shutdown: 0 since the grace floor went. 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. @@ -86,7 +86,8 @@ class DayInputs: rest_day: Declared rest day. first_done: Unix time the day's first earner was done, if any. sick_day: Sick day (``sickdays``): shutdown is at least 20:00 - (``earned_time.resolve(..., sick=True)``) and no grace floor. + plus the gaming pay of the credits + (``earned_time.resolve(..., sick=True)``). first_credits: Each penalised gate's first real credit (:func:`~screen_locker._earned.first_credits`); ``None`` resolves on ``penalty_from`` alone, as before earned_time 0.6. @@ -102,7 +103,7 @@ class DayInputs: def derive(inputs: DayInputs) -> Target: - """The pure formula: earned time, then the grace floor, then the ceiling.""" + """The pure formula: earned time, capped (a sick day stacks on 20:00).""" day = inputs.day answers: dict[str, int | bool | None] = dict(inputs.flat) answers[earned_time.WORKOUT.name] = workout_units( @@ -118,16 +119,11 @@ def derive(inputs: DayInputs) -> Target: if starts is None else earned_time.resolve(answers, day, earners, first_credits=starts, sick=sick) ) - grace = None if inputs.sick_day else grace_for(inputs.first_done, day) - minutes = resolution.shutdown_minutes - if grace is not None: - minutes = min(ceiling(day), max(minutes, grace)) return Target( resolution=resolution, rest_day=inputs.rest_day, first_done_at=inputs.first_done, - grace=grace, - minutes=minutes, + minutes=resolution.shutdown_minutes, ) diff --git a/screen_locker/_workout_credit.py b/screen_locker/_workout_credit.py index d589efb..fda3978 100644 --- a/screen_locker/_workout_credit.py +++ b/screen_locker/_workout_credit.py @@ -23,6 +23,7 @@ from screen_locker._day import today_str from screen_locker._earned import extra_minutes from screen_locker._rest_day import is_rest_day from screen_locker._weekly_check import COUNTED_WORKOUT_TYPES, count_weekly_workouts +from screen_locker._workout_ledger import entry_done_at if TYPE_CHECKING: from pathlib import Path @@ -66,12 +67,22 @@ class WorkoutCreditMixin: workout_data: dict[str, str] log_file: Path + def _done_at(self, day: date) -> float | None: + """When this workout ended; ``None`` (now) when it records no end. + + ``workout_data`` is the entry's inner dict, so it is wrapped as the + log entry ``entry_done_at`` reads. The run's own end anchors a late + workout, never the moment a sync applied it. + """ + return entry_done_at({"workout_data": self.workout_data}, day) + def _try_adjust_shutdown_for_workout(self) -> bool: """Try to adjust shutdown time later for actual workouts.""" workout_type = self.workout_data.get("type", "") if workout_type not in COUNTED_WORKOUT_TYPES: return False - adjusted = self._adjust_shutdown_time_later() + day = date.fromisoformat(today_str()) + adjusted = self._adjust_shutdown_time_later(at=self._done_at(day)) if adjusted: _logger.info("Shutdown time moved later as workout reward") return adjusted @@ -141,7 +152,7 @@ class WorkoutCreditMixin: slot is CreditSlot.FURTHER and extra and before - and self._adjust_shutdown_time_by(extra) + and self._adjust_shutdown_time_by(extra, at=self._done_at(day)) ): new_cfg = self._read_shutdown_config() if new_cfg: diff --git a/screen_locker/tests/_late_credit_fixtures.py b/screen_locker/tests/_late_credit_fixtures.py new file mode 100644 index 0000000..5d5fb30 --- /dev/null +++ b/screen_locker/tests/_late_credit_fixtures.py @@ -0,0 +1,32 @@ +"""Pinning the clock an untimed credit is stamped with. + +Re-exported from ``conftest`` rather than defined there so neither file +exceeds the repo's line limit. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import TYPE_CHECKING +from unittest.mock import patch + +import pytest + +from screen_locker import _late_credit + +if TYPE_CHECKING: + from collections.abc import Iterator + + +@pytest.fixture(autouse=True) +def _untimed_credits_land_at_noon() -> Iterator[None]: + """A credit with no time of its own (``at=None``) lands at today's noon. + + ``at=None`` means "now", and a suite run after a test's configured + shutdown (18:00, say) made every such credit late: the same tests went red + at 18:01. Noon is before any shutdown, so only tests that pass ``at=`` + (or re-patch ``_late_credit._now``) take the late path. + """ + noon = datetime.now(tz=UTC).astimezone().replace(hour=12, minute=0, second=0) + with patch.object(_late_credit, "_now", return_value=noon.timestamp()): + yield diff --git a/screen_locker/tests/conftest.py b/screen_locker/tests/conftest.py index 7d8c23f..e36dc6f 100644 --- a/screen_locker/tests/conftest.py +++ b/screen_locker/tests/conftest.py @@ -28,6 +28,7 @@ from screen_locker.tests._gatelock_fixtures import ( dual_output, ) from screen_locker.tests._isolated_state import ISOLATED_STATE +from screen_locker.tests._late_credit_fixtures import _untimed_credits_land_at_noon from screen_locker.tests._locker_factories import ( _make_locker, create_locker, @@ -47,15 +48,15 @@ from screen_locker.tests._tk_mocks import ( _make_mock_tk, ) -# Re-exported so pytest still resolves these by name: a fixture defined in a -# sibling module is only visible to tests once conftest imports it. __all__ -# keeps ruff from pruning them as unused. +# Re-exported: a sibling module's fixture is visible to tests only once +# conftest imports it, and __all__ keeps ruff from pruning them as unused. __all__ = [ "FAKE_OUTPUTS", "TWO_OUTPUTS", "_hermetic_gatelock", "_make_locker", "_mock_sys_exit", + "_untimed_credits_land_at_noon", "create_locker", "create_locker_early_bird", "create_locker_relaxed_day", diff --git a/screen_locker/tests/test_grace_floor.py b/screen_locker/tests/test_grace_floor.py index 5775923..b385a8a 100644 --- a/screen_locker/tests/test_grace_floor.py +++ b/screen_locker/tests/test_grace_floor.py @@ -1,4 +1,4 @@ -"""Tests for the grace floor, its lift bookkeeping and the derived target.""" +"""Tests for when the day started and the lift bookkeeping (the floor is gone).""" from __future__ import annotations @@ -10,23 +10,20 @@ from typing import TYPE_CHECKING, Any from unittest.mock import patch import earned_time -import pytest -import sickdays -from screen_locker import _earned, _grace_floor +from screen_locker import _grace_floor from screen_locker._grace_floor import ( - GraceFloorMixin, first_done_at, - grace_for, plan_absorb, save_lift, workout_done_at, ) -from screen_locker.tests._sick_helpers import mark_sick if TYPE_CHECKING: from pathlib import Path + import pytest + _DAY = date(2026, 10, 10) _ISO = _DAY.isoformat() _FLAT = {"leetcode": False, "reading": False, "anki": False, "tutor": False} @@ -99,18 +96,6 @@ class TestWhenTheDayStarted: assert first_done_at(_DAY, None) is None -class TestGraceFor: - def test_an_hour_after_the_first_task(self) -> None: - assert grace_for(at("18:55"), _DAY) == 19 * 60 + 55 - - def test_capped_at_the_ceiling(self) -> None: - assert grace_for(at("22:40"), _DAY) == _earned.ceiling(_DAY) - - def test_nothing_done_or_another_day_is_no_floor(self) -> None: - assert grace_for(None, _DAY) is None - assert grace_for(at("12:00", _DAY - timedelta(1)), _DAY) is None - - class TestLift: def test_absorb_pays_from_the_lift_first(self) -> None: with patch.object(_grace_floor, "today_str", return_value=_ISO): @@ -139,71 +124,3 @@ class TestLift: with caplog.at_level(logging.WARNING): save_lift(5, tmp_path / "missing-dir" / "g.json") assert "Could not record the grace lift" in caplog.text - - -class Host(GraceFloorMixin): - """The mixin over an in-memory config.""" - - def __init__(self, config: tuple[int, int, int] | None, *, ok: bool = True) -> None: - self.config = config - self.ok = ok - - def _read_shutdown_config(self) -> tuple[int, int, int] | None: - return self.config - - def _write_shutdown_config( - self, mon_wed: int, thu_sun: int, morning_end: int, *, restore: bool = False - ) -> bool: - assert restore - if self.ok: - self.config = (mon_wed, thu_sun, morning_end) - return self.ok - - -@pytest.fixture -def saturday() -> Any: - with patch.object(_grace_floor, "today_str", return_value=_ISO): - yield - - -@pytest.mark.usefixtures("saturday") -class TestApply: - def test_lifts_to_the_floor_and_records_the_lift(self) -> None: - host = Host((19 * 60 + 25, 19 * 60 + 25, 300)) - assert host._apply_grace_floor(None, first_done=at("18:55")) is True - assert host.config == (19 * 60 + 55, 19 * 60 + 55, 300) - assert plan_absorb(0) == (0, 30) - - def test_already_above_the_floor(self) -> None: - host = Host((21 * 60, 21 * 60, 300)) - assert host._apply_grace_floor(None, first_done=at("18:55")) is False - - def test_no_first_task_no_floor(self) -> None: - with patch.object(_grace_floor, "first_done_at", return_value=None): - assert Host((1140, 1140, 300))._apply_grace_floor(None) is False - - def test_unreadable_config(self, caplog: pytest.LogCaptureFixture) -> None: - with caplog.at_level(logging.WARNING): - assert Host(None)._apply_grace_floor(None, first_done=at("18:55")) is False - assert "config unreadable" in caplog.text - - def test_failed_write(self, caplog: pytest.LogCaptureFixture) -> None: - host = Host((1140, 1140, 300), ok=False) - with caplog.at_level(logging.WARNING): - assert host._apply_grace_floor(None, first_done=at("18:55")) is False - assert "failed to write" in caplog.text - assert plan_absorb(0) == (0, 0) - - def test_a_sick_day_that_keeps_the_workout_lock_still_has_no_floor(self) -> None: - """The sick floor is shutdown's, so keep does not bring the grace floor back.""" - mark_sick(_DAY, keep=[sickdays.LOCKER_WORKOUT]) - host = Host((18 * 60, 18 * 60, 300)) - assert host._apply_grace_floor(None, first_done=at("18:55")) is False - assert host.config == (18 * 60, 18 * 60, 300) - - def test_a_sick_day_has_no_floor(self) -> None: - """A sick day has the 20:00 sick floor instead.""" - mark_sick(_DAY) - host = Host((18 * 60, 18 * 60, 300)) - assert host._apply_grace_floor(None, first_done=at("18:55")) is False - assert host.config == (18 * 60, 18 * 60, 300) diff --git a/screen_locker/tests/test_late_credit.py b/screen_locker/tests/test_late_credit.py new file mode 100644 index 0000000..58ff055 --- /dev/null +++ b/screen_locker/tests/test_late_credit.py @@ -0,0 +1,171 @@ +"""A credit that lands after tonight's shutdown restarts the clock from it. + +kuhy, 2026-10-17: shutting down must never make a finished task impossible to +submit. A credit timed at or after tonight's shutdown moves it to the +credit's own time plus its reward (capped at the ceiling), so LeetCode done +at 19:00 after an 18:00 shutdown means 20:00. Anchored on the credit's time, +never the sync's: a workout done at 17:00 and uploaded at 19:00 is not late. +""" + +from __future__ import annotations + +from datetime import date, datetime +from typing import TYPE_CHECKING, Any +from unittest.mock import patch + +import earned_time +import pytest + +from screen_locker import _grace_floor, _late_credit, _shutdown, _sick_floor +from screen_locker._earned import first_minutes +from screen_locker._grace_floor import plan_absorb, save_lift +from screen_locker._shutdown import ShutdownMixin +from screen_locker.tests._sick_helpers import mark_sick + +if TYPE_CHECKING: + from collections.abc import Iterator + +# Captured before conftest pins it to noon for every test. +_REAL_NOW = _late_credit._now + +_DAY = date(2026, 10, 17) # a Saturday: the Thu-Sun band is tonight's +_ISO = _DAY.isoformat() + + +def at(hhmm: str, day: date = _DAY) -> float: + """Unix time of ``hhmm`` local on ``day``.""" + hour, minute = (int(p) for p in hhmm.split(":")) + return datetime(day.year, day.month, day.day, hour, minute).astimezone().timestamp() + + +def host_workout_minutes() -> int: + return first_minutes(earned_time.WORKOUT, _DAY) + + +def m(hhmm: str) -> int: + hour, minute = (int(p) for p in hhmm.split(":")) + return hour * 60 + minute + + +class Host(ShutdownMixin): + """The real add paths over an in-memory config.""" + + def __init__(self, tonight: str, *, ok: bool = True) -> None: + self.config: tuple[int, int, int] = (m(tonight), m(tonight), 300) + self.ok = ok + + def _read_shutdown_config(self) -> tuple[int, int, int] | None: + return self.config + + def _write_shutdown_config( + self, mon_wed: int, thu_sun: int, morning_end: int, *, restore: bool = False + ) -> bool: + assert restore + if self.ok: + self.config = (mon_wed, thu_sun, morning_end) + return self.ok + + @property + def tonight(self) -> int: + return self.config[1] + + +@pytest.fixture(autouse=True) +def _on_the_day() -> Iterator[Any]: + with ( + patch.object(_sick_floor, "today_str", return_value=_ISO), + patch.object(_grace_floor, "today_str", return_value=_ISO), + patch.object(_shutdown, "today_str", return_value=_ISO), + patch.object(_late_credit, "today_str", return_value=_ISO), + ): + yield + + +class TestLateCredit: + def test_a_credit_before_the_shutdown_adds_as_before(self) -> None: + host = Host("20:00") + assert host._adjust_shutdown_time_by(60, at=at("18:30")) is True + assert host.tonight == m("21:00") + + def test_a_credit_after_the_shutdown_counts_from_its_own_time(self) -> None: + host = Host("18:00") + assert host._adjust_shutdown_time_by(60, at=at("19:00")) is True + assert host.tonight == m("20:00") + assert host.config[0] == m("20:00") # both bands, like the floors + + def test_a_credit_exactly_at_the_shutdown_is_late(self) -> None: + """The check script locks at ``now >= shutdown``.""" + host = Host("19:00") + host._adjust_shutdown_time_by(60, at=at("19:00")) + assert host.tonight == m("20:00") + + def test_capped_at_the_ceiling(self) -> None: + host = Host("22:30") + host._adjust_shutdown_time_by(120, at=at("22:45")) + assert host.tonight == m("23:00") + + def test_after_the_ceiling_nothing_moves_past_it(self) -> None: + host = Host("23:00") + host._adjust_shutdown_time_by(60, at=at("23:10")) + assert host.tonight == m("23:00") + + def test_a_late_upload_is_not_a_late_credit(self) -> None: + """Done at 17:00, synced at 19:00: 18:00 + 2 h, not 19:00 + 2 h.""" + host = Host("18:00") + assert host._adjust_shutdown_time_later(at=at("17:00")) is True + assert host.tonight == m("18:00") + host_workout_minutes() + + def test_a_late_workout_counts_from_its_end(self) -> None: + host = Host("18:00") + host._adjust_shutdown_time_later(at=at("19:00")) + assert host.tonight == min(m("23:00"), m("19:00") + host_workout_minutes()) + + def test_a_credit_timed_on_another_day_is_never_late(self) -> None: + host = Host("18:00") + host._adjust_shutdown_time_by(60, at=at("19:00", date(2026, 10, 16))) + assert host.tonight == m("19:00") + + def test_no_time_means_now(self) -> None: + host = Host("18:00") + with patch.object(_late_credit, "_now", return_value=at("19:30")): + host._adjust_shutdown_time_by(60) + assert host.tonight == m("20:30") + + def test_now_is_the_wall_clock(self) -> None: + with patch.object(_late_credit.time, "time", return_value=123.0): + assert _REAL_NOW() == 123.0 + + def test_a_late_credit_clears_the_lift(self) -> None: + """Past the shutdown the floor is spent: nothing is left to absorb.""" + save_lift(30) + host = Host("18:00") + host._adjust_shutdown_time_by(60, at=at("19:00")) + assert host.tonight == m("20:00") + assert plan_absorb(60) == (60, 0) + + def test_a_failed_write_keeps_the_lift(self) -> None: + save_lift(30) + host = Host("18:00", ok=False) + assert host._adjust_shutdown_time_by(60, at=at("19:00")) is False + assert plan_absorb(0) == (0, 30) + + def test_unreadable_config_fails(self) -> None: + host = Host("18:00") + with patch.object(Host, "_read_shutdown_config", return_value=None): + assert host._adjust_shutdown_time_by(60, at=at("19:00")) is False + + +class TestLateOnASickDay: + def test_late_after_the_sick_target(self) -> None: + mark_sick(_DAY) + host = Host("20:00") + with patch.object(_sick_floor, "sick_target", return_value=m("20:00")): + assert host._adjust_shutdown_time_by(60, at=at("21:00")) is True + assert host.tonight == m("22:00") + + def test_not_late_raises_to_the_target_only(self) -> None: + mark_sick(_DAY) + host = Host("21:00") + with patch.object(_sick_floor, "sick_target", return_value=m("22:00")): + host._adjust_shutdown_time_by(60, at=at("15:00")) + assert host.tonight == m("22:00") diff --git a/screen_locker/tests/test_manual_workout_features_part2.py b/screen_locker/tests/test_manual_workout_features_part2.py index bf1c6f6..74882e9 100644 --- a/screen_locker/tests/test_manual_workout_features_part2.py +++ b/screen_locker/tests/test_manual_workout_features_part2.py @@ -3,12 +3,14 @@ from __future__ import annotations +from datetime import date, datetime, time import pathlib from typing import TYPE_CHECKING from unittest.mock import MagicMock, patch import pytest +from screen_locker._day import today_str from screen_locker._log_mixin import RecordResult from screen_locker.tests.conftest import create_locker @@ -27,6 +29,13 @@ if TYPE_CHECKING: from pathlib import Path +def _today_at(hhmm: str) -> float: + """Unix time of ``hhmm`` local on the locker's today.""" + hours, minutes = (int(p) for p in hhmm.split(":")) + day = date.fromisoformat(today_str()) + return datetime.combine(day, time(hours, minutes)).astimezone().timestamp() + + @pytest.mark.usefixtures("pre_ladder") class TestApplyWorkoutCredit: """Tests for ScreenLocker._apply_workout_credit under the multi-per-day rule. @@ -92,7 +101,7 @@ class TestApplyWorkoutCredit: ``test_additional_verified_workout_earns_plus_one_hour``. """ locker = create_locker(mock_tk, tmp_path) - locker.workout_data = {"type": "manual_workout"} + locker.workout_data = {"type": "manual_workout", "end_time": "17:00"} prior = [{"workout_data": {"type": "manual_workout"}}] object.__setattr__( locker, @@ -121,7 +130,9 @@ class TestApplyWorkoutCredit: assert result.shutdown_adjusted is False assert result.extra_bonus_delta == 60 locker._try_adjust_shutdown_for_workout.assert_not_called() - locker._adjust_shutdown_time_by.assert_called_once_with(60) + locker._adjust_shutdown_time_by.assert_called_once_with( + 60, at=_today_at("17:00") + ) def test_additional_verified_workout_earns_plus_one_hour( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path @@ -162,4 +173,4 @@ class TestApplyWorkoutCredit: assert result.shutdown_adjusted is False assert result.extra_bonus_delta == 60 locker._try_adjust_shutdown_for_workout.assert_not_called() - locker._adjust_shutdown_time_by.assert_called_once_with(60) + locker._adjust_shutdown_time_by.assert_called_once_with(60, at=None) diff --git a/screen_locker/tests/test_phone_check_unlock_part2.py b/screen_locker/tests/test_phone_check_unlock_part2.py index 4fc9a35..791e4a2 100644 --- a/screen_locker/tests/test_phone_check_unlock_part2.py +++ b/screen_locker/tests/test_phone_check_unlock_part2.py @@ -114,14 +114,15 @@ class TestUnlockScreenShutdownAdjustment: """Test unlock_screen adjusts shutdown for phone-verified workout.""" locker = create_locker(mock_tk, tmp_path) locker.log_file = tmp_path / "log.json" - locker.workout_data = {"type": "phone_verified"} + locker.workout_data = {"type": "phone_verified", "completed_at": "123.0"} object.__setattr__( locker, "_adjust_shutdown_time_later", MagicMock(return_value=True) ) locker.unlock_screen() - locker._adjust_shutdown_time_later.assert_called_once() + # Anchored on the workout's own end, so a late upload buys nothing. + locker._adjust_shutdown_time_later.assert_called_once_with(at=123.0) def test_unlock_screen_skips_adjustment_for_sick_day( self, diff --git a/screen_locker/tests/test_screen_lock_coverage_part2.py b/screen_locker/tests/test_screen_lock_coverage_part2.py index 89585c5..967c844 100644 --- a/screen_locker/tests/test_screen_lock_coverage_part2.py +++ b/screen_locker/tests/test_screen_lock_coverage_part2.py @@ -41,7 +41,7 @@ class TestUnlockScreenExtras: ): locker.unlock_screen() - locker._adjust_shutdown_time_by.assert_called_once_with(60) + locker._adjust_shutdown_time_by.assert_called_once_with(60, at=None) def test_extra_bonus_delta_displayed( self, diff --git a/screen_locker/tests/test_shutdown_ceiling_invariant.py b/screen_locker/tests/test_shutdown_ceiling_invariant.py index e90895f..b672b29 100644 --- a/screen_locker/tests/test_shutdown_ceiling_invariant.py +++ b/screen_locker/tests/test_shutdown_ceiling_invariant.py @@ -6,8 +6,8 @@ in ``earned_time.earners_for(day)`` is done to its full units (the tutor's four blocks included) must shut down exactly at ``shutdown_ceiling_for(day)`` -- never short of it, and never past it. Checked through screen-locker's own paths, not a re-implementation: the daily reset's :func:`derive`, and the live -pass (first workout, further workouts, flat gates, counted gate blocks, grace -floor) over :class:`ShutdownMixin` with an in-memory config. +pass (first workout, further workouts, flat gates, counted gate blocks, each +timed at ``first`` so a 22:30 one is a late credit) over :class:`ShutdownMixin` with an in-memory config. Every gate maturity state is covered: ``first_credits`` omitted (0.5.0 ``penalty_from``), empty (every earner on its ``confirmed_on``), never paid @@ -18,13 +18,14 @@ maturing (paid yesterday), mature, and a new tutor beside mature gates. from __future__ import annotations from datetime import date, datetime, time, timedelta +from functools import partial from typing import TYPE_CHECKING, Any from unittest.mock import patch import earned_time import pytest -from screen_locker import _gate_bonus, _grace_floor, _shutdown +from screen_locker import _gate_bonus, _grace_floor, _late_credit, _shutdown from screen_locker._earned import ceiling, extra_minutes, first_minutes, is_gate from screen_locker._earned import registry as registry_for from screen_locker._gate_bonus import apply_counted_bonus @@ -148,26 +149,29 @@ def _live_pass(day: date, case: tuple[str, bool, int, str]) -> int: """Reset with nothing done, then every credit as the live pass applies it.""" _, rest, workouts, first = case host = _Host(derive(_inputs(day, case, done=False)).minutes) - first_done = _at(day, first) - if first_done is not None: - host._apply_grace_floor(None, first_done=first_done) + # Every credit lands at ``first`` (09:00 when "none"): at 22:30 they are + # late credits (_late_credit), which must still land on the ceiling. + when = _at(day, first) or _at(day, "09:00") # A rest day's first unit is already in the reset; a real workout on it # is a further unit (_workout_credit's rest_paid rule). further = workouts if rest else workouts - 1 if not rest: - host._adjust_shutdown_time_later() + host._adjust_shutdown_time_later(at=when) extra = extra_minutes(earned_time.WORKOUT, day) for _ in range(further if extra else 0): - host._adjust_shutdown_time_by(extra) + host._adjust_shutdown_time_by(extra, at=when) state: dict[str, Any] = {} for gate in (e for e in registry_for(day) if is_gate(e)): if gate.kind == "flat": - host._adjust_shutdown_time_by(first_minutes(gate, day)) + host._adjust_shutdown_time_by(first_minutes(gate, day), at=when) continue for units in range(1, (gate.max_units or 1) + 1): with patch.object(_gate_bonus, "earned_units", return_value=units): apply_counted_bonus( - state, host._adjust_shutdown_time_by, gate, day.isoformat() + state, + partial(host._adjust_shutdown_time_by, at=when), + gate, + day.isoformat(), ) return host.config[1] @@ -178,8 +182,10 @@ def frozen_day() -> Iterator[Callable[[date], Any]]: def at_day(day: date) -> Any: iso = day.isoformat() - return patch.multiple(_shutdown, today_str=lambda: iso), patch.multiple( - _grace_floor, today_str=lambda: iso, is_sick=lambda _d: False + return ( + patch.multiple(_shutdown, today_str=lambda: iso, is_sick=lambda _d: False), + patch.multiple(_grace_floor, today_str=lambda: iso), + patch.multiple(_late_credit, today_str=lambda: iso), ) return at_day @@ -197,8 +203,8 @@ def test_the_live_pass_lands_every_all_done_day_on_the_ceiling( case = (maturity, rest, workouts, first) misses = [] for day in _DAYS: - shutdown_patch, grace_patch = frozen_day(day) - with shutdown_patch, grace_patch: + shutdown_patch, grace_patch, late_patch = frozen_day(day) + with shutdown_patch, grace_patch, late_patch: got = _live_pass(day, case) _grace_floor.GRACE_STATE_FILE.unlink(missing_ok=True) if got != ceiling(day): diff --git a/screen_locker/tests/test_shutdown_flat_earners.py b/screen_locker/tests/test_shutdown_flat_earners.py index 3362015..6426a94 100644 --- a/screen_locker/tests/test_shutdown_flat_earners.py +++ b/screen_locker/tests/test_shutdown_flat_earners.py @@ -89,7 +89,7 @@ class TestFlatBonuses: state = tmp_path / "state.json" mixin = _mixin() apply_flat_bonuses_if_new(state, mixin) - mixin._adjust_shutdown_time_by.assert_called_once_with(60) + mixin._adjust_shutdown_time_by.assert_called_once_with(60, at=None) assert json.loads(state.read_text()) == {"reading_bonus_date": today_str()} @@ -118,7 +118,7 @@ class TestANewlyRegisteredEarner: mixin = _mixin() apply_flat_bonuses_if_new(state, mixin) apply_flat_bonuses_if_new(state, mixin) - mixin._adjust_shutdown_time_by.assert_called_once_with(60) + mixin._adjust_shutdown_time_by.assert_called_once_with(60, at=None) assert json.loads(state.read_text()) == {"extra_bonus_date": today_str()} def test_its_ledger_is_read_like_any_other(self, tmp_path: Path) -> None: @@ -126,7 +126,7 @@ class TestANewlyRegisteredEarner: mixin = _mixin() with signing_key(tmp_path): apply_flat_bonuses_if_new(tmp_path / "state.json", mixin) - mixin._adjust_shutdown_time_by.assert_called_once_with(60) + mixin._adjust_shutdown_time_by.assert_called_once_with(60, at=None) def test_a_penalised_earner_lowers_the_base_from_its_day( self, monkeypatch: pytest.MonkeyPatch @@ -149,8 +149,7 @@ class TestRealLedgers: @pytest.mark.usefixtures("pre_ladder") def test_signed_credits_on_disk_reach_the_reset(self, tmp_path: Path) -> None: """No mock between the ledgers and the written hour.""" - # Pinned to local midnight: stamped "now", the grace floor (first - # credit + GRACE_MINUTES) beats base+120 whenever this runs after ~19:00. + # Pinned to local midnight, so the credits are never late ones. midnight = datetime.now(tz=UTC).astimezone().replace(hour=0, minute=0, second=0) write_ledger(earned_time.LEETCODE, [credit(earned_time.LEETCODE, midnight)]) write_ledger(earned_time.READING, [credit(earned_time.READING, midnight)]) @@ -169,6 +168,18 @@ class TestRealLedgers: "reading_bonus_date": today_str(), } + def test_the_live_pass_hands_on_the_credit_s_own_time(self, tmp_path: Path) -> None: + """A late credit counts from its ledger row, never the pass's clock.""" + midnight = datetime.now(tz=UTC).astimezone().replace(hour=0, minute=0, second=0) + write_ledger(earned_time.LEETCODE, [credit(earned_time.LEETCODE, midnight)]) + mixin = _mixin() + with signing_key(tmp_path), answering({"leetcode": True}): + apply_flat_bonuses_if_new(tmp_path / "state.json", mixin) + mixin._adjust_shutdown_time_by.assert_called_once_with( + _earned.first_minutes(earned_time.LEETCODE, midnight.date()), + at=float(int(midnight.timestamp())), + ) + class TestProjectionFollowsTheCut: """Every projected row is priced on its own date, not on today's base.""" diff --git a/screen_locker/tests/test_shutdown_leetcode.py b/screen_locker/tests/test_shutdown_leetcode.py index 21c05c2..4710fa5 100644 --- a/screen_locker/tests/test_shutdown_leetcode.py +++ b/screen_locker/tests/test_shutdown_leetcode.py @@ -95,7 +95,7 @@ class TestLivePass: mixin = _mixin() assert apply_leetcode_bonus_if_new(state, mixin) is True assert apply_leetcode_bonus_if_new(state, mixin) is False - mixin._adjust_shutdown_time_by.assert_called_once_with(60) + mixin._adjust_shutdown_time_by.assert_called_once_with(60, at=None) assert json.loads(state.read_text()) == { "last_reset_date": today_str(), "leetcode_bonus_date": today_str(), diff --git a/screen_locker/tests/test_shutdown_reading.py b/screen_locker/tests/test_shutdown_reading.py index 6888450..40dc54a 100644 --- a/screen_locker/tests/test_shutdown_reading.py +++ b/screen_locker/tests/test_shutdown_reading.py @@ -185,7 +185,7 @@ class TestReadingLivePass: mixin = _mixin() assert apply_reading_bonus_if_new(state, mixin) is True assert apply_reading_bonus_if_new(state, mixin) is False - mixin._adjust_shutdown_time_by.assert_called_once_with(60) + mixin._adjust_shutdown_time_by.assert_called_once_with(60, at=None) assert json.loads(state.read_text()) == { "last_reset_date": today_str(), "reading_bonus_date": today_str(), diff --git a/screen_locker/tests/test_shutdown_target.py b/screen_locker/tests/test_shutdown_target.py index caa92c6..d6ef600 100644 --- a/screen_locker/tests/test_shutdown_target.py +++ b/screen_locker/tests/test_shutdown_target.py @@ -48,16 +48,16 @@ class TestTarget: assert workout_units(2, rest_day=True) == 2 assert workout_units(0, rest_day=False) == 0 - def test_the_floor_lifts_and_the_lift_is_reported(self) -> None: + def test_an_early_first_task_no_longer_lifts_the_sum(self) -> None: + """The grace floor is gone (2026-10-17): late credits replace it.""" target = derive( DayInputs(_DAY, {**_FLAT, "anki": True}, first_done=at("18:55")) ) - assert target.minutes == max(target.earned, 19 * 60 + 55) - assert target.lift == target.minutes - target.earned + assert target.minutes == target.earned + assert target.lift == 0 - def test_a_sick_day_has_no_grace_floor_but_the_20_00_one(self) -> None: + def test_a_sick_day_has_the_20_00_floor(self) -> None: 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 == SICK_FLOOR + sum( t.gaming_minutes for t in target.resolution.terms @@ -112,5 +112,5 @@ class TestTarget: target = gather(_DAY, log) assert target.resolution.term("workout").answer == 1 assert target.first_done_at == at("18:00") - assert target.grace == 19 * 60 + assert target.minutes == target.earned assert not target.rest_day diff --git a/scripts/_preview_sim.py b/scripts/_preview_sim.py index fee4417..e54d3ed 100644 --- a/scripts/_preview_sim.py +++ b/scripts/_preview_sim.py @@ -89,7 +89,7 @@ class SimShutdown(ShutdownMixin): self.config = (*clamp, morning_end) return True - def credit(self, name: str, day: date, first_done: float | None) -> None: + def credit(self, name: str, day: date, at: float | None = None) -> None: """One live pass: the earner's add (lift-absorbing), then the floors. ``sick`` is not an earner: it marks the (temporary) pool and runs the @@ -99,12 +99,11 @@ class SimShutdown(ShutdownMixin): if name == SICK_STEP: sick_pool(day, sick_day=True) elif name == earned_time.WORKOUT.name: - self._adjust_shutdown_time_later() + self._adjust_shutdown_time_later(at=at) else: - self._adjust_shutdown_time_by(first_minutes(earned_time.earner(name), day)) + minutes = first_minutes(earned_time.earner(name), day) + self._adjust_shutdown_time_by(minutes, at=at) self._apply_sick_floor() - if first_done is not None: - self._apply_grace_floor(None, first_done=first_done) # The --sequence step that marks the day sick instead of crediting an earner. diff --git a/scripts/preview_shutdown.py b/scripts/preview_shutdown.py index 9b79a15..422caa1 100755 --- a/scripts/preview_shutdown.py +++ b/scripts/preview_shutdown.py @@ -5,7 +5,7 @@ READ-ONLY on the real system: it never runs the sudo helper, never writes ``/etc``, ``shutdown_base.json``, ``grace_floor.json``, ``rest_days.json``, ``log.json`` or the real workout ledger. Simulations run the real code paths (TCX parsing + walk summing via the RunnerUp backfill, rest-day signing and -verification, ``_shutdown_target.derive``, the grace and sick floors and their +verification, ``_shutdown_target.derive``, the sick floor, late credits and the lift bookkeeping, a temporary ``sickdays`` pool) against a temporary directory, with an in-memory shutdown config. @@ -33,7 +33,7 @@ import tempfile import earned_time -from screen_locker import _earned, _grace_floor, _shutdown, _sick_floor +from screen_locker import _earned, _grace_floor, _late_credit, _shutdown, _sick_floor from screen_locker._constants import SHUTDOWN_CONFIG_FILE from screen_locker._day import today_str from screen_locker._earned import hhmm @@ -74,11 +74,10 @@ def _report(label: str, target: Target) -> None: if target.first_done_at else "-" ) - grace = hhmm(target.grace) if target.grace is not None else "-" _say(f"[{label}] {res.day} base {hhmm(res.base.shutdown_minutes)} | {terms}") _say( f" rest_day={target.rest_day} sick={res.sick} earned={hhmm(target.earned)} " - f"first_done={first} grace={grace} lift={target.lift} " + f"first_done={first} lift={target.lift} " f"-> SHUTDOWN {hhmm(target.minutes)}" ) @@ -118,9 +117,15 @@ def _simulate(args: argparse.Namespace, tmp: Path) -> None: _report("simulated", target) +def hhmm_minutes(text: str) -> int: + """``HH:MM`` as minutes after midnight.""" + hour, minute = (int(p) for p in text.split(":")) + return hour * 60 + minute + + def _sequence(args: argparse.Namespace, tmp: Path) -> None: day = date.fromisoformat(args.day) - for module in (_grace_floor, _shutdown, _sick_floor): + for module in (_grace_floor, _late_credit, _shutdown, _sick_floor): vars(module)["today_str"] = lambda: args.day vars(module)["GRACE_STATE_FILE"] = tmp / "grace_floor.json" use_temp_pool(tmp) @@ -128,7 +133,11 @@ def _sequence(args: argparse.Namespace, tmp: Path) -> None: start = derive(DayInputs(day, done)) sim = SimShutdown(start.minutes) _say(f"[sequence] {day} start {hhmm(start.minutes)} (nothing done, not sick)") - workouts, first = 0, None + workouts, first, went_late = 0, None, False + # The real target reads today's real ledgers; the sim's are ``done``. + vars(_sick_floor)["sick_target"] = lambda d, _log: ( + derive(DayInputs(d, done, workouts, sick_day=True)).minutes + ) for step in args.sequence.split(","): name, _, at = step.partition("@") if name == earned_time.WORKOUT.name: @@ -137,11 +146,19 @@ def _sequence(args: argparse.Namespace, tmp: Path) -> None: done[name] = True if name != SICK_STEP: first = min(first or stamp(day, at), stamp(day, at)) - sim.credit(name, day, first) + late = name != SICK_STEP and hhmm_minutes(at) >= sim.config[0] + sim.credit(name, day, stamp(day, at)) want = derive( DayInputs(day, done, workouts, first_done=first, sick_day=is_sick(day)) ) - verdict = "" if sim.config[0] == want.minutes else " MISMATCH" + # A late credit is live-only, so from it on the derived sum is no judge. + went_late = went_late or late + if late: + verdict = " LATE: from its own time" + elif went_late: + verdict = " live, after a late credit" + else: + verdict = "" if sim.config[0] == want.minutes else " MISMATCH" _say( f" {name}@{at}: config {hhmm(sim.config[0])} " f"(derived {hhmm(want.minutes)}{verdict})"