mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 18:07:22 +01:00
A manual workout may be dated today..today+7 days (no past dates). A future entry is held out of log.json and earns nothing until its date; the first sync/lock/reset pass on that day ingests and credits it like a workout logged that day. - ingest_manual_records defers records dated after the local day and counts the budget (2/7d, 10/30d) against the workout's own date over logged credits plus pending future records (a pending record never rejects itself) - PC-logged future entries (Tk dialog, --log-manual-workout --date) go to a durable pending ledger re-published on every push (a one-shot publish to devices/pc is overwritten by the next push); tombstones win, entries leave the ledger once logged or deleted - one shared date helper (validate_workout_date) used by the dialog and CLI; the form's date field rides in the sport row to keep the lock screen fitting - poke start_the_day also ingests due local entries after the daily reset - PC admission rule pinned to the phone's by the shared budget fixture (planned.expected_by_date) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015mmTBiMpvyfLuEAXc6nDYT
212 lines
8.4 KiB
Python
212 lines
8.4 KiB
Python
"""Headless manual-workout logging: same evidence rules, no UI.
|
|
|
|
The phone form was the only way to log a manual workout, which made logging
|
|
one depend on the phone's foreground: another app stealing focus mid-entry
|
|
could interleave or drop a field. That is a bad property for a record that
|
|
consumes budget, so this offers the same thing as a command.
|
|
|
|
What it deliberately is NOT: a way to log a workout without evidence. Every
|
|
field the form demands is demanded here, checked by the SAME
|
|
``validate_manual_workout``, and a missing or too-short field refuses the
|
|
whole run and writes nothing. ``log_manual_workout.py`` was deleted because
|
|
it bypassed those checks; this routes through the ordinary sync-ingest path
|
|
(``ingest_manual_records``), so validation, the rolling budget, HMAC signing
|
|
and workout_id dedup are the shared implementation rather than a second copy
|
|
that can drift.
|
|
|
|
``--date`` may name a FUTURE day (up to 7 days ahead; past dates are refused).
|
|
Such an entry is not ingested: it is held and published by
|
|
``_manual_schedule`` and ingested -- and credited -- on its date by the
|
|
ordinary sync pass.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import logging
|
|
from typing import TYPE_CHECKING
|
|
|
|
from screen_locker._day import today_str
|
|
from screen_locker._manual_future import is_future_date, validate_workout_date
|
|
from screen_locker._manual_schedule import budget_error, schedule_future_workout
|
|
from screen_locker._manual_sync import ingest_manual_records
|
|
from screen_locker._manual_workout import (
|
|
SPORT_CHOICES,
|
|
SPORT_OTHER,
|
|
ManualWorkoutDraft,
|
|
build_sync_payload,
|
|
manual_sync_record_id,
|
|
validate_manual_workout,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Sequence
|
|
from pathlib import Path
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
__all__ = ["add_manual_arguments", "run_manual_log"]
|
|
|
|
# (flag, payload key, default). Defaults mirror ManualWorkoutDraft's own, so
|
|
# "not supplied" means the same thing headlessly as it does on the form --
|
|
# pain_or_injury is "none" (an answer) rather than "" (never asked).
|
|
_OPTIONAL_TEXT = (
|
|
("reservation_phone", "reservation_phone", ""),
|
|
("techniques", "techniques_practiced", ""),
|
|
("warm_up", "warm_up_minutes", ""),
|
|
("pain_or_injury", "pain_or_injury", "none"),
|
|
("racket", "racket", ""),
|
|
("balls", "balls", ""),
|
|
("equipment", "equipment", ""),
|
|
)
|
|
_OPTIONAL_INT = (
|
|
("matches_won", "matches_won"),
|
|
("matches_lost", "matches_lost"),
|
|
("sets_won", "sets_won"),
|
|
("sets_lost", "sets_lost"),
|
|
)
|
|
|
|
|
|
def add_manual_arguments(parser: argparse.ArgumentParser) -> None:
|
|
"""Register every manual-workout field as a flag on ``parser``.
|
|
|
|
No flag carries a substantive default: the point is that the caller
|
|
supplies the evidence, so an omitted field must fail validation rather
|
|
than be quietly filled in with something plausible.
|
|
"""
|
|
parser.add_argument("--sport", choices=sorted(SPORT_CHOICES), required=True)
|
|
parser.add_argument(
|
|
"--activity",
|
|
default="",
|
|
help=f"what the activity was; required when --sport {SPORT_OTHER}",
|
|
)
|
|
parser.add_argument(
|
|
"--date",
|
|
default="",
|
|
help=(
|
|
"ISO date, today up to 7 days ahead (no past dates); defaults to "
|
|
"today. A future date is held and credited automatically on it."
|
|
),
|
|
)
|
|
parser.add_argument("--start", required=True, help="HH:MM")
|
|
parser.add_argument("--end", required=True, help="HH:MM")
|
|
parser.add_argument("--location", required=True)
|
|
parser.add_argument("--transport", required=True, help="how you got there")
|
|
parser.add_argument("--cost", required=True, help='e.g. "60 PLN" or "none"')
|
|
parser.add_argument("--rpe", required=True, type=int, help="1-10")
|
|
parser.add_argument("--details", required=True, help="what was done")
|
|
parser.add_argument("--went-well", required=True)
|
|
parser.add_argument("--to-improve", required=True)
|
|
parser.add_argument("--feeling", required=True, help="overall feeling")
|
|
for flag, _key, default in _OPTIONAL_TEXT:
|
|
parser.add_argument(f"--{flag.replace('_', '-')}", default=default)
|
|
for flag, _key in _OPTIONAL_INT:
|
|
parser.add_argument(f"--{flag.replace('_', '-')}", default=0, type=int)
|
|
|
|
|
|
def _draft(args: argparse.Namespace) -> ManualWorkoutDraft:
|
|
"""Build the draft from parsed flags, with no substantive defaults.
|
|
|
|
Optional fields fall back to the dataclass's own defaults so the headless
|
|
path and the form agree on what "not supplied" means.
|
|
"""
|
|
return ManualWorkoutDraft(
|
|
sport=args.sport,
|
|
start_time=args.start,
|
|
end_time=args.end,
|
|
location_name=args.location,
|
|
transport_method=args.transport,
|
|
cost=args.cost,
|
|
rpe=args.rpe,
|
|
went_well=args.went_well,
|
|
to_improve=args.to_improve,
|
|
overall_feeling=args.feeling,
|
|
activity_type_other=args.activity if args.sport == SPORT_OTHER else "",
|
|
activity_details=args.details,
|
|
**{key: getattr(args, flag) for flag, key, _default in _OPTIONAL_TEXT},
|
|
**{key: getattr(args, flag) for flag, key in _OPTIONAL_INT},
|
|
)
|
|
|
|
|
|
def _schedule(log_file: Path, draft: ManualWorkoutDraft, date: str) -> int:
|
|
"""Hold a FUTURE-dated workout and publish it; exit code for the CLI.
|
|
|
|
Never written to log.json or signed here: it is ingested by the ordinary
|
|
sync pass on its date.
|
|
"""
|
|
scheduled = schedule_future_workout(log_file, draft, date)
|
|
if not scheduled.scheduled:
|
|
_logger.error(
|
|
"Manual workout %s was NOT scheduled — %s",
|
|
scheduled.record_id,
|
|
scheduled.reason,
|
|
)
|
|
return 1
|
|
_logger.info("Manual workout %s: %s", scheduled.record_id, scheduled.reason)
|
|
return 0
|
|
|
|
|
|
def _log_today(log_file: Path, draft: ManualWorkoutDraft, date: str, start: str) -> int:
|
|
"""Log today's workout through the ordinary ingest; exit code for the CLI."""
|
|
record_id = manual_sync_record_id(date, start)
|
|
payload = build_sync_payload(draft, date)
|
|
# Today's entry takes the ordinary path, but its budget now also counts
|
|
# pending future entries (a window containing today contains tomorrow too).
|
|
over_budget = budget_error(log_file, date)
|
|
if over_budget is not None:
|
|
_logger.error("Manual workout %s was NOT logged — %s", record_id, over_budget)
|
|
return 1
|
|
|
|
ingested = ingest_manual_records(log_file, [(record_id, payload)], today=date)
|
|
if not ingested:
|
|
_logger.error(
|
|
"Manual workout %s was NOT logged — see the reason logged above "
|
|
"(invalid field, exhausted budget, or already logged)",
|
|
record_id,
|
|
)
|
|
return 1
|
|
_logger.info(
|
|
"Logged manual workout %s. It publishes to the other devices on the "
|
|
"next sync pass (workout-sync.timer, every 15 minutes); run "
|
|
"--sync-only to push it now.",
|
|
record_id,
|
|
)
|
|
return 0
|
|
|
|
|
|
def run_manual_log(log_file: Path, argv: Sequence[str]) -> int:
|
|
"""Log one manual workout headlessly. Returns a process exit code.
|
|
|
|
Non-zero means nothing was written: ``ingest_manual_records`` refuses a
|
|
draft that fails validation or that the rolling budget has no room for,
|
|
and it already logs the concrete reason, so a caller never has to guess
|
|
between "rejected" and "already logged".
|
|
"""
|
|
parser = argparse.ArgumentParser(
|
|
prog="screen-locker --log-manual-workout",
|
|
description="Log a manual workout without opening any app.",
|
|
)
|
|
add_manual_arguments(parser)
|
|
args = parser.parse_args(list(argv))
|
|
|
|
# The LOCAL day, like every other day-key (screen_locker._day): the old
|
|
# UTC default filed a workout logged at 00:02 CEST under yesterday, and the
|
|
# lock check then could not find it "today".
|
|
date = args.date or today_str()
|
|
draft = _draft(args)
|
|
# Validated here as well as inside the ingest, purely so a person running
|
|
# this by hand gets the concrete field error on stderr and a non-zero exit
|
|
# instead of only a log line. The ingest re-checks it regardless.
|
|
error = validate_manual_workout(draft)
|
|
if error is not None:
|
|
_logger.error("Manual workout REFUSED — %s. Nothing was written.", error)
|
|
return 1
|
|
|
|
date_error = validate_workout_date(date)
|
|
if date_error is not None:
|
|
_logger.error("Manual workout REFUSED — %s. Nothing was written.", date_error)
|
|
return 1
|
|
if is_future_date(date):
|
|
return _schedule(log_file, draft, date)
|
|
return _log_today(log_file, draft, date, args.start)
|