# Project export: Cal Squared

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: Cal Hacks 12.0
- Tagline: Don't waste time managing time.
- Devpost: https://devpost.com/software/cal-squared
- GitHub: https://github.com/aryanj2374/Calhacks12.0
- Video: https://www.youtube.com/embed/bNnI6ipIXO0?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 2 GitHub contributor(s) — kabilesh16 (8 commits), Aryan Jain (7 commits)

## Devpost submission (written by the team)

### Inspiration

Coming to Berkeley, we struggled with manually managing our time. Even with tools like Google Calendar it was tedious to add every single lecture, homework, exam, to our calendars. Upon further research we found that in workplaces an average of 4.7 meetings per week are canceled/rescheduled. In order to solve this problem and save time we created Cal Squared. Features Cal Squared can import all events from a course in one chat, add events from gmail in your calendar autonomously, and help guide you through managing your time every step of the day. We used Python for the backend and API calls to LLMs like GPT 4o mini. We designed our frontend through React, Javascript, and CSS. Challenges We struggled with inexperience with technologies for the frontend and also with scraping data for the course and menu information.

## README (from the GitHub repository)

# Course Calendar Agent

Python agent that ingests course schedules, syncs them to Google Calendar, and now scans Gmail for new events using a LavaPayments-backed LLM.

## Quickstart

1. **Install dependencies**
   ```bash
   python3 -m venv .venv
   source .venv/bin/activate
   pip install -r requirements.txt
   ```
