signal-bot/signal_bot/workout_command.py
Krzysztof kuhy Rudnicki 77795ee6f8
Some checks are pending
CI / check (push) Waiting to run
CI / web (push) Waiting to run
Today's food and workout logs, read from where diet_guard and screen_locker keep them
Two commands, food_today and workout_today, each printing the current
Warsaw day out of a log another program owns: every meal with its time,
calories and source plus the day's total and macros; every workout with
every field it carries. Three workout fields are withheld (a reservation
phone number, a screenshot path, the sync record id), and the food budget
is never printed -- diet_guard keeps that number on the machine on purpose.

The spellings the group uses carry a surname, so they are custom aliases in
the state directory; aliases.json ships only plain words. The suite points
both logs at absent temp files so it can never read the operator's own,
and the e2e run writes one invented entry of each under today's date.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-04 22:07:46 +02:00

124 lines
4.7 KiB
Python

# 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)