screen-locker/screen_locker/_poke_credit.py
Krzysztof kuhy Rudnicki 7d8daca111 sick days: one shared sickdays pool, 20:00 shutdown floor, one-tap lock button
A sick day is now a date in the shared sickdays pool (utils sickdays 0.1.0)
and nothing else. Removed: the 7/30/90-day budget, the escalating lockout
countdown, the justification form, the commitment prompt and its double
debt, the workout-debt counter, the -60 min shutdown cut and its
sick_day_state.json, the post-sick-day --verify-workout mode, the sick
auto-upgrade, scripts/revoke_sick_day.py and their tests.

- Lock screen: "I'm sick" on the retry, no-phone and both Tue-Thu screens;
  one tap -> sickdays.add_sick_day(today) -> sick floor -> best-effort
  background sickdays.sync_quietly -> unlock. Refusals (free day,
  unreadable pool) are shown and the lock stays.
- Shutdown: the reset resolves with earned_time.resolve(..., sick=True)
  (max(20:00, ladder), capped 23:00) and saves the sick lift with the grace
  lift, so later credits absorb it instead of stacking on 20:00. A day
  marked after the reset is lifted by the new _sick_floor live pass (every
  5-min tick, --sync-only, --apply-bonuses, and the tap itself).
- Workout lock and grace floor stand down on a sick day; workouts that day
  credit normally through the sync paths.
- wake-alarm workday_penalty.json is void when its issued_date or
  penalty_date is a sick day.
- Status window, i3 line, CLI --status, MCP flags and web reasons read
  sickdays.
- scripts/migrate_sick_history.py imports sick_history.json's dates via
  sickdays.import_legacy, verifies and removes the file; idempotent, run by
  install.sh.
- Pins: earned-time 0.11.0, freedays 0.4.0, sickdays 0.1.0 (tags pending);
  pre-commit 4.7.0, ruff 0.17.1 (freshness gate).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xfj1sxKH9VBPqpcAni8Jry
2026-10-16 13:30:39 +02:00

208 lines
7.5 KiB
Python