2. **Create OAuth credentials**
   - In the [Google Cloud Console](https://console.cloud.google.com/), create desktop-app OAuth credentials.
   - Save the JSON as `credentials.json` in the repo root.
3. **Run the CSV agent**
   ```bash
   python -m agentic_calendar.cli --calendar CalendarID
   ```
   - By default the CLI ingests `schedule.csv`; override with `--csv /path/to/file.csv`.
   - If the CSV omits a year (e.g., `Oct 05`), add `--csv-year 2025` so dates are interpreted correctly.
   - Use `--default-start HH:MM` and `--duration minutes` for files without explicit times.
   - Add `--console-oauth` to use the copy/paste OAuth flow.
- Use `--replace-events` to delete matching events before inserting new ones.

## Web UI + API server

The React dashboard (`src/App.js`) now talks to a FastAPI backend that wraps the calendar agent, the scraper, and the Gmail ingest loop.

1. **Start the API**
   ```bash
   # Same virtualenv as above
   export GOOGLE_CALENDAR_ID="primary"          # or a specific calendar id
   export CALENDAR_AGENT_DRY_RUN=false          # set true to preview without writing
   export ENABLE_GMAIL_POLLING=true             # polls Gmail every 5 minutes
   export LAVAPAY_FORWARD_TOKEN='{"token":"..."}'
   uvicorn backend.server:app --reload
   ```
   Useful overrides:
   - `SCHEDULE_CSV` – path to the working CSV (defaults to `schedule.csv`).
   - `CALENDAR_TIMEZONE`, `DEFAULT_START_TIME`, `DEFAULT_DURATION_MINUTES`, `SCHEDULE_FALLBACK_YEAR` – parser hints.
   - `FRONTEND_ORIGINS` – comma-separated list of allowed browser origins (default `http://localhost:3000`).
   - `COURSE_IMPORT_REPLACE=true` – delete+recreate matching events during course imports.
   - `GMAIL_*` variables mirror the CLI flags (`GMAIL_QUERY`, `GMAIL_TOKEN`, etc.).

2. **Run the frontend**
   ```bash
   npm install
   npm start
   ```
   Set `REACT_APP_API_BASE_URL` if the backend is not on `http://localhost:8000`.

### Using the chat box

- Natural-language requests (“add a study session tomorrow at 6pm”, “move HW4 to Friday”) are forwarded to the LLM agent that previously lived in the CLI.
- Pasting a course/syllabus URL and mentioning “course”, “syllabus”, etc. triggers the scraper, rewrites `schedule.csv`, reloads the events, and (when not in dry-run mode) pushes them to Google Calendar.
- Gmail ingestion runs automatically every five minutes whenever `ENABLE_GMAIL_POLLING=true` **and** `CALENDAR_AGENT_DRY_RUN=false`; the background task feeds new email-derived events into the same agent memory so you can reference them in chat.

## Gmail ingestion

The `agentic_calendar.gmail_ingest_cli` entry point authorizes Gmail (read-only) and Calendar (write) and sends each unread email to the LLM so commitments (deadlines, hackathon schedules, etc.) are added automatically.

```bash
python -m agentic_calendar.gmail_ingest_cli \
  --calendar primary \
  --gmail-credentials credentials.json \
  --gmail-token gmail_token.json \
  --query "label:unread newer_than:1d" \
  --forward-token "$LAVAPAY_FORWARD_TOKEN" \
  --apply
```

Workflow:

1. Gmail OAuth tokens are stored separately (`gmail_token.json`) so you can grant read-only mail access alongside Calendar writes.
2. The CLI searches Gmail with the provided query (default `label:unread newer_than:1d`), skips messages already logged in `.gmail_processed.json`, and feeds new ones into the extractor.
3. Preview results without `--apply` or add the flag to create the events immediately.

Environment variables:

- `LAVAPAY_FORWARD_TOKEN` / `LAVA_FORWARD_TOKEN` – LavaPayments forward token (or pass via `--forward-token`).
- `LAVAPAY_BASE_URL`, `LAVAPAY_TARGET_URL`, `LAVAPAY_PROVIDER`, `LAVAPAY_MODEL` – optional overrides for the Lava gateway/provider.

## CSV format

Two layouts are supported:

1. **Normalized headers** – `title,date,start_time,end_time,category,location,description`. These map directly to event fields.
2. **Scraper feed (default)** – `Date,Type,Description` as produced by `schedule.csv`. Titles/descriptions are inferred, type maps to categories, and missing times fall back to per-category defaults.

`Date` is required. Optional columns include `duration`, `location`, and explicit titles. When dates omit a year, provide `--csv-year` so parsing succeeds.


## Detected evidence (automated analysis)

Indexed codebase: 31 recognized source files, 263 KB.
- CSS (language) — detected in the code
- FastAPI (technology) — detected in the code
- HTML (language) — detected in the code
- JavaScript (language) — detected in the code
- Python (language) — detected in the code
- React (technology) — detected in the code
- Tailwind CSS (technology) — detected in the code

## Codebase structure (from repository index)

### Files (39 of 39)

```
.gitignore
app.py
backend/server.py
calhacks.py
chat_agent.py
example_schedule.csv
gymscraper.py
letta_memory.py
menu_recommender.py
menus.json
package.json
public/index.html
public/manifest.json
public/robots.txt
README.md
requirements.txt
schedule.csv
scraper.py
src/agentic_calendar/__init__.py
src/agentic_calendar/agent.py
src/agentic_calendar/calendar_client.py
src/agentic_calendar/chat_cli.py
src/agentic_calendar/cli.py
src/agentic_calendar/csv_loader.py
src/agentic_calendar/email_event_extractor.py
src/agentic_calendar/gmail_client.py
src/agentic_calendar/gmail_ingest_cli.py
src/agentic_calendar/gmail_ingest.py
src/agentic_calendar/llm_client.py
src/agentic_calendar/models.py
src/agentic_calendar/rag.py
src/App.css
src/App.js
src/App.test.js
src/index.css
src/index.js
src/reportWebVitals.js
src/setupTests.js
vector_store.py
```

### Dependencies

- package.json: @testing-library/dom@^10.4.1, @testing-library/jest-dom@^6.9.1, @testing-library/react@^16.3.0, @testing-library/user-event@^13.5.0, autoprefixer@^10.4.21, postcss@^8.5.6, react@^19.2.0, react-dom@^19.2.0, react-scripts@5.0.1, tailwindcss@^4.1.16, web-vitals@^2.1.4
- requirements.txt: fastapi@==0.115.6, google-api-python-client@==2.143.0, google-auth@==2.35.0, google-auth-httplib2@==0.2.0, google-auth-oauthlib@==1.2.1, pydantic@==2.9.2, python-dateutil@==2.9.0.post0, requests@==2.32.3, rich@==13.9.2, selenium@==4.26.1, uvicorn@==0.32.1, webdriver-manager@==4.0.2

### Recent commits (newest first)

- Add menu recommender data and UI updates
- Enhance gym data, Gmail ingest, and dashboard UX
- add menu scraper
- adding menu recommender files
- Add conflict confirmation flow and UI polish
- Add files via upload
- Add backend API and surface Gmail sync status
- Add Gmail ingestion + CSV year/time parsing
- Add files via upload
- Add files via upload
- Restore initial email extractor baseline
- Add Lava chat agent and LLM forward client
- Add files via upload
- Add calendar agent CSV workflow
- Add files via upload
- Add files via upload

## Key source files (fetched from GitHub, selected and truncated for size)

### requirements.txt

```
python-dateutil==2.9.0.post0
pydantic==2.9.2
google-api-python-client==2.143.0
google-auth==2.35.0
google-auth-httplib2==0.2.0
google-auth-oauthlib==1.2.1
rich==13.9.2
requests==2.32.3
fastapi==0.115.6
uvicorn==0.32.1
selenium==4.26.1
webdriver-manager==4.0.2

```

### package.json

```
{
  "name": "frontend",
  "version": "0.1.0",
  "private": true,
  "dependencies": {
    "@testing-library/dom": "^10.4.1",
    "@testing-library/jest-dom": "^6.9.1",
    "@testing-library/react": "^16.3.0",
    "@testing-library/user-event": "^13.5.0",
    "react": "^19.2.0",
    "react-dom": "^19.2.0",
    "react-scripts": "5.0.1",
    "web-vitals": "^2.1.4"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject"
  },
  "eslintConfig": {
    "extends": [
      "react-app",
      "react-app/jest"
    ]
  },
  "browserslist": {
    "production": [
      ">0.2%",
      "not dead",
      "not op_mini all"
    ],
    "development": [
      "last 1 chrome version",
      "last 1 firefox version",
      "last 1 safari version"
    ]
  },
  "devDependencies": {
    "autoprefixer": "^10.4.21",
    "postcss": "^8.5.6",
    "tailwindcss": "^4.1.16"
  }
}

```

### app.py

```python
"""FastAPI service exposing the dining chat assistant."""

from __future__ import annotations

import os
from pathlib import Path
from typing import Any, Dict, Optional, Literal

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field, validator

from chat_agent import ChatResult, DiningChatAgent, DEFAULT_MODEL, load_agent

app = FastAPI(title="Cal Dining Assistant")

AGENT: Optional[DiningChatAgent] = None


class ChatRequest(BaseModel):
    query: str = Field(..., description="Natural language request from the user.")
    top_k: int = Field(5, ge=1, le=10, description="Maximum number of dishes to return.")
    user_id: Optional[str] = Field(
        None,
        description="Optional user identifier to personalize results.",
    )


class RecommendationItem(BaseModel):
    name: str
    item_id: str
    serving: Dict[str, Any]
    dietary: Dict[str, Any]
    nutrition: Dict[str, Any]
    blurb: str
    metadata: Dict[str, Any]
    preference_score: float
    classification_hints: Dict[str, Any]


class ChatResponse(BaseModel):
    used_llm: bool
    response: str
    items: list[RecommendationItem]
    memory_used: bool
    memory_context: Optional[Dict[str, Any]]


class FeedbackRequest(BaseModel):
    user_id: str = Field(..., min_length=1, description="Identifier for the end user.")
    item_id: str = Field(..., min_length=1, description="Identifier for the menu item.")
    vote: Literal["upvote", "downvote"] = Field(
        ...,
        description="Whether the user liked (upvote) or disliked (downvote) the item.",
    )


@app.on_event("startup")
def startup() -> None:
    """Initialize the chat agent once when the API starts."""
    global AGENT
    menus_path = Path(os.getenv("MENUS_JSON", "menus.json"))
    if not menus_path.exists():
        raise RuntimeError(f"menus.json not found at {menus_path}. Run scraper.py first.")

    model = os.getenv("OLLAMA_MODEL", DEFAULT_MODEL)
    AGENT = load_agent(menus_path, model=model)


@app.get("/health", tags=["meta"])
def health() -> Dict[str, Any]:
    """Simple health check."""
    return {
        "status": "ok",
        "model": os.getenv("OLLAMA_MODEL", DEFAULT_MODEL),
        "menus_cached": bool(Path(os.getenv("MENUS_JSON", "menus.json")).exists()),
        "ollama_host": os.getenv("OLLAMA_HOST", "http://localhost:11434"),
    }


@app.post("/chat", response_model=ChatResponse, tags=["chat"])
def chat(request: ChatRequest) -> ChatResponse:
    """Return grounded recommendations for the provided query."""
    if AGENT is None:
        raise HTTPException(status_code=503, detail="Assistant is still loading. Try again shortly.")

    result: ChatResult = AGENT.respond(
        request.query,
        top_k=request.top_k,
        user_id=request.user_id,
    )
    formatted = [_format_item_for_api(item) for item in result.recommendations]
    return ChatResponse(
        used_llm=result.used_llm,
        response=result.response,
        items=formatted,
        memory_used=result.memory_used,
        memory_context=result.memory_context,
    )


@app.post("/feedback", tags=["chat"])
def feedback(request: FeedbackRequest) -> Dict[str, Any]:
    """Capture user feedback (upvote or downvote) on a recommendation."""
    if AGENT is None:
        raise HTTPException(status_code=503, detail="Assistant is still loading. Try again shortly.")

    vote_value = 1 if request.vote == "upvote" else -1
    try:
        feedback_result = AGENT.record_feedback(request.user_id, request.item_id, vote_value)
    except KeyError as exc:
        raise HTTPException(status_code=404, detail=str(exc)) from exc
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc
    except RuntimeError as exc:
        raise HTTPException(status_code=500, detail=str(exc)) from exc

    payload = {"status": "recorded"}
    payload.update(feedback_result)
    return payload


def _format_item_for_api(item: Dict[str, Any]) -> RecommendationItem:
    serving = {
        "location": item.get("location"),
        "meal": item.get("meal"),
        "hours": item.get("hours") or [],
        "hours_structured": item.get("hours_structured") or [],
        "status": item.get("status"),
        "crowdedness": item.get("crowdedness"),
    }
    dietary = {
        "choices": item.get("dietary_choices") or [],
        "tags": item.get("tags") or [],
    }
    metadata = {
        "category": item.get("category"),
        "score": item.get("score"),
        "menu_reference": item.get("menu_reference"),
    }
    return RecommendationItem(
        name=item.get("name", "Unknown Item"),
        item_id=item.get("item_id", ""),
        serving=serving,
        dietary=dietary,
        nutrition=item.get("nutrition") or {},
        blurb=item.get("blurb") or "",
        metadata=metadata,
        preference_score=float(item.get("preference_score") or 0.0),
        classification_hints=item.get("classification_hints") or {},
    )

```

### src/index.js

```javascript
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

```

### src/App.js

```javascript
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import "./App.css";

function App() {
  const apiBaseUrl = useMemo(
    () => process.env.REACT_APP_API_BASE_URL || "http://localhost:8000",
    []
  );
  const calendarId = useMemo(
    () => process.env.REACT_APP_GOOGLE_CALENDAR_ID || "primary",
    []
  );
  const calendarTz = useMemo(
    () => process.env.REACT_APP_GOOGLE_CALENDAR_TZ || "America/Los_Angeles",
    []
  );
  const [calendarView, setCalendarView] = useState("AGENDA");
  const [calendarRefresh, setCalendarRefresh] = useState(0);

  const calendarSrc = useMemo(() => {
    const base = `https://calendar.google.com/calendar/embed`;
    const params = new URLSearchParams({
      src: calendarId,
      ctz: calendarTz,
      mode: calendarView,
      showCalendars: "0",
      showTabs: "0",
      showTitle: "0",
      refresh: String(calendarRefresh),
    });
    return `${base}?${params.toString()}`;
  }, [calendarId, calendarTz, calendarView, calendarRefresh]);

  const [messages, setMessages] = useState([
    {
      id: 1,
      text: "Hi! I'm your calendar agent. Ask me to add or move events, or paste a course syllabus link and mention 'course' to import it.",
      sender: "bot",
    },
  ]);
  const [input, setInput] = useState("");
  const [status, setStatus] = useState("Connected to local agent.");
  const [sending, setSending] = useState(false);
  const [error, setError] = useState("");
  const [syncInfo, setSyncInfo] = useState(null);
  const [gymStatus, setGymStatus] = useState(null);
  const [menuRecommendation, setMenuRecommendation] = useState(null);
  const [menuVotePending, setMenuVotePending] = useState(false);
  const [menuVoteError, setMenuVoteError] = useState("");
  const [todos, setTodos] = useState([]);
  const [showIdeas, setShowIdeas] = useState(true);
  const [pendingConfirmation, setPendingConfirmation] = useState(null);
  const chatRef = useRef(null);
  const lastSyncTimestampRef = useRef(null);

  const fetchStatus = useCallback(async () => {
    try {
      const response = await fetch(`${apiBaseUrl}/api/status`);
      if (!response.ok) {
        throw new Error("Status request failed");
      }
      const payload = await response.json();
      setSyncInfo(payload.gmail_sync || null);
      setGymStatus(payload.gym_status || null);
      setMenuRecommendation(payload.menu_recommendation || null);
      if (payload.menu_recommendation) {
        setMenuVoteError("");
      }
      const nextTimestamp = payload.gmail_sync?.timestamp || null;
      if (nextTimestamp) {
        if (nextTimestamp !== lastSyncTimestampRef.current) {
          lastSyncTimestampRef.current = nextTimestamp;
          setCalendarRefresh((count) => count + 1);
        }
      } else {
        lastSyncTimestampRef.current = null;
      }
    } catch (err) {
      console.error("Failed to fetch status", err);
    }
  }, [apiBaseUrl]);

  const fetchTodos = useCallback(async () => {
    try {
      const response = await fetch(`${apiBaseUrl}/api/todos`);
      if (!response.ok) {
        throw new Error("Todo request failed");
      }
      const payload = await response.json();
      if (!Array.isArray(payload)) {
        throw new Error("Todo payload malformed");
      }
      setTodos(
        payload.map((item, idx) => ({
          id: item.id ?? `todo-${idx}`,
          text: item.text ?? "",
          removing: false,
        }))
      );
    } catch (err) {
      console.error("Failed to fetch todos", err);
    }
  }, [apiBaseUrl]);

  useEffect(() => {
    const run = () => {
      fetchStatus();
      fetchTodos();
    };
    run();
    const id = setInterval(run, 60000);
    return () => clearInterval(id);
  }, [fetchStatus, fetchTodos]);

  const formatSyncTime = (timestamp, options = {}) => {
    if (!timestamp) return "—";
    try {
      return new Date(timestamp).toLocaleString(undefined, {
        dateStyle: "medium",
        timeStyle: "short",
        ...options,
      });
    } catch {
      return timestamp;
    }
  };

  const syncErrors = syncInfo?.errors || [];

  const defaultRecommendations = useMemo(
    () => [
      "Check the RSF crowd meter before heading out",
      "Top dining pick loading…",
      "Block focus time for project",
    ],
    []
  );

  useEffect(() => {
    if (chatRef.current) {
      chatRef.current.scrollTop = chatRef.current.scrollHeight;
    }
  }, [messages]);

  const recommendations = useMemo(() => {
    const next = [...defaultRecommendations];
    if (gymStatus) {
      if (gymStatus.error) {
        next[0] = `RSF crowd data unavailable (${gymStatus.error})`;
      } else {
        const percent = gymStatus.occupancy_percent;
        if (typeof percent === "number") {
          next[0] =
            percent < 50
              ? `RSF · ${percent}% full · Hit the gym!`
              : `RSF · ${percent}% full · Crowded now—wait till off-peak hours`;
        } else {
          next[0] = "RSF crowd status unavailable";
        }
      }
    }

    if (menuRecommendation) {
      const parts = [];
      if (menuRecommendation.location) parts.push(menuRecommendation.location);
      if (menuRecommendation.meal) parts.push(menuRecommendation.meal);
      const dishName = menuRecommendation.name || "Dining hall highlight";
      if (dishName) parts.push(dishName);
      const text = parts.join(" · ").trim();
      next[1] = text || dishName;
    } else {
      next[1] = defaultRecommendations[1];
    }

    return next;
  }, [defaultRecommendations, gymStatus, menuRecommendation]);

  const handleMenuFeedback = useCallback(
    async (vote) => {
      if (!menuRecommendation?.item_id || menuVotePending) return;
      setMenuVotePending(true);
      setMenuVoteError("");
      try {
        const response = await fetch(`${apiBaseUrl}/api/menu/feedback`, {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({
            item_id: me
[truncated — 12772 more characters]
```

### src/agentic_calendar/cli.py

```python
from __future__ import annotations

import argparse
from typing import List

from rich.console import Console
from rich.table import Table

from .calendar_client import GoogleCalendarClient
from .csv_loader import load_schedule_csv
from .models import CourseEvent


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description="Load course events from CSV and push them to Google Calendar")
    parser.add_argument(
        "--csv",
        default="schedule.csv",
        help="Path to the CSV file containing course events (default: schedule.csv)",
    )
    parser.add_argument("--calendar", required=True, help="Google Calendar ID (or primary)")
    parser.add_argument("--credentials", default="credentials.json", help="Path to OAuth client credentials")
    parser.add_argument("--token", default="token.json", help="Path to cached OAuth token")
    parser.add_argument("--timezone", default="America/Los_Angeles", help="IANA timezone for parsed dates")
    parser.add_argument("--duration", type=int, default=50, help="Default event length in minutes when no end time is provided")
    parser.add_argument(
        "--default-start",
        default="09:00",
        help="Fallback start time (HH:MM, 24h) when the schedule omits explicit times",
    )
    parser.add_argument("--max-events", type=int, default=100, help="Limit events pushed to Calendar")
    parser.add_argument(
        "--csv-year",
        type=int,
        default=None,
        help="Default year to apply when the CSV dates omit the year (e.g., 2025)",
    )
    parser.add_argument(
        "--console-oauth",
        action="store_true",
        help="Use console-based OAuth instead of opening a browser window",
    )
    parser.add_argument(
        "--replace-events",
        action="store_true",
        help="Delete matching events on the target calendar before inserting new ones",
    )
    parser.add_argument("--dry-run", action="store_true", help="Do not call Google Calendar; just print extracted events")
    return parser


def main(args: list[str] | None = None) -> None:
    parser = build_parser()
    opts = parser.parse_args(args=args)

    try:
        default_start_hour, default_start_minute = _parse_time_arg(opts.default_start)
    except ValueError as exc:
        parser.error(str(exc))

    console = Console()
    try:
        report = load_schedule_csv(
            csv_path=opts.csv,
            timezone=opts.timezone,
            default_start_hour=default_start_hour,
            default_start_minute=default_start_minute,
            default_duration_minutes=opts.duration,
            fallback_year=opts.csv_year,
        )
    except Exception as exc:  # pragma: no cover - invalid file path
        console.print(f"[red]Failed to load CSV: {exc}[/]")
        return

    all_events: List[CourseEvent] = report.events

    if report.warnings:
        console.print("[yellow]Warnings during CSV load:[/]")
        for warning in report.warnings:
            console.print(f"- {warning}")

    if not all_events:
        console.print("[yellow]No events extracted.[/]")
        return

    all_events = all_events[: opts.max_events]
    _render_preview(console, all_events)

    client = GoogleCalendarClient(
        calendar_id=opts.calendar,
        credentials_file=opts.credentials,
        token_file=opts.token,
        dry_run=opts.dry_run,
        use_console_oauth=opts.console_oauth,
    )

    if opts.replace_events:
        if opts.dry_run:
            console.print("[yellow]Replace flag ignored during dry-run; no events deleted.[/]")
        else:
            deleted = client.delete_matching_events(all_events)
            console.print(f"[yellow]Deleted {deleted} existing events before inserting new ones.[/]")

    results = client.create_events(all_events)
    if opts.dry_run:
        console.print("[green]Dry run complete. Events were not created.[/]")
        for payload in results:
            console.print(payload)
    else:
        console.print(f"[green]Created {len(results)} Google Calendar events.[/]")


def _render_preview(console: Console, events: List[CourseEvent]) -> None:
    table = Table(title="Extracted Events")
    table.add_column("Title")
    table.add_column("Category")
    table.add_column("Start")
    table.add_column("End")
    for event in events:
        table.add_row(event.title, event.category.value, str(event.start), str(event.end or event.start))
    console.print(table)


def _parse_time_arg(value: str) -> tuple[int, int]:
    try:
        hour_str, minute_str = value.split(":")
        hour = int(hour_str)
        minute = int(minute_str)
    except ValueError as exc:
        raise ValueError("default start time must be formatted as HH:MM (24-hour)") from exc
    if not (0 <= hour <= 23 and 0 <= minute <= 59):
        raise ValueError("default start time must use 0<=HH<=23 and 0<=MM<=59")
    return hour, minute


if __name__ == "__main__":  # pragma: no cover
    main()

```

### backend/server.py

```python
from __future__ import annotations

import asyncio
import json
import logging
import os
import re
from dataclasses import dataclass
from datetime import date, datetime, time, timedelta, timezone, tzinfo
from pathlib import Path
from threading import Lock
from typing import Dict, List, Optional, Sequence, TYPE_CHECKING, Literal

from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field
from dateutil import tz

from agentic_calendar import CalendarAgent, GoogleCalendarClient, load_schedule_csv
from agentic_calendar.agent import AgentResult
from agentic_calendar.gmail_client import GmailClient
from agentic_calendar.gmail_ingest import ingest_gmail
from agentic_calendar.llm_client import LavaPaymentsLLMClient
from agentic_calendar.models import CourseEvent
from gymscraper import fetch_rsf_occupancy_percent

if TYPE_CHECKING:  # pragma: no cover - import only for typing
    from menu_recommender import MenuRecommender


DEFAULT_MENU_SOURCE_URL = "https://dining.berkeley.edu/menus/"
DEFAULT_MENUS_JSON_PATH = "menus.json"
DEFAULT_MENU_RECOMMENDATION_CACHE_MINUTES = 30

try:
    from calhacks import save_to_csv, scrape_course_schedule
except ImportError as exc:  # pragma: no cover - optional scraper dependency
    raise RuntimeError("calhacks.py must be importable to support course scraping") from exc


logger = logging.getLogger("calendar_server")
logging.basicConfig(level=logging.INFO)

COURSE_KEYWORDS = ("course", "syllabus", "schedule", "class")
URL_RE = re.compile(r"https?://\S+", re.IGNORECASE)
TODO_SYSTEM_PROMPT = """
You are a helpful assistant that reads a list of a student's scheduled events for today and suggests
three high-impact focus tasks for them to prioritize. The tasks should be action-oriented and specific,
not generic. If the schedule is light and you cannot find three meaningful tasks, respond with an empty
JSON array instead of inventing filler items.

Return JSON only. Use this schema:
[
  {"text": "<short actionable focus item>"},
  ...
]

The array may contain from zero to three items. Do not include any other fields or commentary.
""".strip()


def _env_bool(key: str, default: bool = False) -> bool:
    value = os.getenv(key)
    if value is None:
        return default
    return value.strip().lower() in {"1", "true", "yes", "on"}


def _parse_default_start(value: str) -> tuple[int, int]:
    hour_str, minute_str = value.split(":")
    return int(hour_str), int(minute_str)


@dataclass
class ServerConfig:
    schedule_csv: Path = Path(os.getenv("SCHEDULE_CSV", "schedule.csv"))
    calendar_id: Optional[str] = os.getenv("GOOGLE_CALENDAR_ID")
    calendar_credentials: str = os.getenv("CALENDAR_CREDENTIALS", "credentials.json")
    calendar_token: str = os.getenv("CALENDAR_TOKEN", "token.json")
    timezone: str = os.getenv("CALENDAR_TIMEZONE", "America/Los_Angeles")
    default_start: tuple[int, int] = _parse_default_start(os.getenv("DEFAULT_START_TIME", "09:00"))
    default_duration_minutes: int = int(os.getenv("DEFAULT_DURATION_MINUTES", "50"))
    fallback_year: Optional[int] = (
        int(os.getenv("SCHEDULE_FALLBACK_YEAR")) if os.getenv("SCHEDULE_FALLBACK_YEAR") else None
    )
    dry_run: bool = _env_bool("CALENDAR_AGENT_DRY_RUN", True)
    enable_gmail_polling: bool = _env_bool("ENABLE_GMAIL_POLLING", True)
    gmail_poll_interval_seconds: int = int(os.getenv("GMAIL_POLL_INTERVAL_SECONDS", "60"))
    gmail_query: str = os.getenv("GMAIL_QUERY", "label:unread newer_than:1d")
    gmail_credentials: str = os.getenv("GMAIL_CREDENTIALS", "credentials.json")
    gmail_token: str = os.getenv("GMAIL_TOKEN", "gmail_token.json")
    gmail_processed_store: str = os.getenv("GMAIL_PROCESSED_STORE", ".gmail_processed.json")
    llm_provider: str = os.getenv("LAVAPAY_PROVIDER", "openai")
    llm_model: str = os.getenv("LAVAPAY_MODEL", "gpt-4o-mini")
    forward_token: Optional[str] = (
        os.getenv("CALENDAR_AGENT_FORWARD_TOKEN")
        or os.getenv("LAVAPAY_FORWARD_TOKEN")
        or os.getenv("LAVA_FORWARD_TOKEN")
    )
    frontend_origins: Sequence[str] = tuple(
        origin.strip()
        for origin in os.getenv("FRONTEND_ORIGINS", "http://localhost:3000").split(",")
        if origin.strip()
    )
    console_oauth: bool = _env_bool("CONSOLE_OAUTH", False)
    course_import_replace: bool = _env_bool("COURSE_IMPORT_REPLACE", False)
    enable_menu_recommendations: bool = _env_bool("ENABLE_MENU_RECOMMENDATIONS", True)
    menu_source_url: str = os.getenv("MENU_SOURCE_URL", DEFAULT_MENU_SOURCE_URL)
    menus_json: Path = Path(os.getenv("MENUS_JSON", DEFAULT_MENUS_JSON_PATH))
    menu_recommendation_cache_minutes: int = int(
        os.getenv("MENU_RECOMMENDATION_CACHE_MINUTES", str(DEFAULT_MENU_RECOMMENDATION_CACHE_MINUTES))
    )
    menu_recommendation_user_id: str = os.getenv("MENU_RECOMMENDATION_USER_ID", "default-user")


class ChatRequest(BaseModel):
    message: str = Field(min_length=1, max_length=2000)


class ConfirmationRequest(BaseModel):
    response: str = Field(min_length=1, max_length=200)
    pending_event: dict = Field(description="The pending event data to confirm")


class ChatResponse(BaseModel):
    reply: str
    action: str
    executed: bool
    raw_response: Optional[str] = None
    metadata: dict | None = None
    needs_confirmation: bool = False
    pending_event: Optional[dict] = None


class GmailSyncInfo(BaseModel):
    timestamp: datetime
    event_count: int
    created_count: int
    applied: bool
    errors: List[str] = Field(default_factory=list)


class TodoItem(BaseModel):
    id: str
    text: str


class GymStatus(BaseModel):
    occupancy_percent: Optional[int] = None
    last_updated: Optional[datetime] = None
    error: Optional[str] = None


class MenuRecommendation(BaseModel):
    name: str
    location: Optional[str] = None
    meal: Optional[str] = None
    category: Optional[str] = None
    item_id: Optional[str] = None
    blurb: Optional[str]
[truncated — 27825 more characters]
```

### gymscraper.py

```python
from __future__ import annotations

import re
from contextlib import suppress
from typing import Optional

from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from webdriver_manager.chrome import ChromeDriverManager

RSF_WEIGHT_ROOM_URL = (
    "https://recwell.berkeley.edu/facilities/recreational-sports-facility-rsf/rsf-weight-room-crowd-meter/"
)


def _span_has_full_text(driver: webdriver.Chrome) -> Optional[str]:
    """Return the text content of the span that contains '% Full', if present."""
    spans = driver.find_elements(By.TAG_NAME, "span")
    for span in spans:
        if "% Full" in span.text:
            return span.text
    return None


def fetch_rsf_occupancy_percent(timeout: int = 20) -> int:
    """Scrape the RSF weight room crowd meter and return the occupancy percentage."""
    options = Options()
    # new headless helps avoid deprecated warning and better parity with Chrome 109+
    options.add_argument("--headless=new")
    options.add_argument("--no-sandbox")
    options.add_argument("--disable-dev-shm-usage")

    driver = webdriver.Chrome(service=Service(ChromeDriverManager().install()), options=options)
    try:
        driver.get(RSF_WEIGHT_ROOM_URL)
        iframe = WebDriverWait(driver, 10).until(
            lambda d: d.find_element(By.XPATH, '//iframe[@title="Weightroom Capacity"]')
        )
        driver.switch_to.frame(iframe)
        full_text = WebDriverWait(driver, timeout).until(_span_has_full_text)
        if not full_text:
            raise RuntimeError("RSF occupancy span not found")

        match = re.search(r"(\d+)", full_text)
        if not match:
            raise RuntimeError(f"Could not extract occupancy number from '{full_text}'")
        return int(match.group(1))
    finally:
        with suppress(Exception):
            driver.quit()


if __name__ == "__main__":
    try:
        occupancy = fetch_rsf_occupancy_percent()
    except Exception as exc:  # pragma: no cover - convenience path
        print(f"Could not extract occupancy number: {exc}")
    else:
        print(f"Current weight room occupancy: {occupancy}%")

```

### letta_memory.py

```python
"""Lightweight client that emulates a LeTTA-style memory service for feedback."""

from __future__ import annotations

import json
import os
import threading
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, Optional, Sequence

import datetime as dt


@dataclass
class PreferenceProfile:
    """Aggregated view of a user's expressed meal preferences."""

    item_scores: Dict[str, float]
    tag_scores: Dict[str, float]
    category_scores: Dict[str, float]
    location_scores: Dict[str, float]
    last_updated: Optional[str] = None

    def has_signal(self) -> bool:
        """Return True when any preference weights are non-zero."""
        return any(
            any(values.values())
            for values in (
                self.item_scores,
                self.tag_scores,
                self.category_scores,
                self.location_scores,
            )
        )


class LettaMemoryClient:
    """Stores up/down votes with simple heuristics to mimic LeTTA memory."""

    def __init__(self, storage_path: Optional[Path] = None) -> None:
        default_root = Path(os.getenv("LETTA_STORAGE_ROOT", ".cache")) / "letta"
        self.storage_path = storage_path or (default_root / "feedback.json")
        self.storage_path.parent.mkdir(parents=True, exist_ok=True)
        self._lock = threading.Lock()

    def record_feedback(
        self,
        user_id: str,
        item_id: str,
        *,
        tags: Sequence[str],
        category: Optional[str],
        location: Optional[str],
        vote: int,
    ) -> PreferenceProfile:
        """Persist a feedback event and return the updated profile."""
        if vote not in (-1, 1):
            raise ValueError("vote must be +1 (upvote) or -1 (downvote)")
        with self._lock:
            state = self._load_state()
            user = state.setdefault(
                user_id,
                {
                    "items": {},
                    "tags": {},
                    "categories": {},
                    "locations": {},
                    "history": [],
                    "last_updated": None,
                },
            )

            self._bump(user["items"], item_id, vote)
            for tag in tags:
                self._bump(user["tags"], tag.lower(), vote)
            if category:
                self._bump(user["categories"], category.lower(), vote)
            if location:
                self._bump(user["locations"], location.lower(), vote)

            timestamp = dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds")
            user["history"].append({"item_id": item_id, "vote": vote, "at": timestamp})
            user["history"] = user["history"][-200:]  # avoid unbounded growth
            user["last_updated"] = timestamp

            self._save_state(state)
            return self._profile_from_state(user)

    def get_preference_profile(self, user_id: str) -> PreferenceProfile:
        """Fetch the stored profile for a user."""
        with self._lock:
            state = self._load_state()
            user = state.get(
                user_id,
                {
                    "items": {},
                    "tags": {},
                    "categories": {},
                    "locations": {},
                    "last_updated": None,
                },
            )
            return self._profile_from_state(user)

    def clear_feedback(self, user_id: str) -> None:
        """Remove all stored feedback for the given user."""
        with self._lock:
            state = self._load_state()
            if user_id in state:
                del state[user_id]
                self._save_state(state)

    def _load_state(self) -> Dict[str, Any]:
        if not self.storage_path.exists():
            return {}
        try:
            raw = self.storage_path.read_text(encoding="utf-8")
        except OSError:
            return {}
        try:
            state = json.loads(raw)
        except json.JSONDecodeError:
            return {}
        return state if isinstance(state, dict) else {}

    def _save_state(self, state: Dict[str, Any]) -> None:
        payload = json.dumps(state, indent=2, sort_keys=True)
        tmp_path = self.storage_path.with_suffix(".tmp")
        tmp_path.write_text(payload, encoding="utf-8")
        tmp_path.replace(self.storage_path)

    @staticmethod
    def _bump(bucket: Dict[str, float], key: str, delta: float) -> None:
        new_value = bucket.get(key, 0.0) + float(delta)
        if abs(new_value) < 1e-6:
            bucket.pop(key, None)
        else:
            bucket[key] = new_value

    @staticmethod
    def _profile_from_state(state: Dict[str, Any]) -> PreferenceProfile:
        return PreferenceProfile(
            item_scores=dict(state.get("items", {})),
            tag_scores=dict(state.get("tags", {})),
            category_scores=dict(state.get("categories", {})),
            location_scores=dict(state.get("locations", {})),
            last_updated=state.get("last_updated"),
        )


```

### src/setupTests.js

```javascript
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';

```

[22 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]