diet-guard/diet_guard/_manual_workout_parse.py
Krzysztof kuhy Rudnicki e7ef648757 Tag manual workouts with source manual
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 17:01:52 +01:00

116 lines
4.0 KiB
Python

"""Read a screen-locker manual workout's free text for sport and distance.
A manual entry's sport field is often a generic "other" while the typed text
says "Walking at Ai clearing..." -- and a distance, when there is one, lives
in the prose ("5 km", "5,2km", "3000 m"). Without this, a walk logged by
hand gets only the flat MET estimate even though the user typed its length.
Duration never comes from here: ``duration_minutes`` is a structured field.
"""
from __future__ import annotations
from datetime import datetime, timedelta
import re
from diet_guard._activity_kcal import Session
__all__ = ["logged_window", "manual_session"]
_WALK_RE = re.compile(r"\b(walk|walking|walked|hike|hiking|hiked)\b", re.IGNORECASE)
_RUN_RE = re.compile(r"\b(run|running|ran|jog|jogging|jogged)\b", re.IGNORECASE)
_DISTANCE_RE = re.compile(
r"(\d+(?:[.,]\d+)?)\s*(km|kilomet(?:er|re)s?|m|met(?:er|re)s?)\b",
re.IGNORECASE,
)
_METRES_PER_KM = 1000.0
_SPORT_KINDS = {
"running": "run",
"walking": "walk",
"hiking": "walk",
"cycling": "cycle",
"biking": "cycle",
"strength": "strength",
"gym": "strength",
}
def _text(item: dict[str, object]) -> str:
"""Every free-text field of the entry, joined."""
return " ".join(str(v) for v in item.values() if isinstance(v, str))
def _kind(item: dict[str, object], text: str) -> str:
"""The sport field if it is specific, else what the prose says."""
sport = str(item.get("activity_type") or item.get("sport") or "").lower()
if sport in _SPORT_KINDS:
return _SPORT_KINDS[sport]
if _RUN_RE.search(text):
return "run"
if _WALK_RE.search(text):
return "walk"
return "other"
def _typed_km(item: dict[str, object]) -> float | None:
"""A structured ``distance_km``, when present and numeric."""
raw = item.get("distance_km")
try:
return float(str(raw)) if raw is not None else None
except ValueError:
return None
def _text_km(text: str) -> float | None:
"""The first distance written in ``text``, in km, or None."""
match = _DISTANCE_RE.search(text)
if match is None:
return None
value = float(match.group(1).replace(",", "."))
in_km = match.group(2).lower().startswith("k")
return value if in_km else value / _METRES_PER_KM
def logged_window(stamp: object, minutes: float) -> tuple[str | None, str | None]:
"""``(start, end)`` for a workout logged at ``stamp`` as it finished.
screen-locker stamps a verified workout when it is logged, i.e. at its
end, so the start is ``minutes`` earlier. Unparsable stamp -> no window.
"""
try:
end = datetime.fromisoformat(str(stamp)).astimezone()
except ValueError:
return None, None
start = end - timedelta(minutes=minutes)
return start.isoformat(timespec="seconds"), end.isoformat(timespec="seconds")
def _clock_window(day: str, item: dict[str, object]) -> tuple[str | None, str | None]:
"""``(start, end)`` from a manual entry's ``HH:MM`` start/end fields."""
try:
start = datetime.fromisoformat(f"{day}T{item['start_time']}").astimezone()
end = datetime.fromisoformat(f"{day}T{item['end_time']}").astimezone()
except KeyError, ValueError:
return None, None
if end < start: # past midnight
end += timedelta(days=1)
return start.isoformat(timespec="seconds"), end.isoformat(timespec="seconds")
def manual_session(
day: str, item: dict[str, object], minutes: float, label: str
) -> Session:
"""The :class:`Session` one manual workout stands for.
Source is "manual" so the phone's activity sheet can paint the human-typed
label (``source == "manual"`` in Dart). Verified screen-locker workouts keep
source "screen-locker".
"""
text = _text(item)
kind = _kind(item, text)
km = _typed_km(item)
if km is None and kind in {"run", "walk"}:
km = _text_km(text)
start, end = _clock_window(day, item)
return Session(day, kind, minutes, km, label, "manual", start, end)