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