screen-locker/screen_locker/_shutdown_target.py
Krzysztof kuhy Rudnicki 46393fa866
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
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)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-17 18:14:04 +02:00

156 lines
5.6 KiB
Python

"""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::
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
earns. :func:`gather` reads today's real inputs; it writes nothing.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
import earned_time
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
from screen_locker._weekly_check import count_day_credits
if TYPE_CHECKING:
from collections.abc import Mapping
from datetime import date
from pathlib import Path
@dataclass(frozen=True)
class Target:
"""One day's derived shutdown and what made it.
Attributes:
resolution: ``earned_time``'s sum (base + terms, capped; on a sick
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.
minutes: The shutdown to write, minutes after midnight.
"""
resolution: earned_time.Resolution
rest_day: bool
first_done_at: float | None
minutes: int
@property
def earned(self) -> int:
"""The earned shutdown: base plus terms, before any floor."""
return self.resolution.shutdown_minutes - self.resolution.sick_lift_minutes
@property
def lift(self) -> int:
"""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.
"""
if self.resolution.sick:
return 0 # earned_time 0.13.0: credits stack on 20:00
return self.minutes - self.earned
def workout_units(count: int, *, rest_day: bool) -> int:
"""A rest day stands in for the first workout, never for a further one."""
return max(count, 1) if rest_day else count
@dataclass(frozen=True)
class DayInputs:
"""Everything one day's shutdown is derived from.
Attributes:
day: The day.
flat: Each gate earner's answer: done (flat) or units (counted, the
tutor); ``None`` = could not check.
workout_credits: The log's distinct workout credits.
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
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.
"""
day: date
flat: Mapping[str, int | bool | None]
workout_credits: int = 0
rest_day: bool = False
first_done: float | None = None
sick_day: bool = False
first_credits: Mapping[str, date | None] | None = None
def derive(inputs: DayInputs) -> Target:
"""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(
inputs.workout_credits, rest_day=inputs.rest_day
)
# The registry is passed explicitly: gate_answers iterated this same
# day's registry, so the earners asked and the earners summed match.
earners = registry(day)
starts = inputs.first_credits
sick = inputs.sick_day
resolution = (
earned_time.resolve(answers, day, earners, sick=sick)
if starts is None
else earned_time.resolve(answers, day, earners, first_credits=starts, sick=sick)
)
return Target(
resolution=resolution,
rest_day=inputs.rest_day,
first_done_at=inputs.first_done,
minutes=resolution.shutdown_minutes,
)
def day_credit_count(log_file: Path | None, day: date) -> int:
"""How many distinct workouts ``day``'s log counts (0 without a log)."""
if log_file is None:
return 0
iso = day.isoformat()
entries = load_workout_log(log_file).get(iso, [])
return count_day_credits(iso, [e for e in entries if isinstance(e, dict)])
def gather_inputs(day: date, log_file: Path | None) -> DayInputs:
"""Read ``day``'s real inputs: ledgers, log, rest days, sick pool."""
rest_day = is_rest_day(day)
return DayInputs(
day=day,
flat=gate_answers(day),
workout_credits=day_credit_count(log_file, day),
rest_day=rest_day,
first_done=first_done_at(day, log_file, rest_day=rest_day),
sick_day=is_sick(day),
first_credits=first_credits(registry(day), day),
)
def gather(day: date, log_file: Path | None) -> Target:
"""Derive ``day``'s target from the real ledgers, log and rest days."""
return derive(gather_inputs(day, log_file))