"""Credit one poked StrongLifts session: the sync's ingest, minus the network.
The phone POSTs a finished session straight to the PC
(``docs/DOCS-workout-poke-contract.md``); this applies it through exactly the
path a synced session takes -- :func:`ingest_session_records`, which
re-validates and dedups under ``log.lock``, then the shared
:meth:`~screen_locker._workout_credit.WorkoutCreditMixin._apply_credit_for_written_entry`
(slot-based credit, shutdown push, the "Workout credited" notification).
Nothing here computes a reward of its own.
Each call builds a FRESH headless locker, the same object
``--sync-only`` uses, so no state leaks from one request into the next.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date
import logging
from typing import TYPE_CHECKING, Final, cast
from screen_locker._cli import _headless_locker
from screen_locker._constants import (
EXTRA_BENEFITS_FILE,
SHUTDOWN_BASE_FILE,
SHUTDOWN_CONFIG_FILE,
)
from screen_locker._credit_notify import gaming_minutes_today
from screen_locker._day import today_str
from screen_locker._earned import hhmm
from screen_locker._extra_benefits import process_week_transition
from screen_locker._grace_floor import tonight_minutes
from screen_locker._session_sync import ingest_session_records, validate_session
from screen_locker._shutdown import read_shutdown_config
from screen_locker._shutdown_base import reset_to_base_if_new_day
from screen_locker.screen_lock import ScreenLocker
if TYPE_CHECKING:
from collections.abc import Iterable, Mapping
from pathlib import Path
from screen_locker._workout_credit import WorkoutCreditResult
__all__ = [
"BatchOutcome",
"PokeOutcome",
"credit_session",
"credit_sessions",
"current_gaming_minutes",
"current_shutdown",
]
_logger: Final = logging.getLogger(__name__)
@dataclass(frozen=True)
class PokeOutcome:
"""What crediting one poked session did; ``reason`` is for the phone's UI."""
ok: bool
credited: bool
duplicate: bool
reason: str
@dataclass(frozen=True)
class BatchOutcome:
"""What one batch did: newly logged ids, and how many of those earned time."""
ingested: tuple[str, ...]
credited: int
class PokeLocker(ScreenLocker):
"""The headless locker plus the two steps a poke needs, as public calls."""
def start_the_day(self) -> None:
"""Run the new-day reset the locker's startup runs, in the same order.
Mirrors ``StartupChecksMixin._check_non_verify_exits``, not
``sync_now`` (which never resets): the week transition (streak,
early-bird window) first, then the daily reset.
"""
for reward in process_week_transition(self.log_file, EXTRA_BENEFITS_FILE):
_logger.info("Weekly reward: %s", reward)
reset_to_base_if_new_day(
SHUTDOWN_BASE_FILE,
self,
log_file=self.log_file,
)
def credit_written(
self, entry: dict[str, str], prior_entries: list[dict[str, object]]
) -> WorkoutCreditResult:
"""``SyncMixin._credit_ingested_workout``, keeping the result."""
self.workout_data = entry
return self._apply_credit_for_written_entry(prior_entries)
def fresh_locker() -> PokeLocker:
"""The ``--sync-only`` headless locker: no Tk, no window, no lock."""
return cast("PokeLocker", _headless_locker(PokeLocker))
def current_shutdown() -> str | None:
"""Tonight's shutdown from ``/etc/shutdown-schedule.conf`` as ``HH:MM``."""
config = read_shutdown_config(SHUTDOWN_CONFIG_FILE)
if config is None:
_logger.warning(
"Workout poke: shutdown config unreadable, replying shutdown=null"
)
return None
return hhmm(tonight_minutes(config, date.fromisoformat(today_str())))
def current_gaming_minutes(log_file: Path) -> int | None:
"""Today's gaming budget (``None`` and a warning when it cannot be summed)."""
return gaming_minutes_today(log_file)
def _precheck(record_id: str, payload: Mapping[str, object]) -> str | None:
"""Why the session can never count, or ``None`` if it can.
``ingest_session_records`` applies the same rules but only returns what
it ingested, so a rejection would be indistinguishable from a duplicate.
"""
day = payload.get("date")
if not isinstance(day, str) or not day:
return f"session {record_id} has no usable date ({day!r}), so it cannot count"
valid, detail = validate_session(payload)
if not valid:
return f"session {record_id} does not count: {detail}"
return None
def credit_session(record_id: str, payload: Mapping[str, object]) -> PokeOutcome:
"""Reset the day if new, then ingest and credit one session.
The caller serialises calls; the ingest also holds ``log.lock`` and the
credit the shutdown-state lock, so a concurrent sync or locker pass is safe.
"""
locker = fresh_locker()
locker.start_the_day()
problem = _precheck(record_id, payload)
if problem is not None:
_logger.warning("Workout poke not credited: %s", problem)
return PokeOutcome(ok=False, credited=False, duplicate=False, reason=problem)
results: list[WorkoutCreditResult] = []
def on_ingested(
entry: dict[str, str], prior_entries: list[dict[str, object]]
) -> None:
results.append(locker.credit_written(entry, prior_entries))
ingested = ingest_session_records(
locker.log_file, [(record_id, payload)], on_ingested=on_ingested
)
if not ingested or not results:
reason = (
f"session {record_id} is already in the PC's log (the sync or an "
"earlier poke got there first); nothing new to credit"
)
_logger.info("Workout poke duplicate: %s", reason)
return PokeOutcome(ok=True, credited=False, duplicate=True, reason=reason)
if results[0].already_counted_today:
reason = (
f"session {record_id} was logged, but today's slot for it was already "
"paid by another copy of the same workout; no extra time"
)
_logger.warning("Workout poke not credited: %s", reason)
return PokeOutcome(ok=True, credited=False, duplicate=False, reason=reason)
_logger.info("Workout poke credited session %s", record_id)
return PokeOutcome(
ok=True, credited=True, duplicate=False, reason="workout credited"
)
def credit_sessions(
records: Iterable[tuple[str, Mapping[str, object]]],
) -> BatchOutcome:
"""Reset the day if new, then ingest and credit a batch of synced sessions.
``SyncMixin._ingest_synced_sessions`` with one fresh locker for the whole
batch -- what the Firebase stream (:mod:`screen_locker._session_stream`)
runs per event. Already-logged ids cost one ``log.json`` read and write
nothing; invalid ones are warned about by the ingest itself.
"""
locker = fresh_locker()
locker.start_the_day()
results: list[WorkoutCreditResult] = []
def on_ingested(
entry: dict[str, str], prior_entries: list[dict[str, object]]
) -> None:
results.append(locker.credit_written(entry, prior_entries))
ingested = ingest_session_records(locker.log_file, records, on_ingested=on_ingested)
credited = sum(not result.already_counted_today for result in results)
if len(results) > credited:
_logger.warning(
"%d streamed session(s) were logged but earned no time: today's slot "
"was already paid by another copy of the same workout",
len(results) - credited,
)
return BatchOutcome(ingested=tuple(ingested), credited=credited)