late credits: a credit at/after tonight's shutdown sets it to min(23:00, its own time + reward); grace floor removed (also on sick days)
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 18:14:04 +02:00
parent f2ddf3a491
commit 46393fa866
24 changed files with 508 additions and 292 deletions

View File

@ -59,12 +59,25 @@ made the code's default dead.
A sick day is a date in the shared `sickdays` pool, read only through 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.py`. It may keep lockers: the workout lock asks
`_sick.stands_down_workout` (keep-aware), while everything that only computes `_sick.stands_down_workout` (keep-aware), while everything that only computes
shutdown (this floor, `_grace_floor`, `_shutdown_base`, `_shutdown_target`, shutdown (`_sick_floor`, `_shutdown_base`, `_shutdown_target`, the preview
the preview scripts) asks `is_sick` and ignores keep. The reset resolves it with `earned_time.resolve(..., sick=True)` scripts) asks `is_sick` and ignores keep. A sick day's shutdown is absolute:
(shutdown >= 20:00) and saves the lift above the earned sum like the grace 20:00 plus the GAMING pay of everything credited that day, capped at 23:00
lift; a day marked after the reset is lifted by `_sick_floor` on the next (`earned_time.sick_shutdown_from`, 0.13). The reset writes it, `_sick_floor`
live pass. Both are floors, never terms: later credits are paid out of the re-derives it on every live pass from the ledgers, and a sick-day credit only
lift first (`_grace_floor.plan_absorb`), so nothing stacks on 20:00. 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` 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 via `_earned.py`: HMAC-verified `credit` rows matched by the earner's own

View File

@ -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 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 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 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, the same. On a sick day the workout lock stands down,
wake-alarm's workday penalty is void, and shutdown is wake-alarm's workday penalty is void, and shutdown is 20:00 plus the gaming
`max(20:00, the normal ladder)` capped at 23:00 (`earned_time.resolve(..., pay of everything credited that day, capped at 23:00
sick=True)`). A day marked after its daily reset is lifted to 20:00 by the (`earned_time.sick_shutdown_from`). A day marked after its daily reset is
next 5-minute pass (`_sick_floor`); a workout that day still earns normally raised to it by the next live pass (`_sick_floor`), and every later credit
and is paid out of that lift first, so it lands on the ladder value (e.g. that day raises it again.
20:40), never 20:00 plus its whole reward. `install.sh` migrates the old
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`). `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 A sick day may *keep* lockers (`sickdays add --keep workout`). A kept workout

View File

@ -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_DAY_FILE = Path(__file__).resolve().parent / "rest_days.json"
REST_DAYS_PER_ISO_WEEK = 2 REST_DAYS_PER_ISO_WEEK = 2
GRACE_STATE_FILE = Path(__file__).resolve().parent / "grace_floor.json" 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 # A day's RunnerUp walks (commute legs) count as the workout once their
# MOVING time sums to this; moving = trackpoint pairs at walking speed. # MOVING time sums to this; moving = trackpoint pairs at walking speed.
WALK_MIN_MOVING_MINUTES = 40 WALK_MIN_MOVING_MINUTES = 40

View File

@ -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 ``None`` when there is none, when it cannot be checked, and on an
``earned_time`` without ``first_credit_at`` (0.3.0) -- logged, because ``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) fn = getattr(earned_time, "first_credit_at", None)
if fn is 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: def _warn_no_first_credit() -> None:
"""Once per process: the installed earned_time predates first_credit_at.""" """Once per process: the installed earned_time predates first_credit_at."""
_logger.warning( _logger.warning(
"earned_time has no first_credit_at (needs >= 0.4); the grace floor " "earned_time has no first_credit_at (needs >= 0.4); a late ledger "
"sees no ledger earner, only the workout log" "credit counts from now, not from its own time"
) )

View File

