Spaces:
Sleeping
Sleeping
| 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 | |