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
223 lines
7.8 KiB
Python
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)
|
|
)
|