mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 21:07:14 +01:00
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
130 lines
4.7 KiB
Python
130 lines
4.7 KiB
Python
"""Simulation doubles for ``preview_shutdown.py``: real code, temporary state.
|
|
|
|
Split out to keep the script under the 250-line cap. Every class here runs
|
|
the production methods it inherits; only the I/O edges (the drop directory,
|
|
adb, the sudo-written config, the sickdays pool) point at a temporary place.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import date, datetime, time
|
|
import importlib
|
|
from typing import TYPE_CHECKING
|
|
|
|
import earned_time
|
|
import sickdays
|
|
|
|
from screen_locker._earned import first_minutes
|
|
from screen_locker._runnerup_verification import RunnerUpVerificationMixin
|
|
from screen_locker._shutdown import ShutdownMixin
|
|
from screen_locker._sick import is_sick
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
_NS = "http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2"
|
|
HELPER_CEILING = 23 * 60 # adjust_shutdown_schedule.sh's RESTORE_CEILING
|
|
|
|
|
|
def stamp(day: date, hhmm_text: str) -> float:
|
|
"""Unix time of ``HH:MM`` local on ``day``."""
|
|
hours, _, minutes = hhmm_text.partition(":")
|
|
moment = datetime.combine(day, time(int(hours), int(minutes))).astimezone()
|
|
return moment.timestamp()
|
|
|
|
|
|
def _iso(stamp: float) -> str:
|
|
return datetime.fromtimestamp(stamp).astimezone().isoformat()
|
|
|
|
|
|
def write_walk(folder: Path, day: date, start: str, minutes: int) -> None:
|
|
"""A RunnerUp-style walk export: Sport="Other", the sport in the name."""
|
|
begin = stamp(day, start)
|
|
points = "".join(
|
|
f"<Trackpoint><Time>{_iso(begin + s)}</Time>"
|
|
f"<DistanceMeters>{s * 1.3:.1f}</DistanceMeters></Trackpoint>"
|
|
for s in range(0, minutes * 60 + 1, 5)
|
|
)
|
|
stem = f"RunnerUp_Pixel_6a_{day}-{start.replace(':', '-')}-00_Walking.tcx"
|
|
(folder / stem).write_text(
|
|
f'<TrainingCenterDatabase xmlns="{_NS}"><Activities><Activity Sport="Other">'
|
|
f"<Id>{day}T{start}:00Z</Id><Lap><TotalTimeSeconds>{minutes * 60}"
|
|
f"</TotalTimeSeconds><DistanceMeters>{minutes * 78}</DistanceMeters>"
|
|
f"<Track>{points}</Track></Lap></Activity></Activities></TrainingCenterDatabase>",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
|
|
class SimRunnerUp(RunnerUpVerificationMixin):
|
|
"""The real backfill, reading only a temporary drop directory."""
|
|
|
|
def __init__(self, folder: Path) -> None:
|
|
self.folder = folder
|
|
|
|
def _has_adb_device(self) -> bool:
|
|
return False
|
|
|
|
def fill(self, day: str, log: Path) -> bool:
|
|
"""The real RunnerUp backfill for ``day`` into ``log``."""
|
|
return self._try_fill_runnerup_for_date(day, log)
|
|
|
|
def _find_runnerup_exports_for_date(self, date_str: str) -> list[str]:
|
|
return sorted(str(p) for p in self.folder.glob(f"*{date_str}*.tcx"))
|
|
|
|
|
|
class SimShutdown(ShutdownMixin):
|
|
"""The real add/absorb paths against an in-memory config."""
|
|
|
|
def __init__(self, minutes: int) -> None:
|
|
self.config = (minutes, minutes, 5 * 60)
|
|
|
|
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:
|
|
del restore
|
|
clamp = min(HELPER_CEILING, mon_wed), min(HELPER_CEILING, thu_sun)
|
|
self.config = (*clamp, morning_end)
|
|
return True
|
|
|
|
def credit(self, name: str, day: date, first_done: float | 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
|
|
live sick-floor pass, as the lock screen's button and the 5-minute
|
|
tick do.
|
|
"""
|
|
if name == SICK_STEP:
|
|
sick_pool(day, sick_day=True)
|
|
elif name == earned_time.WORKOUT.name:
|
|
self._adjust_shutdown_time_later()
|
|
else:
|
|
self._adjust_shutdown_time_by(first_minutes(earned_time.earner(name), day))
|
|
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.
|
|
SICK_STEP = "sick"
|
|
|
|
|
|
def use_temp_pool(tmp: Path) -> None:
|
|
"""Point every sickdays read and write at a pool under ``tmp``."""
|
|
pool = sickdays.Paths.under(tmp / "sickdays")
|
|
api = importlib.import_module("sickdays._api")
|
|
vars(api)["resolve_paths"] = lambda paths: paths or pool
|
|
|
|
|
|
def sick_pool(day: date, *, sick_day: bool) -> bool:
|
|
"""Mark ``day`` sick in the temp pool, read back through the real check.
|
|
|
|
Call :func:`use_temp_pool` first: this writes through sickdays' own
|
|
``add_sick_day``, with ``day`` as "today" and no free-day check.
|
|
"""
|
|
if sick_day and not is_sick(day):
|
|
sickdays.add_sick_day(day, is_free=lambda _day: False, now=day)
|
|
return is_sick(day)
|