# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. """!co_ćwiczył: what screen_locker has on record for the operator today. "With all data" was the ask, so every field a workout carries is printed, one per line under the entry's heading, whatever the entry's shape -- a verified StrongLifts session has four fields and a padel evening has twenty, and the log gains new kinds without this file hearing about it. Three fields are held back on purpose. A reservation phone number is a phone number, and this repository's whole personal-data gate exists because one of those reached a chat once. A screenshot path is a path on the operator's disk, meaningless anywhere else. A sync record id is screen_locker's own bookkeeping. Everything else is the workout. """ from __future__ import annotations import datetime as dt import logging from typing import TYPE_CHECKING, Any, Final from signal_bot.animals import load_aliases from signal_bot.day_log import DayLogError, path_from_env, read_day from signal_bot.stats.periods import WARSAW, pl_day_month from signal_bot.stats.text import plural if TYPE_CHECKING: from pathlib import Path from signal_bot.context import CommandContext from signal_bot.decorators import Decorator from signal_bot.registry import CommandRegistry, Handler logger = logging.getLogger(__name__) COMMAND_NAME: Final = "workout_today" DESCRIPTION: Final = "co dziś ćwiczył operator bota, ze wszystkimi danymi" ENV_VAR: Final = "WORKOUT_LOG_FILE" DEFAULT_PATH: Final = "~/screen-locker/screen_locker/log.json" LIFTER: Final = "\U0001f3cb\ufe0f" UNAVAILABLE: Final = f"{LIFTER} Dziennik treningów jest dziś niedostępny." # Printed in the heading, so not repeated as fields. HEADING_FIELDS: Final = frozenset({"type", "source"}) # Never printed: a phone number, a local path, and another program's bookkeeping. WITHHELD: Final = frozenset({"reservation_phone", "proof_screenshot_path", "sync_record_id"}) def _clock(entry: dict[str, Any]) -> str: """When the entry was written, as Warsaw wall-clock time; screen_locker logs UTC.""" try: stamp = dt.datetime.fromisoformat(str(entry.get("timestamp", ""))) except ValueError: return "??:??" if stamp.tzinfo is None: stamp = stamp.replace(tzinfo=dt.UTC) return stamp.astimezone(WARSAW).strftime("%H:%M") def _fields(data: dict[str, Any]) -> list[str]: """Every field worth a line, in the order the log wrote them.""" lines = [] for key, value in data.items(): if key in HEADING_FIELDS or key in WITHHELD: continue shown = value.strip() if isinstance(value, str) else value if shown in ("", None): continue lines.append(f" {key.replace('_', ' ')}: {shown}") return lines def entry_lines(entry: dict[str, Any]) -> list[str]: """One workout: a heading with the time, the source and the kind, then its fields.""" data = entry.get("workout_data") data = data if isinstance(data, dict) else {} source = str(data.get("source") or "").strip() kind = str(data.get("type") or "?") heading = f"• {_clock(entry)} {source} [{kind}]" if source else f"• {_clock(entry)} [{kind}]" return [heading, *_fields(data)] def render(day: str, entries: list[dict[str, Any]]) -> str: """The day's workouts as one message, every field of every one.""" if not entries: return f"{LIFTER} {pl_day_month(day)} — brak treningu." count = len(entries) noun = plural(count, "wpis", "wpisy", "wpisów") head = f"{LIFTER} {pl_day_month(day)} — {count} {noun}" lines = [head] for entry in entries: lines.extend(entry_lines(entry)) return "\n".join(lines) def workout_handler(path: Path) -> Handler: """A handler that reads the log fresh on every call; the day's entry arrives after the run.""" async def handle(ctx: CommandContext) -> None: try: entries = read_day(path, ctx.day) except DayLogError as error: logger.warning("workout log unavailable: %s", error) await ctx.reply(UNAVAILABLE) return await ctx.reply(render(ctx.day, entries)) return handle def register_workout_command( registry: CommandRegistry, *, decorate: Decorator | None = None, aliases: dict[str, list[str]] | None = None, path: Path | None = None, ) -> None: """Register !co_ćwiczył under every spelling aliases.json gives it.""" table = load_aliases() if aliases is None else aliases handler = workout_handler(path or path_from_env(ENV_VAR, DEFAULT_PATH)) if decorate is not None: handler = decorate(handler) registry.register(COMMAND_NAME, table[COMMAND_NAME], handler, DESCRIPTION)