@ -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 The grace floor (first task + 60 min) is gone since 2026-10-17: a credit at
cut off at 19:00. The floor is ``first done + GRACE_MINUTES``, capped at the or after tonight's shutdown now counts from its own time
day's ceiling. "First done" is the earliest of every gate earner's first (:mod:`screen_locker._late_credit`), which is what the floor approximated,
verified credit, flat or counted (``earned_time.first_credit_at``), and the and the floor's lift then absorbed minutes the late credit had already
workout's own completion time -- the end of the run or walk, never the restarted from (LeetCode late at 19:00, reading at 19:30: 20:10, not 20:20).
moment a sync happened to log it, so a late upload does not move the floor
later.
**A floor, not a bonus.** The config is otherwise read-add-write: every What stays: :func:`first_done_at` (shown by the preview and the reset log)
earner adds its minutes to whatever is there. Lifting it to the floor and and the lift file. The lift is ``0`` on every day now (no floor lifts the
then letting the next earner add on top would pay the lift twice (anki at config above the earned sum), but ``plan_absorb`` still spends a stale one
21:00 lifts to 22:00; reading then makes 22:30 where max(earned, floor) is recorded before the change, once.
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``.
""" """
from __future__ import annotations from __future__ import annotations
@ -28,13 +21,10 @@ from typing import TYPE_CHECKING
import earned_time import earned_time
from screen_locker._bonus_lock import bonus_lock from screen_locker._constants import GRACE_STATE_FILE
from screen_locker._constants import GRACE_MINUTES, GRACE_STATE_FILE
from screen_locker._day import today_str 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._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._weekly_check import credit_key, day_workout_index
from screen_locker._workout_ledger import entry_done_at from screen_locker._workout_ledger import entry_done_at
@ -96,17 +86,6 @@ def first_done_at(
return min(on_day, default=None) 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: def load_lift(state_file: Path, today: str) -> int:
"""Today's recorded floor lift (grace or sick), 0 when none or unreadable.""" """Today's recorded floor lift (grace or sick), 0 when none or unreadable."""
if not state_file.exists(): 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.""" """``day``'s shutdown from a (mon_wed, thu_sun, morning_end) config."""
mon_wed, thu_sun, _ = config mon_wed, thu_sun, _ = config
return mon_wed if day.weekday() in _MON_WED else thu_sun 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

View File

@ -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

View File

@ -15,7 +15,8 @@ from screen_locker._constants import (
) )
from screen_locker._day import today_str from screen_locker._day import today_str
from screen_locker._earned import ceiling, first_minutes, hhmm, span 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 import is_sick
from screen_locker._sick_floor import SickFloorMixin 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 return mon_wed, thu_sun, morning_end
class ShutdownMixin(GraceFloorMixin, SickFloorMixin): class ShutdownMixin(SickFloorMixin, LateCreditMixin):
"""Mixin providing shutdown schedule adjustment functionality.""" """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. """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()) day = date.fromisoformat(today_str())
reward = first_minutes(earned_time.WORKOUT, day)
if is_sick(day): if is_sick(day):
return self._raise_on_sick_day() return self._raise_on_sick_day(reward, at)
bonus, lift = plan_absorb(first_minutes(earned_time.WORKOUT, day)) late = self._late_write(reward, at)
if late is not None:
return late
bonus, lift = plan_absorb(reward)
try: try:
config_values = self._read_shutdown_config() config_values = self._read_shutdown_config()
if config_values is None: if config_values is None:
@ -98,18 +104,24 @@ class ShutdownMixin(GraceFloorMixin, SickFloorMixin):
save_lift(lift) save_lift(lift)
return ok 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). """Push shutdown later by *extra_minutes*, capped at 24:00 (midnight).
Used for earner and extra-workout bonuses. A cap of midnight works 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 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. 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())): if is_sick(date.fromisoformat(today_str())):
return self._raise_on_sick_day() return self._raise_on_sick_day(extra_minutes, at)
# Paid out of the grace lift first, so the floor is never stacked on. 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) moved, lift = plan_absorb(extra_minutes)
try: try:
config_values = self._read_shutdown_config() config_values = self._read_shutdown_config()
@ -131,17 +143,6 @@ class ShutdownMixin(GraceFloorMixin, SickFloorMixin):
save_lift(lift) save_lift(lift)
return ok 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: def _read_shutdown_config(self) -> tuple[int, int, int] | None:
"""Read shutdown config as (mon_wed, thu_sun, morning_end) minutes, or None.""" """Read shutdown config as (mon_wed, thu_sun, morning_end) minutes, or None."""
return read_shutdown_config(SHUTDOWN_CONFIG_FILE) return read_shutdown_config(SHUTDOWN_CONFIG_FILE)

View File

@ -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; disk. Any bonus source that is not a term of this derivation has that bug;
registering it in ``earned_time`` makes it one. registering it in ``earned_time`` makes it one.
A sick day (``sickdays``) is resolved with ``sick=True``: shutdown is at least A sick day (``sickdays``) is resolved with ``sick=True``: 20:00 plus the
20:00, and the lift above the earned sum is saved like the grace lift. A day gaming pay of the day's credits, with no lift saved. A day marked sick after
marked sick after the reset is lifted by the live pass the reset is raised by the live pass (:mod:`screen_locker._sick_floor`).
(:mod:`screen_locker._sick_floor`).
""" """
from __future__ import annotations from __future__ import annotations
@ -38,6 +37,7 @@ from screen_locker._day import today_str
from screen_locker._earned import ( from screen_locker._earned import (
counted_gate_earners, counted_gate_earners,
earned_today, earned_today,
first_credit_time,
first_credits, first_credits,
first_minutes, first_minutes,
flat_earners, flat_earners,
@ -166,7 +166,7 @@ def _reset(
_logger.warning("Daily base reset: failed to write shutdown config.") _logger.warning("Daily base reset: failed to write shutdown config.")
return False 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) save_lift(derived.lift)
# The bonuses the reset already included are stamped, so the live pass # 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 f"{span(t.shutdown_minutes)} {t.earner.label}" for t in day.terms
) )
_logger.info( _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(target),
hhmm(day.base.shutdown_minutes), hhmm(day.base.shutdown_minutes),
earned, earned,
"; rest day" if derived.rest_day else "", "; rest day" if derived.rest_day else "",
"; sick day" if day.sick else "", "; sick day" if day.sick else "",
f"; grace floor {hhmm(derived.grace)}" if derived.grace is not None else "",
) )
return True return True
@ -218,8 +217,10 @@ def _apply_flat_bonus(
return False return False
if not answer: if not answer:
return False return False
minutes = first_minutes(earner, date.fromisoformat(today)) day = date.fromisoformat(today)
if not mixin._adjust_shutdown_time_by(minutes): 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) _logger.warning("%s bonus: failed to write shutdown config.", earner.label)
return False return False
state[stamp] = today state[stamp] = today
@ -242,7 +243,6 @@ def apply_flat_bonuses_if_new(state_file: Path, mixin: object) -> None:
state = _load_state(state_file) state = _load_state(state_file)
if apply_counted_bonus(state, adjust, earner, today_str()): if apply_counted_bonus(state, adjust, earner, today_str()):
_save_state(state_file, state) _save_state(state_file, state)
# Last, so the floors compare against everything earned so far. The # Last, so the sick target sees everything earned so far; it covers a
# sick floor covers a day marked sick after today's reset. # day marked sick after today's reset.
mixin._apply_sick_floor() mixin._apply_sick_floor()
mixin._apply_grace_floor(getattr(mixin, "log_file", None))

View File

@ -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 :func:`derive` is pure, and is the one formula behind both the daily reset
(:mod:`screen_locker._shutdown_base`) and the read-only preview (:mod:`screen_locker._shutdown_base`) and the read-only preview
(``scripts/preview_shutdown.py``), so a dry run exercises the real sum:: (``scripts/preview_shutdown.py``), so a dry run exercises the real sum::
earned = earned_time.resolve(answers, sick=...) # base + every earner, capped; target = earned_time.resolve(answers, sick=...) # base + every earner, capped;
# sick: at least 20:00 # sick: 20:00 + gaming pay
target = min(ceiling, max(earned, grace floor)) # no grace floor when sick
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 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 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 import earned_time
from screen_locker._earned import ceiling, first_credits, gate_answers, registry from screen_locker._earned import first_credits, gate_answers, registry
from screen_locker._grace_floor import first_done_at, grace_for from screen_locker._grace_floor import first_done_at
from screen_locker._log_io import load_workout_log from screen_locker._log_io import load_workout_log
from screen_locker._rest_day import is_rest_day from screen_locker._rest_day import is_rest_day
from screen_locker._sick import is_sick from screen_locker._sick import is_sick
@ -42,14 +44,12 @@ class Target:
day raised to the 20:00 sick floor). day raised to the 20:00 sick floor).
rest_day: Whether the day is a declared rest day. rest_day: Whether the day is a declared rest day.
first_done_at: Unix time the day's first earner was done, if any. 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. minutes: The shutdown to write, minutes after midnight.
""" """
resolution: earned_time.Resolution resolution: earned_time.Resolution
rest_day: bool rest_day: bool
first_done_at: float | None first_done_at: float | None
grace: int | None
minutes: int minutes: int
@property @property
@ -59,7 +59,7 @@ class Target:
@property @property
def lift(self) -> int: 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 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. 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. rest_day: Declared rest day.
first_done: Unix time the day's first earner was done, if any. first_done: Unix time the day's first earner was done, if any.
sick_day: Sick day (``sickdays``): shutdown is at least 20:00 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 first_credits: Each penalised gate's first real credit
(:func:`~screen_locker._earned.first_credits`); ``None`` resolves (:func:`~screen_locker._earned.first_credits`); ``None`` resolves
on ``penalty_from`` alone, as before earned_time 0.6. on ``penalty_from`` alone, as before earned_time 0.6.
@ -102,7 +103,7 @@ class DayInputs:
def derive(inputs: DayInputs) -> Target: 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 day = inputs.day
answers: dict[str, int | bool | None] = dict(inputs.flat) answers: dict[str, int | bool | None] = dict(inputs.flat)
answers[earned_time.WORKOUT.name] = workout_units( answers[earned_time.WORKOUT.name] = workout_units(
@ -118,16 +119,11 @@ def derive(inputs: DayInputs) -> Target:
if starts is None if starts is None
else earned_time.resolve(answers, day, earners, first_credits=starts, sick=sick) 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( return Target(
resolution=resolution, resolution=resolution,
rest_day=inputs.rest_day, rest_day=inputs.rest_day,
first_done_at=inputs.first_done, first_done_at=inputs.first_done,
grace=grace, minutes=resolution.shutdown_minutes,
minutes=minutes,
) )

View File

@ -23,6 +23,7 @@ from screen_locker._day import today_str
from screen_locker._earned import extra_minutes from screen_locker._earned import extra_minutes
from screen_locker._rest_day import is_rest_day from screen_locker._rest_day import is_rest_day
from screen_locker._weekly_check import COUNTED_WORKOUT_TYPES, count_weekly_workouts from screen_locker._weekly_check import COUNTED_WORKOUT_TYPES, count_weekly_workouts
from screen_locker._workout_ledger import entry_done_at
if TYPE_CHECKING: if TYPE_CHECKING:
from pathlib import Path from pathlib import Path
@ -66,12 +67,22 @@ class WorkoutCreditMixin:
workout_data: dict[str, str] workout_data: dict[str, str]
log_file: Path 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: def _try_adjust_shutdown_for_workout(self) -> bool:
"""Try to adjust shutdown time later for actual workouts.""" """Try to adjust shutdown time later for actual workouts."""
workout_type = self.workout_data.get("type", "") workout_type = self.workout_data.get("type", "")
if workout_type not in COUNTED_WORKOUT_TYPES: if workout_type not in COUNTED_WORKOUT_TYPES:
return False 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: if adjusted:
_logger.info("Shutdown time moved later as workout reward") _logger.info("Shutdown time moved later as workout reward")
return adjusted return adjusted
@ -141,7 +152,7 @@ class WorkoutCreditMixin:
slot is CreditSlot.FURTHER slot is CreditSlot.FURTHER
and extra and extra
and before 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() new_cfg = self._read_shutdown_config()
if new_cfg: if new_cfg:

View File

@ -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

View File

@ -28,6 +28,7 @@ from screen_locker.tests._gatelock_fixtures import (
dual_output, dual_output,
) )
from screen_locker.tests._isolated_state import ISOLATED_STATE 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 ( from screen_locker.tests._locker_factories import (
_make_locker, _make_locker,
create_locker, create_locker,
@ -47,15 +48,15 @@ from screen_locker.tests._tk_mocks import (
_make_mock_tk, _make_mock_tk,
) )
# Re-exported so pytest still resolves these by name: a fixture defined in a # Re-exported: a sibling module's fixture is visible to tests only once
# sibling module is only visible to tests once conftest imports it. __all__ # conftest imports it, and __all__ keeps ruff from pruning them as unused.
# keeps ruff from pruning them as unused.
__all__ = [ __all__ = [
"FAKE_OUTPUTS", "FAKE_OUTPUTS",
"TWO_OUTPUTS", "TWO_OUTPUTS",
"_hermetic_gatelock", "_hermetic_gatelock",
"_make_locker", "_make_locker",
"_mock_sys_exit", "_mock_sys_exit",
"_untimed_credits_land_at_noon",
"create_locker", "create_locker",
"create_locker_early_bird", "create_locker_early_bird",
"create_locker_relaxed_day", "create_locker_relaxed_day",

View File

@ -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 from __future__ import annotations
@ -10,23 +10,20 @@ from typing import TYPE_CHECKING, Any
from unittest.mock import patch from unittest.mock import patch
import earned_time 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 ( from screen_locker._grace_floor import (
GraceFloorMixin,
first_done_at, first_done_at,
grace_for,
plan_absorb, plan_absorb,
save_lift, save_lift,
workout_done_at, workout_done_at,
) )
from screen_locker.tests._sick_helpers import mark_sick
if TYPE_CHECKING: if TYPE_CHECKING:
from pathlib import Path from pathlib import Path
import pytest
_DAY = date(2026, 10, 10) _DAY = date(2026, 10, 10)
_ISO = _DAY.isoformat() _ISO = _DAY.isoformat()
_FLAT = {"leetcode": False, "reading": False, "anki": False, "tutor": False} _FLAT = {"leetcode": False, "reading": False, "anki": False, "tutor": False}
@ -99,18 +96,6 @@ class TestWhenTheDayStarted:
assert first_done_at(_DAY, None) is None 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: class TestLift:
def test_absorb_pays_from_the_lift_first(self) -> None: def test_absorb_pays_from_the_lift_first(self) -> None:
with patch.object(_grace_floor, "today_str", return_value=_ISO): with patch.object(_grace_floor, "today_str", return_value=_ISO):
@ -139,71 +124,3 @@ class TestLift:
with caplog.at_level(logging.WARNING): with caplog.at_level(logging.WARNING):
save_lift(5, tmp_path / "missing-dir" / "g.json") save_lift(5, tmp_path / "missing-dir" / "g.json")
assert "Could not record the grace lift" in caplog.text 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)

View File

@ -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")

View File

@ -3,12 +3,14 @@
from __future__ import annotations from __future__ import annotations
from datetime import date, datetime, time
import pathlib import pathlib
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest import pytest
from screen_locker._day import today_str
from screen_locker._log_mixin import RecordResult from screen_locker._log_mixin import RecordResult
from screen_locker.tests.conftest import create_locker from screen_locker.tests.conftest import create_locker
@ -27,6 +29,13 @@ if TYPE_CHECKING:
from pathlib import Path 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") @pytest.mark.usefixtures("pre_ladder")
class TestApplyWorkoutCredit: class TestApplyWorkoutCredit:
"""Tests for ScreenLocker._apply_workout_credit under the multi-per-day rule. """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``. ``test_additional_verified_workout_earns_plus_one_hour``.
""" """
locker = create_locker(mock_tk, tmp_path) 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"}}] prior = [{"workout_data": {"type": "manual_workout"}}]
object.__setattr__( object.__setattr__(
locker, locker,
@ -121,7 +130,9 @@ class TestApplyWorkoutCredit:
assert result.shutdown_adjusted is False assert result.shutdown_adjusted is False
assert result.extra_bonus_delta == 60 assert result.extra_bonus_delta == 60
locker._try_adjust_shutdown_for_workout.assert_not_called() 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( def test_additional_verified_workout_earns_plus_one_hour(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path 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.shutdown_adjusted is False
assert result.extra_bonus_delta == 60 assert result.extra_bonus_delta == 60
locker._try_adjust_shutdown_for_workout.assert_not_called() 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)

View File

@ -114,14 +114,15 @@ class TestUnlockScreenShutdownAdjustment:
"""Test unlock_screen adjusts shutdown for phone-verified workout.""" """Test unlock_screen adjusts shutdown for phone-verified workout."""
locker = create_locker(mock_tk, tmp_path) locker = create_locker(mock_tk, tmp_path)
locker.log_file = tmp_path / "log.json" 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__( object.__setattr__(
locker, "_adjust_shutdown_time_later", MagicMock(return_value=True) locker, "_adjust_shutdown_time_later", MagicMock(return_value=True)
) )
locker.unlock_screen() 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( def test_unlock_screen_skips_adjustment_for_sick_day(
self, self,

View File

@ -41,7 +41,7 @@ class TestUnlockScreenExtras:
): ):
locker.unlock_screen() 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( def test_extra_bonus_delta_displayed(
self, self,

View File

@ -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)`` 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 -- 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 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 pass (first workout, further workouts, flat gates, counted gate blocks, each
floor) over :class:`ShutdownMixin` with an in-memory config. 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 Every gate maturity state is covered: ``first_credits`` omitted (0.5.0
``penalty_from``), empty (every earner on its ``confirmed_on``), never paid ``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 __future__ import annotations
from datetime import date, datetime, time, timedelta from datetime import date, datetime, time, timedelta
from functools import partial
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from unittest.mock import patch from unittest.mock import patch
import earned_time import earned_time
import pytest 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 ceiling, extra_minutes, first_minutes, is_gate
from screen_locker._earned import registry as registry_for from screen_locker._earned import registry as registry_for
from screen_locker._gate_bonus import apply_counted_bonus 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.""" """Reset with nothing done, then every credit as the live pass applies it."""
_, rest, workouts, first = case _, rest, workouts, first = case
host = _Host(derive(_inputs(day, case, done=False)).minutes) host = _Host(derive(_inputs(day, case, done=False)).minutes)
first_done = _at(day, first) # Every credit lands at ``first`` (09:00 when "none"): at 22:30 they are
if first_done is not None: # late credits (_late_credit), which must still land on the ceiling.
host._apply_grace_floor(None, first_done=first_done) 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 # 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). # is a further unit (_workout_credit's rest_paid rule).
further = workouts if rest else workouts - 1 further = workouts if rest else workouts - 1
if not rest: if not rest:
host._adjust_shutdown_time_later() host._adjust_shutdown_time_later(at=when)
extra = extra_minutes(earned_time.WORKOUT, day) extra = extra_minutes(earned_time.WORKOUT, day)
for _ in range(further if extra else 0): 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] = {} state: dict[str, Any] = {}
for gate in (e for e in registry_for(day) if is_gate(e)): for gate in (e for e in registry_for(day) if is_gate(e)):
if gate.kind == "flat": 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 continue
for units in range(1, (gate.max_units or 1) + 1): for units in range(1, (gate.max_units or 1) + 1):
with patch.object(_gate_bonus, "earned_units", return_value=units): with patch.object(_gate_bonus, "earned_units", return_value=units):
apply_counted_bonus( 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] return host.config[1]
@ -178,8 +182,10 @@ def frozen_day() -> Iterator[Callable[[date], Any]]:
def at_day(day: date) -> Any: def at_day(day: date) -> Any:
iso = day.isoformat() iso = day.isoformat()
return patch.multiple(_shutdown, today_str=lambda: iso), patch.multiple( return (
_grace_floor, today_str=lambda: iso, is_sick=lambda _d: False 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 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) case = (maturity, rest, workouts, first)
misses = [] misses = []
for day in _DAYS: for day in _DAYS:
shutdown_patch, grace_patch = frozen_day(day) shutdown_patch, grace_patch, late_patch = frozen_day(day)
with shutdown_patch, grace_patch: with shutdown_patch, grace_patch, late_patch:
got = _live_pass(day, case) got = _live_pass(day, case)
_grace_floor.GRACE_STATE_FILE.unlink(missing_ok=True) _grace_floor.GRACE_STATE_FILE.unlink(missing_ok=True)
if got != ceiling(day): if got != ceiling(day):

View File

@ -89,7 +89,7 @@ class TestFlatBonuses:
state = tmp_path / "state.json" state = tmp_path / "state.json"
mixin = _mixin() 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()) == {"reading_bonus_date": today_str()} assert json.loads(state.read_text()) == {"reading_bonus_date": today_str()}
@ -118,7 +118,7 @@ class TestANewlyRegisteredEarner:
mixin = _mixin() mixin = _mixin()
apply_flat_bonuses_if_new(state, mixin) apply_flat_bonuses_if_new(state, 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()} 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: def test_its_ledger_is_read_like_any_other(self, tmp_path: Path) -> None:
@ -126,7 +126,7 @@ class TestANewlyRegisteredEarner:
mixin = _mixin() mixin = _mixin()
with signing_key(tmp_path): with signing_key(tmp_path):
apply_flat_bonuses_if_new(tmp_path / "state.json", mixin) 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( def test_a_penalised_earner_lowers_the_base_from_its_day(
self, monkeypatch: pytest.MonkeyPatch self, monkeypatch: pytest.MonkeyPatch
@ -149,8 +149,7 @@ class TestRealLedgers:
@pytest.mark.usefixtures("pre_ladder") @pytest.mark.usefixtures("pre_ladder")
def test_signed_credits_on_disk_reach_the_reset(self, tmp_path: Path) -> None: def test_signed_credits_on_disk_reach_the_reset(self, tmp_path: Path) -> None:
"""No mock between the ledgers and the written hour.""" """No mock between the ledgers and the written hour."""
# Pinned to local midnight: stamped "now", the grace floor (first # Pinned to local midnight, so the credits are never late ones.
# credit + GRACE_MINUTES) beats base+120 whenever this runs after ~19:00.
midnight = datetime.now(tz=UTC).astimezone().replace(hour=0, minute=0, second=0) 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.LEETCODE, [credit(earned_time.LEETCODE, midnight)])
write_ledger(earned_time.READING, [credit(earned_time.READING, midnight)]) write_ledger(earned_time.READING, [credit(earned_time.READING, midnight)])
@ -169,6 +168,18 @@ class TestRealLedgers:
"reading_bonus_date": today_str(), "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: class TestProjectionFollowsTheCut:
"""Every projected row is priced on its own date, not on today's base.""" """Every projected row is priced on its own date, not on today's base."""

View File

@ -95,7 +95,7 @@ class TestLivePass:
mixin = _mixin() mixin = _mixin()
assert apply_leetcode_bonus_if_new(state, mixin) is True assert apply_leetcode_bonus_if_new(state, mixin) is True
assert apply_leetcode_bonus_if_new(state, mixin) is False 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()) == { assert json.loads(state.read_text()) == {
"last_reset_date": today_str(), "last_reset_date": today_str(),
"leetcode_bonus_date": today_str(), "leetcode_bonus_date": today_str(),

View File

@ -185,7 +185,7 @@ class TestReadingLivePass:
mixin = _mixin() mixin = _mixin()
assert apply_reading_bonus_if_new(state, mixin) is True assert apply_reading_bonus_if_new(state, mixin) is True
assert apply_reading_bonus_if_new(state, mixin) is False 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()) == { assert json.loads(state.read_text()) == {
"last_reset_date": today_str(), "last_reset_date": today_str(),
"reading_bonus_date": today_str(), "reading_bonus_date": today_str(),

View File

@ -48,16 +48,16 @@ class TestTarget:
assert workout_units(2, rest_day=True) == 2 assert workout_units(2, rest_day=True) == 2
assert workout_units(0, rest_day=False) == 0 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( target = derive(
DayInputs(_DAY, {**_FLAT, "anki": True}, first_done=at("18:55")) DayInputs(_DAY, {**_FLAT, "anki": True}, first_done=at("18:55"))
) )
assert target.minutes == max(target.earned, 19 * 60 + 55) assert target.minutes == target.earned
assert target.lift == 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)) 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.resolution.sick is True
assert target.minutes == SICK_FLOOR + sum( assert target.minutes == SICK_FLOOR + sum(
t.gaming_minutes for t in target.resolution.terms t.gaming_minutes for t in target.resolution.terms
@ -112,5 +112,5 @@ class TestTarget:
target = gather(_DAY, log) target = gather(_DAY, log)
assert target.resolution.term("workout").answer == 1 assert target.resolution.term("workout").answer == 1
assert target.first_done_at == at("18:00") assert target.first_done_at == at("18:00")
assert target.grace == 19 * 60 assert target.minutes == target.earned
assert not target.rest_day assert not target.rest_day

View File

@ -89,7 +89,7 @@ class SimShutdown(ShutdownMixin):
self.config = (*clamp, morning_end) self.config = (*clamp, morning_end)
return True 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. """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 ``sick`` is not an earner: it marks the (temporary) pool and runs the
@ -99,12 +99,11 @@ class SimShutdown(ShutdownMixin):
if name == SICK_STEP: if name == SICK_STEP:
sick_pool(day, sick_day=True) sick_pool(day, sick_day=True)
elif name == earned_time.WORKOUT.name: elif name == earned_time.WORKOUT.name:
self._adjust_shutdown_time_later() self._adjust_shutdown_time_later(at=at)
else: 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() 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. # The --sequence step that marks the day sick instead of crediting an earner.

View File

@ -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``, ``/etc``, ``shutdown_base.json``, ``grace_floor.json``, ``rest_days.json``,
``log.json`` or the real workout ledger. Simulations run the real code paths ``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 (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 lift bookkeeping, a temporary ``sickdays`` pool) against a temporary
directory, with an in-memory shutdown config. directory, with an in-memory shutdown config.
@ -33,7 +33,7 @@ import tempfile
import earned_time 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._constants import SHUTDOWN_CONFIG_FILE
from screen_locker._day import today_str from screen_locker._day import today_str
from screen_locker._earned import hhmm from screen_locker._earned import hhmm
@ -74,11 +74,10 @@ def _report(label: str, target: Target) -> None:
if target.first_done_at if target.first_done_at
else "-" 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"[{label}] {res.day} base {hhmm(res.base.shutdown_minutes)} | {terms}")
_say( _say(
f" rest_day={target.rest_day} sick={res.sick} earned={hhmm(target.earned)} " 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)}" f"-> SHUTDOWN {hhmm(target.minutes)}"
) )
@ -118,9 +117,15 @@ def _simulate(args: argparse.Namespace, tmp: Path) -> None:
_report("simulated", target) _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: def _sequence(args: argparse.Namespace, tmp: Path) -> None:
day = date.fromisoformat(args.day) 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)["today_str"] = lambda: args.day
vars(module)["GRACE_STATE_FILE"] = tmp / "grace_floor.json" vars(module)["GRACE_STATE_FILE"] = tmp / "grace_floor.json"
use_temp_pool(tmp) use_temp_pool(tmp)
@ -128,7 +133,11 @@ def _sequence(args: argparse.Namespace, tmp: Path) -> None:
start = derive(DayInputs(day, done)) start = derive(DayInputs(day, done))
sim = SimShutdown(start.minutes) sim = SimShutdown(start.minutes)
_say(f"[sequence] {day} start {hhmm(start.minutes)} (nothing done, not sick)") _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(","): for step in args.sequence.split(","):
name, _, at = step.partition("@") name, _, at = step.partition("@")
if name == earned_time.WORKOUT.name: if name == earned_time.WORKOUT.name:
@ -137,10 +146,18 @@ def _sequence(args: argparse.Namespace, tmp: Path) -> None:
done[name] = True done[name] = True
if name != SICK_STEP: if name != SICK_STEP:
first = min(first or stamp(day, at), stamp(day, at)) 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( want = derive(
DayInputs(day, done, workouts, first_done=first, sick_day=is_sick(day)) DayInputs(day, done, workouts, first_done=first, sick_day=is_sick(day))
) )
# 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" verdict = "" if sim.config[0] == want.minutes else " MISMATCH"
_say( _say(
f" {name}@{at}: config {hhmm(sim.config[0])} " f" {name}@{at}: config {hhmm(sim.config[0])} "