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
`_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

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

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

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

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

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._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)

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;
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))

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
(: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,
)

View File

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

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

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

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

View File

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

View File

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

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)``
-- 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):

View File

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

View File

@ -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(),

View File

@ -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(),

View File

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

View File

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

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``,
``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})"