workout-tracker / tracker /scheduler.py
ronheichman's picture
Deploy workout tracker
234891d verified
Raw
History Blame Contribute Delete
10.1 kB
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from pathlib import Path
from zoneinfo import ZoneInfo
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient.discovery import Resource, build
from tracker.config import load_config
SCOPES = ["https://www.googleapis.com/auth/calendar"]
CHICAGO_TZ = ZoneInfo("America/Chicago")
def get_calendar_service() -> Resource:
"""Build Google Calendar API service using stored OAuth credentials."""
cfg = load_config()
creds_path = Path(cfg.google_credentials_path)
token_path = Path(cfg.google_token_path)
creds: Credentials | None = None
if token_path.exists():
creds = Credentials.from_authorized_user_file(str(token_path), SCOPES)
if creds is None or not creds.valid:
if creds is not None and creds.expired and creds.refresh_token:
creds.refresh(Request())
else:
flow = InstalledAppFlow.from_client_secrets_file(str(creds_path), SCOPES)
creds = flow.run_local_server(port=0, open_browser=True)
token_path.write_text(creds.to_json())
return build("calendar", "v3", credentials=creds)
def list_events(
service: Resource,
calendar_id: str,
time_min: datetime,
time_max: datetime,
) -> list[dict[str, str]]:
"""List events from a calendar in the given time range with pagination."""
events: list[dict[str, str]] = []
page_token: str | None = None
while True:
result = (
service.events()
.list(
calendarId=calendar_id,
timeMin=time_min.isoformat(),
timeMax=time_max.isoformat(),
singleEvents=True,
orderBy="startTime",
maxResults=250,
pageToken=page_token,
)
.execute()
)
events.extend(result.get("items", []))
page_token = result.get("nextPageToken")
if page_token is None:
break
return events
def merged_busy_intervals(
service: Resource,
time_min: datetime,
time_max: datetime,
) -> list[tuple[datetime, datetime]]:
"""Opaque busy intervals merged across all subscribed calendars."""
calendars = service.calendarList().list().execute().get("items", [])
busy_intervals: list[tuple[datetime, datetime]] = []
for cal in calendars:
cal_events = list_events(service, cal["id"], time_min, time_max)
for ev in cal_events:
start_raw = ev.get("start", {})
end_raw = ev.get("end", {})
transparency = ev.get("transparency", "opaque")
if transparency == "transparent":
continue
start_dt = parse_event_dt(start_raw)
end_dt = parse_event_dt(end_raw)
if start_dt and end_dt:
busy_intervals.append((start_dt, end_dt))
busy_intervals.sort(key=lambda interval: interval[0])
merged: list[tuple[datetime, datetime]] = []
for start, end in busy_intervals:
if merged and start <= merged[-1][1]:
merged[-1] = (merged[-1][0], max(merged[-1][1], end))
else:
merged.append((start, end))
return merged
def interval_overlaps_busy(
start: datetime,
end: datetime,
merged_busy: list[tuple[datetime, datetime]],
) -> bool:
for busy_start, busy_end in merged_busy:
if start < busy_end and end > busy_start:
return True
return False
def find_free_blocks(
service: Resource,
time_min: datetime,
time_max: datetime,
min_duration_minutes: int,
) -> list[tuple[datetime, datetime]]:
"""Find free time blocks across all calendars."""
merged = merged_busy_intervals(service, time_min, time_max)
free: list[tuple[datetime, datetime]] = []
cursor = time_min
for busy_start, busy_end in merged:
if cursor < busy_start:
gap_minutes = int((busy_start - cursor).total_seconds() / 60)
if gap_minutes >= min_duration_minutes:
free.append((cursor, busy_start))
cursor = max(cursor, busy_end)
if cursor < time_max:
gap_minutes = int((time_max - cursor).total_seconds() / 60)
if gap_minutes >= min_duration_minutes:
free.append((cursor, time_max))
return free
def parse_event_dt(raw: dict[str, str]) -> datetime | None:
if "dateTime" in raw:
return datetime.fromisoformat(raw["dateTime"])
if "date" in raw:
return datetime.fromisoformat(raw["date"]).replace(tzinfo=UTC)
return None
def create_event(
service: Resource,
calendar_id: str,
summary: str,
start: datetime,
end: datetime,
description: str,
) -> str:
"""Create a calendar event. Returns the event ID."""
body = {
"summary": summary,
"start": {"dateTime": start.isoformat(), "timeZone": "America/Chicago"},
"end": {"dateTime": end.isoformat(), "timeZone": "America/Chicago"},
"description": description,
}
result = service.events().insert(calendarId=calendar_id, body=body).execute()
return result["id"]
def create_daily_weighin_reminder(service: Resource, calendar_id: str) -> str:
"""Create a recurring daily weigh-in reminder at 6:15am CT."""
tomorrow = (datetime.now(tz=CHICAGO_TZ) + timedelta(days=1)).replace(
hour=6, minute=15, second=0, microsecond=0,
)
body = {
"summary": "Weigh in (open /tracker)",
"start": {"dateTime": tomorrow.isoformat(), "timeZone": "America/Chicago"},
"end": {
"dateTime": (tomorrow + timedelta(minutes=5)).isoformat(),
"timeZone": "America/Chicago",
},
"recurrence": ["RRULE:FREQ=DAILY"],
"reminders": {
"useDefault": False,
"overrides": [{"method": "popup", "minutes": 0}],
},
}
result = service.events().insert(calendarId=calendar_id, body=body).execute()
return result["id"]
def schedule_workouts(
service: Resource,
calendar_id: str,
program_name: str,
day_labels: list[str],
days_ahead: int,
duration_minutes: int,
exercise_descriptions: dict[str, str],
) -> list[str]:
"""Place workout events in free blocks over the next N days.
Avoids scheduling before 8am or after 9pm CT. Returns created event IDs.
"""
now = datetime.now(tz=CHICAGO_TZ)
time_min = now.replace(hour=8, minute=0, second=0, microsecond=0)
if time_min < now:
time_min += timedelta(days=1)
time_max = time_min + timedelta(days=days_ahead)
free_blocks = find_free_blocks(service, time_min, time_max, duration_minutes)
created_ids: list[str] = []
day_idx = 0
used_dates: set[str] = set()
for block_start, block_end in free_blocks:
if day_idx >= len(day_labels):
break
block_start_ct = block_start.astimezone(CHICAGO_TZ)
if block_start_ct.hour < 8 or block_start_ct.hour >= 21:
continue
date_key = block_start_ct.strftime("%Y-%m-%d")
if date_key in used_dates:
continue
workout_end = block_start + timedelta(minutes=duration_minutes)
if workout_end > block_end:
continue
day_label = day_labels[day_idx % len(day_labels)]
summary = f"Workout: {program_name} - Day {day_label}"
description = exercise_descriptions.get(day_label, "")
event_id = create_event(
service, calendar_id, summary, block_start, workout_end, description,
)
created_ids.append(event_id)
used_dates.add(date_key)
day_idx += 1
return created_ids
def schedule_workouts_at_fixed_times(
service: Resource,
calendar_id: str,
program_name: str,
day_labels: list[str],
workout_starts_iso: list[str],
duration_minutes: int,
exercise_descriptions: dict[str, str],
allow_overlap_with_existing: bool,
) -> tuple[list[str] | None, str | None]:
"""Create one workout event per (day_label, start) pair. Validates overlap unless opted out."""
if len(workout_starts_iso) != len(day_labels):
return None, (
f"workout_starts_iso must have one start per program day ({len(day_labels)}); "
f"got {len(workout_starts_iso)}"
)
parsed: list[tuple[str, datetime, datetime]] = []
for day_label, start_raw in zip(day_labels, workout_starts_iso, strict=True):
start_dt = datetime.fromisoformat(start_raw)
end_dt = start_dt + timedelta(minutes=duration_minutes)
parsed.append((day_label, start_dt, end_dt))
for idx_a in range(len(parsed)):
_, s_a, e_a = parsed[idx_a]
for idx_b in range(idx_a + 1, len(parsed)):
_, s_b, e_b = parsed[idx_b]
if s_a < e_b and e_a > s_b:
return None, "Requested workout times overlap each other; pick non-overlapping starts."
if not allow_overlap_with_existing:
time_min = min(s for _, s, _ in parsed) - timedelta(hours=1)
time_max = max(e for _, _, e in parsed) + timedelta(hours=1)
merged_busy = merged_busy_intervals(service, time_min, time_max)
for day_label, start_dt, end_dt in parsed:
if interval_overlaps_busy(start_dt, end_dt, merged_busy):
return None, (
f"Start {start_dt.isoformat()} (Day {day_label}) overlaps an existing calendar event. "
"Choose another time or pass allow_overlap_with_existing=true."
)
created_ids: list[str] = []
for day_label, start_dt, end_dt in parsed:
summary = f"Workout: {program_name} - Day {day_label}"
description = exercise_descriptions.get(day_label, "")
event_id = create_event(
service, calendar_id, summary, start_dt, end_dt, description,
)
created_ids.append(event_id)
return created_ids, None