mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 17:47:23 +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
142 lines
5.0 KiB
Python
Executable File
142 lines
5.0 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Move ``sick_history.json``'s sick days into the shared ``sickdays`` pool, once.
|
|
|
|
screen-locker's old sick day lived in its own file, next to a budget, a
|
|
justification log, commitments and a debt counter -- all gone now. A sick day
|
|
is a date in the fleet-wide ``sickdays`` pool, nothing else, so only the
|
|
dates move (2026-06-21 and 2026-07-24 on kuhy's PC). Both are in the past,
|
|
which only ``sickdays.import_legacy`` may write.
|
|
|
|
Idempotent, so ``install.sh`` runs it on every install:
|
|
|
|
- no history file: nothing to do, exit 0;
|
|
- every date already sick in the pool: nothing is rewritten (no new HLC tick,
|
|
no audit line), the file is removed;
|
|
- otherwise the missing dates are imported, read back from the pool, and only
|
|
when every one is there is the file removed. Any failure leaves it in
|
|
place and exits 1, so the next run tries again.
|
|
|
|
scripts/migrate_sick_history.py --dry-run # show what would happen
|
|
scripts/migrate_sick_history.py # do it
|
|
|
|
``--history-file`` and ``--pool-dir`` point it at copies (for tests and
|
|
rehearsals); without ``--pool-dir`` the real pool is written.
|
|
|
|
Output goes through ``_report`` rather than ``print``: ruff's T201 fix is
|
|
unsafe and deletes the whole call.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
from datetime import date
|
|
import json
|
|
import logging
|
|
from pathlib import Path
|
|
import sys
|
|
|
|
import sickdays
|
|
|
|
DEFAULT_HISTORY_FILE = (
|
|
Path(__file__).resolve().parent.parent / "screen_locker" / "sick_history.json"
|
|
)
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class MigrationError(Exception):
|
|
"""The history could not be read or the pool did not take every date."""
|
|
|
|
|
|
def _report(message: str) -> None:
|
|
"""Write one line to stdout, un-deletable by an autofixer."""
|
|
sys.stdout.write(f"{message}\n")
|
|
|
|
|
|
def read_sick_days(path: Path) -> list[date]:
|
|
"""The ``sick_days`` dates in ``path``, ascending.
|
|
|
|
Raises:
|
|
MigrationError: The file is unreadable or holds something that is not
|
|
a list of ``YYYY-MM-DD`` dates.
|
|
"""
|
|
try:
|
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, ValueError) as exc:
|
|
msg = f"could not read {path}: {exc}"
|
|
raise MigrationError(msg) from exc
|
|
days = raw.get("sick_days", []) if isinstance(raw, dict) else None
|
|
if not isinstance(days, list):
|
|
msg = f"{path} has no list of sick_days"
|
|
raise MigrationError(msg)
|
|
try:
|
|
return sorted({date.fromisoformat(str(entry)) for entry in days})
|
|
except ValueError as exc:
|
|
msg = f"{path} holds a sick day that is not a YYYY-MM-DD date: {exc}"
|
|
raise MigrationError(msg) from exc
|
|
|
|
|
|
def migrate(
|
|
history: Path, paths: sickdays.Paths | None, *, dry_run: bool
|
|
) -> list[date]:
|
|
"""Import ``history``'s dates into the pool, verify, remove ``history``.
|
|
|
|
Returns:
|
|
The dates newly written (or, on a dry run, that would be).
|
|
|
|
Raises:
|
|
MigrationError: Unreadable history, a refusing pool, or a date that
|
|
is not in the pool after the import.
|
|
"""
|
|
days = read_sick_days(history)
|
|
log_path = paths.log if paths is not None else None
|
|
missing = [d for d in days if d not in sickdays.sick_days(log_path=log_path)]
|
|
if dry_run:
|
|
return missing
|
|
try:
|
|
written = sickdays.import_legacy(missing, paths=paths)
|
|
except sickdays.SickDayError as exc:
|
|
msg = f"the sickdays pool refused the import: {exc}"
|
|
raise MigrationError(msg) from exc
|
|
absent = sorted(set(days) - sickdays.sick_days(log_path=log_path))
|
|
if absent:
|
|
msg = f"not in the pool after the import: {', '.join(map(str, absent))}"
|
|
raise MigrationError(msg)
|
|
history.unlink()
|
|
return written
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
"""Run the migration. Returns 0 when done or nothing to do, 1 on failure."""
|
|
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
parser.add_argument("--history-file", type=Path, default=DEFAULT_HISTORY_FILE)
|
|
parser.add_argument("--pool-dir", type=Path, help="pool directory (default: real)")
|
|
parser.add_argument(
|
|
"--dry-run", action="store_true", help="show what would change, write nothing"
|
|
)
|
|
args = parser.parse_args(argv)
|
|
history: Path = args.history_file
|
|
if not history.exists():
|
|
_report(f"no {history} -- nothing to migrate")
|
|
return 0
|
|
paths = sickdays.Paths.under(args.pool_dir) if args.pool_dir else None
|
|
try:
|
|
days = migrate(history, paths, dry_run=args.dry_run)
|
|
except MigrationError as exc:
|
|
_logger.warning(
|
|
"sick-history migration failed, %s left in place: %s", history, exc
|
|
)
|
|
_report(f"FAILED: {exc} ({history} left in place)")
|
|
return 1
|
|
listed = ", ".join(map(str, days)) or "none"
|
|
if args.dry_run:
|
|
_report(f"would import: {listed}; {history} would be removed")
|
|
else:
|
|
_report(f"imported: {listed}; every sick day is in the pool; {history} removed")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
logging.basicConfig(level=logging.WARNING)
|
|
sys.exit(main())
|