screen-locker/screen_locker/_manual_future.py
Krzysztof kuhy Rudnicki e12b840f8c manual workout: log for future dates (credited on the day)
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
2026-10-16 15:34:15 +02:00

223 lines
7.8 KiB
Python

"""Future-dated manual workouts: the date rule and the per-date budget rule.
A manual workout may be logged AHEAD of time (``today .. today + 7 days``, never
a past date) and earns its normal credit on that date, not before. Two pure
rules live here, shared by every entry point (Tk dialog, CLI, sync ingest):
* :func:`validate_workout_date` -- the one 0..7-days-ahead check;
* :func:`budget_error_for_date` -- the rolling budget (2 per 7 days, 10 per 30)
counted against the WORKOUT's date, over logged credits AND pending future
entries, so a future entry holds its slot from the moment it is logged.
No I/O beyond reading ``log.json``; the pending ledger and the Firebase pull
are in ``_manual_pending`` / ``_manual_schedule``.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, date, datetime, timedelta
import logging
from typing import TYPE_CHECKING
from screen_locker._constants import (
MANUAL_WORKOUT_BUDGET_PER_7_DAYS,
MANUAL_WORKOUT_BUDGET_PER_30_DAYS,
MANUAL_WORKOUT_MAX_DAYS_AHEAD,
)
from screen_locker._day import today_str
from screen_locker._manual_workout_types import manual_credit_dates
if TYPE_CHECKING:
from collections.abc import Iterable
from pathlib import Path
__all__ = [
"BudgetUsage",
"budget_closed_for_all_days",
"budget_error_for_date",
"budget_usage_for_date",
"is_future_date",
"parse_workout_date",
"validate_workout_date",
]
_logger = logging.getLogger(__name__)
# (window length in days, cap) -- a window of N days is [start, start + N - 1].
_WINDOWS: tuple[tuple[int, int], ...] = (
(7, MANUAL_WORKOUT_BUDGET_PER_7_DAYS),
(30, MANUAL_WORKOUT_BUDGET_PER_30_DAYS),
)
def parse_workout_date(value: str) -> date | None:
"""Parse a strict ``YYYY-MM-DD`` string, or return None (and warn)."""
try:
return datetime.strptime(value.strip(), "%Y-%m-%d").replace(tzinfo=UTC).date()
except ValueError as exc:
_logger.warning(
"Date %r is not YYYY-MM-DD (%s) — treated as invalid", value, exc
)
return None
def is_future_date(value: str, *, today: str | None = None) -> bool:
"""True if ``value`` is a valid date strictly after today (local day)."""
parsed = parse_workout_date(value)
reference = parse_workout_date(today or today_str())
return parsed is not None and reference is not None and parsed > reference
def validate_workout_date(value: str, *, today: str | None = None) -> str | None:
"""Return an error message if ``value`` is not today..today+7 days, else None.
THE date rule for manual workouts: no past dates (a back-dated entry is the
easiest way to fake a missed week) and no more than
``MANUAL_WORKOUT_MAX_DAYS_AHEAD`` days ahead.
"""
parsed = parse_workout_date(value)
if parsed is None:
return "Workout date must be YYYY-MM-DD"
reference = parse_workout_date(today or today_str())
if reference is None:
return f"Today's date {today!r} is not YYYY-MM-DD"
ahead = (parsed - reference).days
if ahead < 0:
return (
f"Workout date {value.strip()} is in the past -- only today or up "
f"to {MANUAL_WORKOUT_MAX_DAYS_AHEAD} days ahead"
)
if ahead > MANUAL_WORKOUT_MAX_DAYS_AHEAD:
return (
f"Workout date {value.strip()} is {ahead} days ahead -- at most "
f"{MANUAL_WORKOUT_MAX_DAYS_AHEAD} days"
)
return None
def _ordinals(dates: Iterable[str]) -> list[int]:
"""Day ordinals of every parsable date; unparsable ones are skipped."""
days: list[int] = []
for value in dates:
parsed = parse_workout_date(value)
if parsed is not None:
days.append(parsed.toordinal())
return days
def _fullest_window(days: list[int], ordinal: int, length: int) -> tuple[int, int]:
"""Return ``(count, start)`` of the fullest ``length``-day window holding D.
Counts the entries in ``days`` only (the new entry is not among them), over
every window ``[start, start + length - 1]`` that contains ``ordinal``.
"""
return max(
(
(sum(1 for day in days if start <= day < start + length), start)
for start in range(ordinal - length + 1, ordinal + 1)
),
key=lambda found: found[0],
)
def _existing_days(log_file: Path, pending_dates: Iterable[str]) -> list[int]:
"""Ordinals of every logged credit and every pending future entry."""
return [*_ordinals(manual_credit_dates(log_file)), *_ordinals(pending_dates)]
@dataclass(frozen=True)
class BudgetUsage:
"""How full the fullest windows around one workout date already are."""
week: int
month: int
@property
def exhausted(self) -> bool:
"""True if one more entry on that date would break either cap."""
return (
self.week >= MANUAL_WORKOUT_BUDGET_PER_7_DAYS
or self.month >= MANUAL_WORKOUT_BUDGET_PER_30_DAYS
)
def budget_usage_for_date(
log_file: Path,
workout_date: str,
pending_dates: Iterable[str] = (),
) -> BudgetUsage | None:
"""Return the fullest 7- and 30-day windows containing ``workout_date``.
The PC's admission rule as numbers: the shared cross-language fixture
(``fixtures/manual_budget_merge.json``, ``planned.expected_by_date``) pins
these to what the phone computes. None if the date does not parse.
"""
target = parse_workout_date(workout_date)
if target is None:
return None
days = _existing_days(log_file, pending_dates)
ordinal = target.toordinal()
return BudgetUsage(
week=_fullest_window(days, ordinal, 7)[0],
month=_fullest_window(days, ordinal, 30)[0],
)
def budget_error_for_date(
log_file: Path,
workout_date: str,
pending_dates: Iterable[str] = (),
) -> str | None:
"""Return why a manual workout dated ``workout_date`` does not fit, else None.
Adding an entry dated D is allowed only if EVERY rolling window containing
D -- each 7-day window and each 30-day window -- stays within its cap
counting the credits already in ``log_file``, the pending future entries
in ``pending_dates``, and the new entry itself. Counting against D rather
than against today is what lets a full week still schedule next week, and
what keeps tomorrow's entry from being free of the cap.
``pending_dates`` must already exclude the entry being checked (an entry
that holds its slot would otherwise reject itself on its own date).
"""
target = parse_workout_date(workout_date)
if target is None:
return f"Workout date {workout_date!r} must be YYYY-MM-DD"
days = _existing_days(log_file, pending_dates)
for length, cap in _WINDOWS:
used, start = _fullest_window(days, target.toordinal(), length)
if used >= cap:
first = date.fromordinal(start)
last = first + timedelta(days=length - 1)
return (
f"Manual-workout budget: {used} already counted/scheduled "
f"between {first} and {last}, max {cap} per {length} days"
)
return None
def budget_closed_for_all_days(
log_file: Path,
pending_dates: Iterable[str] = (),
*,
today: str | None = None,
) -> bool:
"""True if no date in today..today+7 could take another manual workout.
The "Log Manual Workout" button hides only then; "today's window is full"
no longer closes it, since a later day may still have room.
"""
reference = parse_workout_date(today or today_str())
if reference is None:
_logger.warning("Today %r is not YYYY-MM-DD -- budget treated as closed", today)
return True
pending = list(pending_dates)
return all(
budget_error_for_date(
log_file, (reference + timedelta(days=offset)).isoformat(), pending
)
is not None
for offset in range(MANUAL_WORKOUT_MAX_DAYS_AHEAD + 1)
)