# Project export: BearFuel

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: UC Berkeley AI Hackathon 2026
- Tagline: Tells you what to put on your tray. Eat toward your goals using what Berkeley's dining halls are actually serving today.
- Devpost: https://devpost.com/software/bearfuel
- GitHub: https://github.com/yukai0/AIhackathon2026
- Video: https://www.youtube.com/embed/vxPJRLV824o?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 2 GitHub contributor(s) — yukai0 (4 commits), Arnav Kamath (4 commits)

## Devpost submission (written by the team)

### Overview

Here’s a cleaner, more human-sounding hackathon writeup with markdown formatting, fewer “AI-polished” phrases, and no em dashes.

### Inspiration

Most Berkeley freshmen show up in the same spot. They are living on their own for the first time, eating most of their meals on a dining plan, and walking past a free gym on the way to class. It is a great time to build healthy habits, but it is also really easy to drift. Some people gain weight without noticing. Some get run down and sick. Some lose weight without meaning to. The hard part is not logging food. Plenty of apps already do that. The hard part is making a decision in the moment. You are standing in Crossroads with a tray. You want to eat better, get a little leaner, put on muscle, or just feel better, but you do not know which of the twenty things in front of you actually helps with that. Normal nutrition apps cannot help because they do not know what Berkeley is serving today. That gap between a health goal and the actual dining hall tray is what BearFuel is built for.

### What it does

BearFuel helps Berkeley students decide what to eat from the dining halls based on their personal goals. A student enters basic information like height, weight, age, activity level, goal, number of meals per day, dietary restrictions, and allergens. BearFuel then: pulls the current UC Berkeley dining hall menu uses the real nutrition facts for each dish calculates personal calorie and protein targets builds a meal-by-meal plan using only dishes being served that day respects dietary restrictions and allergens gives macro totals and a short reason for each meal The goal is simple: answer the question, “What should I put on my tray?” Students can also browse the full menu, see nutrition for every item, and open a dish detail page for more information. BearFuel is not trying to be another tracker. It is meant to give students a useful answer before they eat.

### How we built it

BearFuel has three main parts: SwiftUI iOS app The app is built with SwiftUI using an MVVM structure for iOS 17. It includes onboarding, a Today screen with macro rings, a searchable menu browser, and dish detail pages. We used a Berkeley blue and gold visual style and included dark mode support. Python and FastAPI backend The backend scrapes and normalizes Berkeley dining hall menus, stores cached menu data by date, calls Claude, and returns clean JSON to the app. The Anthropic API key stays on the server and never reaches the client. Claude as the meal planning engine Claude takes the available dishes and builds a plan that fits the user’s goal. We use structured JSON output so the backend can validate the result instead of trusting freeform text. We split the responsibilities carefully. Python handles the safety-critical math and guardrails. Claude handles the flexible part: choosing real dishes from the menu and putting them together into meals. For calorie targets, we use the Mifflin-St Jeor equation: From there, the backend applies the user’s goal, calorie floors, and deficit caps. Those rules are enforced in code, not left to the model. Claude only selects meals after the targets and constraints have already been calculated. The backend then checks the model’s totals and can retry or fall back if the plan does not meet the requirements.

### Challenges we ran into

The biggest challenge was getting real nutrition data. At first, the public Berkeley dining page looked like it only showed dish names, allergens, dietary tags, and carbon ratings. We could not find calories, protein, carbs, or fat anywhere on the main page. For a while, we thought we would have to estimate nutrition with AI, which would have made the app much less trustworthy. Then we noticed that clicking on a dish opens a popup with full nutrition facts. It had calories, protein, carbs, fat, sodium, fiber, and other USDA-style information. That was exactly what we needed. The next challenge was figuring out where the popup data came from. We expected an AJAX request or a hidden WordPress endpoint. We opened the Network tab, clicked a dish, and saw nothing. No request at all. That ended up being the key insight. The nutrition data was already inside the page HTML when it loaded. The site was just hiding and showing it with JavaScript. That changed our whole approach. We did not need to reverse engineer an API. We needed to scrape the raw HTML without cleaning away the hidden blocks. This also led to one of the funniest bugs of the project. The nutrition section used a misspelled CSS class: nutration-details. Our selector kept failing because we were spelling “nutrition” correctly. Other challenges included: making sure macro totals were accurate validating Claude’s arithmetic in code re-prompting when the model returned an invalid plan building calorie guardrails that avoid unsafe recommendations writing the app in a way that feels supportive instead of guilt-based Accomplishments we are proud of Real nutrition data BearFuel does not guess macros. Every calorie and protein number comes from Berkeley’s own dining data. That makes the recommendations much more defensible. Useful AI with real constraints Claude is not just writing a paragraph of advice. It is choosing from the actual dishes available today and building a plan around hard nutrition targets, dietary restrictions, and allergens. Safety built in from the start The backend enforces calorie floors, deficit caps, and goal limits. The app also uses supportive language and points students toward campus nutrition resources when appropriate. A demo that can survive spotty data Menus are cached by date, and the app has graceful fallbacks. The demo does not depend on the dining site working perfectly at that exact moment.

### What we learned

We learned not to assume an API is missing just because it is not obvious. The data we needed was already on the page the whole time. We just had to inspect the raw HTML closely enough to find it. We also learned that AI works best when it is given a narrow job. In BearFuel, code handles the math, safety rules, validation, and fallback behavior. Claude handles the part that is naturally fuzzy: turning a list of available dishes into a reasonable meal plan. Structured outputs made a huge difference. Instead of treating the model response like a finished answer, we treat it like data that must pass checks before the user sees it. We also learned that anything involving weight, food, and body composition needs careful wording. Small product choices matter. BearFuel is designed to help students build habits, not make them feel judged. What is next Weekly planning Students should be able to plan across multiple days instead of only seeing today’s menu. Swap a dish If a student does not want one item, BearFuel should be able to replace it while keeping the meal close to the same targets. Apple Health integration Activity data could help targets adjust based on how much the student actually moved, trained, or lifted that day. Dining hall notifications BearFuel could send reminders around dining hall hours so students do not have to remember to check the app. More campuses Berkeley’s menu system is used in other places too, so the same approach could work at more universities with relatively small changes. Habit tracking without guilt Over time, students could see patterns in their eating habits without turning the app into a strict calorie tracker or a guilt machine.

## README (from the GitHub repository)

# BearFuel — Berkeley AI Hackathon 2026

**The wedge:** UC Berkeley freshmen eat on a meal plan, have free gym access, and don't cook. The single highest-leverage nutrition intervention is *"tell me what to put on my tray at Crossroads tonight."* Generic meal-plan apps can't do this. BearFuel can.

## What it does

BearFuel takes a student's stats and body-composition goal, pulls today's real Berkeley dining-hall menu (with actual USDA-derived nutrition per dish), and uses **Claude** to assemble a personalized meal plan from dishes that are actually being served — hitting their calorie and protein targets.

## The data insight (key technical story)

Berkeley dining embeds full USDA-derived nutrition facts for every dish in their menu system. The `data-location` attributes in the page HTML are base64-encoded paths to public XML files:

```
https://dining.berkeley.edu/wp-content/uploads/menus-exportimport/{Location}_{YYYYMMDD}.xml
```

Each XML is `EatecExchange` format with structured nutrition for every recipe (kcal, protein, carb, fat, fiber, sugar, sodium, …). **One HTTP request per location per day gives every dish with complete USDA-derived nutrition** — no AJAX, no per-item API calls, no estimation.

Verified against the Berkeley site: Cinnamon Raisin Bagels → 250.83 kcal, 8.05g protein, 50.14g carb, 1.34g fat ✓

## Architecture

```
┌──────────────────┐     HTTPS/JSON      ┌─────────────────────────┐    Anthropic API
│  SwiftUI iOS app │  <───────────────>  │  FastAPI backend         │  <──────────────>  Claude
│  (the client)    │                     │  scraper + planner + AI  │
└──────────────────┘                     └─────────────────────────┘
```

- **API key lives only in the backend.** The iOS app never sees it.
- **Cache-first:** scraper writes `cache/menu_{date}.json`; endpoints read from cache. No live scrape at request time.
- **Demo-safe:** `DEMO_MODE=true` serves `cache/menu_demo.json` (committed seed). iOS app bundles `demo_plan.json` as fallback if backend is unreachable.

## Running locally

### Backend

```bash
cd backend
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt

# Copy and fill in your API key
cp .env.example .env
# Edit .env: ANTHROPIC_API_KEY=sk-ant-...

# Option A: demo mode (no live scrape)
DEMO_MODE=true uvicorn main:app --reload --port 8002

# Option B: live mode (scrapes today's menu on first /menu request)
uvicorn main:app --reload --port 8002

# Or pre-seed the cache manually
python seed_cache.py      # copies demo seed to today's date
python scraper.py         # scrapes live Berkeley data for today
```

Verify:
```bash
curl http://localhost:8002/health
curl "http://localhost:8002/menu?date=today&location=Crossroads" | python3 -m json.tool | head -30
curl -X POST http://localhost:8002/plan \
  -H 'Content-Type: application/json' \
  -d '{"profile":{"height_cm":178,"weight_kg":75,"age":19,"sex":"male","activity_level":"moderate","goal_type":"lean_gain","meals_per_day":3,"diet_restrictions":[],"exclude_allergens":[],"preferred_locations":["Crossroads"]},"date":"today"}'
```

### iOS

```bash
cd ios
# Install XcodeGen if needed: brew install xcodegen
xcodegen generate    # produces BearFuel.xcodeproj
open BearFuel.xcodeproj
```

- Set `Config.swift` `baseURL` to your backend URL
- Build and run on Simulator (iOS 17+)
- The app loads `demo_plan.json` from the bundle if the backend is unreachable

## Health & safety guardrails

All enforced in `backend/planner.py` — never delegated to the model:

| Guardrail | Implementation |
|-----------|---------------|
| Calorie floor | ≥1500 kcal/day (men), ≥1200 (women); clamped with user-visible note |
| Deficit cap | Max −20% below TDEE |
| Surplus cap | Max +10% above TDEE |
| Low BMI guard | BMI < 17.5 → switch to maintenance + link to Berkeley dietitian |
| Input validation | Height/weight/age range checks via Pydantic |
| Framing | Language is fueling/energy/habits — no weight loss imagery |
| Disclaimer | Always shown on every plan |

## Sponsor context (Anthropic / Claude)

Claude is the headline capability: given 200+ real Berkeley dining-hall dishes with USDA nutrition, it composes a day's meals that hit a personalized calorie/protein target under dietary, allergen, and location constraints — something no static macro calculator can do. The server recomputes all totals from real nutrition data after Claude selects dishes, so the numbers are always accurate.

Model: `claude-sonnet-4-6` (configurable via `ANTHROPIC_MODEL` in `.env`).


## Detected evidence (automated analysis)

Indexed codebase: 22 recognized source files, 195 KB.
- Anthropic (technology) — detected in the code
- FastAPI (technology) — detected in the code
- Python (language) — detected in the code
- Swift (language) — detected in the code

## Codebase structure (from repository index)

### Files (30 of 30)

```
.gitignore
backend/.env.example
backend/cache/menu_demo.json
backend/main.py
backend/models.py
backend/nutrition.py
backend/planner.py
backend/prompts/planner_system.txt
backend/requirements.txt
backend/scraper.py
backend/seed_cache.py
ios/BearFuel.xcodeproj/project.pbxproj
ios/BearFuel/BearFuelApp.swift
ios/BearFuel/Config.swift
ios/BearFuel/ContentView.swift
ios/BearFuel/DesignSystem.swift
ios/BearFuel/Info.plist
ios/BearFuel/Models/Models.swift
ios/BearFuel/Networking/APIClient.swift
ios/BearFuel/Resources/demo_plan.json
ios/BearFuel/Views/Menu/DishDetailView.swift
ios/BearFuel/Views/Menu/MenuBrowserView.swift
ios/BearFuel/Views/Menu/MenuViewModel.swift
ios/BearFuel/Views/Profile/ProfileStore.swift
ios/BearFuel/Views/Profile/ProfileView.swift
ios/BearFuel/Views/Today/PlanStore.swift
ios/BearFuel/Views/Today/TodayView.swift
ios/BearFuel/Views/Today/TodayViewModel.swift
ios/project.yml
README.md
```

### Dependencies

- backend/requirements.txt: anthropic@>=0.28.0, beautifulsoup4@>=4.12.0, fastapi@>=0.111.0, httpx@>=0.27.0, lxml@>=5.2.0, pydantic@>=2.7.0, python-dotenv@>=1.0.0, requests@>=2.31.0, uvicorn[standard]@>=0.29.0

### Recent commits (newest first)

- Add logout action to profile view
- Refactor app flow and streamline state handling
- Functional changes
- Revert "feat: Berkeley-themed UI redesign with animated components"
- feat: Berkeley-themed UI redesign with animated components
- Fixed bugs
- update app version
- version 3 codex
- added version 2
- Wrote version 1
- Update README.md
- Update README.md
- Initial commit

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

### backend/requirements.txt

```
fastapi>=0.111.0
uvicorn[standard]>=0.29.0
pydantic>=2.7.0
anthropic>=0.28.0
httpx>=0.27.0
requests>=2.31.0
beautifulsoup4>=4.12.0
lxml>=5.2.0
python-dotenv>=1.0.0

```

### backend/main.py

```python
from __future__ import annotations

import json
import os
from datetime import date
from pathlib import Path

from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware

load_dotenv()

from models import MenuItem, MealPlan, PlanRequest
from planner import generate_plan
from scraper import scrape_menu

CACHE_DIR = Path(__file__).parent / "cache"
DEMO_MODE = os.getenv("DEMO_MODE", "false").lower() == "true"
DEMO_CACHE_FILE = CACHE_DIR / "menu_demo.json"

app = FastAPI(title="BearFuel API", version="0.1.0")

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_methods=["*"],
    allow_headers=["*"],
)


def _load_menu_cache(target_date: str, location: str | None = None) -> dict:
    if DEMO_MODE:
        cache_file = DEMO_CACHE_FILE
    else:
        cache_file = CACHE_DIR / f"menu_{target_date}.json"
        if not cache_file.exists():
            # Fall back to demo seed if no live cache
            cache_file = DEMO_CACHE_FILE

    if not cache_file.exists():
        raise HTTPException(status_code=503, detail="Menu cache not available")

    with open(cache_file) as f:
        data = json.load(f)

    if location and location != "all":
        data["items"] = [
            item for item in data.get("items", [])
            if item.get("location", "").lower() == location.lower()
        ]

    return data


@app.get("/health")
async def health():
    demo_cache_exists = DEMO_CACHE_FILE.exists()
    today = date.today().isoformat()
    live_cache_exists = (CACHE_DIR / f"menu_{today}.json").exists()
    return {
        "status": "ok",
        "demo_mode": DEMO_MODE,
        "demo_cache": demo_cache_exists,
        "live_cache_today": live_cache_exists,
        "date": today,
    }


@app.get("/menu", response_model=list[MenuItem])
async def get_menu(
    menu_date: str = Query(default="today", alias="date"),
    location: str = Query(default="all"),
):
    from datetime import date as _date
    target_date = _date.today().isoformat() if menu_date == "today" else menu_date

    # If live cache is missing for this date, try a live scrape (unless DEMO_MODE)
    if not DEMO_MODE:
        cache_file = CACHE_DIR / f"menu_{target_date}.json"
        if not cache_file.exists():
            import asyncio, logging
            logging.getLogger(__name__).info("No cache for %s — triggering scrape", target_date)
            await asyncio.get_event_loop().run_in_executor(None, scrape_menu, target_date)

    data = _load_menu_cache(target_date, location if location != "all" else None)
    items = data.get("items", [])
    return [MenuItem(**item) for item in items]


@app.post("/scrape")
async def trigger_scrape(menu_date: str = Query(default="today", alias="date")):
    """Admin endpoint to populate the menu cache for a given date."""
    from datetime import date as _date
    import asyncio
    target_date = _date.today().isoformat() if menu_date == "today" else menu_date
    items = await asyncio.get_event_loop().run_in_executor(None, scrape_menu, target_date)
    return {
        "scraped": len(items),
        "locations": list({i.location for i in items}),
        "date": target_date,
    }


@app.post("/plan", response_model=MealPlan)
async def create_plan(request: PlanRequest):
    from datetime import date as _date
    target_date = request.date if request.date else _date.today().isoformat()

    # Load menu from cache
    data = _load_menu_cache(target_date)
    all_items = [MenuItem(**item) for item in data.get("items", [])]

    plan = await generate_plan(request.profile, all_items, target_date)
    return plan

```

### backend/seed_cache.py

```python
#!/usr/bin/env python3
"""
Copy the demo seed to today's dated cache file, useful when running
the backend without a live scrape or in DEMO_MODE=false environments.

Usage: python seed_cache.py
"""
import json
import shutil
from datetime import date
from pathlib import Path

CACHE_DIR = Path(__file__).parent / "cache"
DEMO_FILE = CACHE_DIR / "menu_demo.json"


def seed_today() -> None:
    today = date.today().isoformat()
    dest = CACHE_DIR / f"menu_{today}.json"
    with open(DEMO_FILE) as f:
        data = json.load(f)
    data["date"] = today
    data["source"] = "seed_demo"
    with open(dest, "w") as f:
        json.dump(data, f, indent=2)
    print(f"Seeded {dest}")


if __name__ == "__main__":
    seed_today()

```

### ios/project.yml

```yaml
name: BearFuel
options:
  bundleIdPrefix: com.bearfuel
  deploymentTarget:
    iOS: "17.0"
  xcodeVersion: "15.0"

settings:
  base:
    SWIFT_VERSION: 5.9
    DEVELOPMENT_TEAM: ""
    CODE_SIGN_STYLE: Automatic

targets:
  BearFuel:
    type: application
    platform: iOS
    deploymentTarget: "17.0"
    sources:
      - path: BearFuel
    resources:
      - path: BearFuel/Resources
    info:
      path: BearFuel/Info.plist
      properties:
        CFBundleDisplayName: BearFuel
        CFBundleShortVersionString: "1.0"
        CFBundleVersion: "1"
        UILaunchScreen:
          UIColorName: ""
        NSAppTransportSecurity:
          NSAllowsLocalNetworking: true
        UIApplicationSceneManifest:
          UIApplicationSupportsMultipleScenes: false

```

### backend/nutrition.py

```python
"""
NutritionFallback: Claude-estimated macros for dishes missing a scraped nutrition block.
This module is rarely called — real Berkeley dining data covers nearly every item.
Results are disk-cached by dish name and always flagged estimated=true.
"""
from __future__ import annotations

import json
import os
from pathlib import Path

import anthropic

from models import NutritionInfo

_FALLBACK_CACHE_FILE = Path(__file__).parent / "cache" / "nutrition_fallback_cache.json"
MODEL = os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4-6")


def _load_fallback_cache() -> dict:
    if _FALLBACK_CACHE_FILE.exists():
        with open(_FALLBACK_CACHE_FILE) as f:
            return json.load(f)
    return {}


def _save_fallback_cache(cache: dict) -> None:
    with open(_FALLBACK_CACHE_FILE, "w") as f:
        json.dump(cache, f, indent=2)


async def estimate_nutrition(dish_name: str, serving_desc: str = "") -> NutritionInfo:
    cache = _load_fallback_cache()
    key = f"{dish_name}|{serving_desc}"
    if key in cache:
        data = cache[key]
        return NutritionInfo(**data)

    client = anthropic.AsyncAnthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
    prompt = (
        f"Estimate the nutrition facts for: {dish_name}"
        + (f" (serving: {serving_desc})" if serving_desc else "")
        + "\n\nRespond ONLY with a JSON object with keys: "
        "kcal, protein_g, carb_g, fat_g, fiber_g, sugar_g, sodium_mg. "
        "Use typical values for a university dining hall preparation."
    )

    try:
        response = await client.messages.create(
            model=MODEL,
            max_tokens=256,
            timeout=15.0,
            messages=[{"role": "user", "content": prompt}],
        )
        raw = response.content[0].text.strip()
        if raw.startswith("```"):
            raw = raw.split("```")[1]
            if raw.startswith("json"):
                raw = raw[4:]
        data = json.loads(raw)
        nutrition = NutritionInfo(
            kcal=float(data.get("kcal", 200)),
            protein_g=float(data.get("protein_g", 8)),
            carb_g=float(data.get("carb_g", 25)),
            fat_g=float(data.get("fat_g", 7)),
            fiber_g=float(data.get("fiber_g", 2)),
            sugar_g=float(data.get("sugar_g", 3)),
            sodium_mg=float(data.get("sodium_mg", 300)),
            estimated=True,
            confidence="low",
        )
        cache[key] = nutrition.model_dump()
        _save_fallback_cache(cache)
        return nutrition
    except Exception:
        return NutritionInfo(
            kcal=200, protein_g=8, carb_g=25, fat_g=7,
            estimated=True, confidence="low"
        )

```

### backend/models.py

```python
from __future__ import annotations
from typing import Literal, Optional
from pydantic import BaseModel, Field


class NutritionInfo(BaseModel):
    kcal: float
    protein_g: float
    carb_g: float
    fat_g: float
    fiber_g: float = 0.0
    sugar_g: float = 0.0
    sodium_mg: float = 0.0
    sat_fat_g: float = 0.0
    trans_fat_g: float = 0.0
    cholesterol_mg: float = 0.0
    estimated: bool = False
    confidence: Literal["high", "medium", "low"] = "high"


DietFlag = Literal["vegan", "vegetarian", "halal", "kosher"]
AllergenFlag = Literal[
    "milk", "egg", "fish", "shellfish", "treenut",
    "wheat", "peanut", "soy", "sesame", "gluten", "pork", "alcohol"
]
CarbonRating = Literal["low", "medium", "high"]
MealPeriod = Literal["Brunch", "Lunch", "Dinner", "All Day"]
ActivityLevel = Literal["sedentary", "light", "moderate", "active", "very_active"]
GoalType = Literal["cut", "maintain", "lean_gain", "recomp", "athletic_performance"]
Sex = Literal["male", "female", "unspecified"]


class MenuItem(BaseModel):
    id: str
    name: str
    station: str
    location: str
    meal_period: MealPeriod
    date: str
    diet_flags: list[DietFlag] = Field(default_factory=list)
    allergens: list[AllergenFlag] = Field(default_factory=list)
    carbon: Optional[CarbonRating] = None
    carbon_kg_co2: Optional[float] = None
    serving_desc: str = ""
    nutrition: NutritionInfo


class UserProfile(BaseModel):
    height_cm: float = Field(ge=100, le=250)
    weight_kg: float = Field(ge=30, le=300)
    age: int = Field(ge=13, le=100)
    sex: Sex = "unspecified"
    activity_level: ActivityLevel = "moderate"
    goal_type: GoalType = "maintain"
    goal_weight_kg: Optional[float] = None
    goal_timeline_weeks: Optional[int] = Field(default=None, ge=1, le=104)
    meals_per_day: int = Field(default=3, ge=1, le=6)
    diet_restrictions: list[DietFlag] = Field(default_factory=list)
    exclude_allergens: list[AllergenFlag] = Field(default_factory=list)
    preferred_locations: list[str] = Field(default_factory=list)
    disliked_foods: list[str] = Field(default_factory=list)


class MacroTotals(BaseModel):
    kcal: float
    protein_g: float
    carb_g: float
    fat_g: float


class MacroTargets(MacroTotals):
    pass


class MealSlot(BaseModel):
    label: str
    location: str
    items: list[str]  # MenuItem ids
    totals: MacroTotals
    rationale: str


class MealPlan(BaseModel):
    date: str
    targets: MacroTargets
    meals: list[MealSlot]
    day_totals: MacroTotals
    notes: str = ""
    warnings: list[str] = Field(default_factory=list)
    disclaimer: str = (
        "Nutrition from UC Berkeley dining data; may vary. Not medical advice."
    )


class PlanRequest(BaseModel):
    profile: UserProfile
    date: str = ""
    meal_periods: list[MealPeriod] = Field(default_factory=list)


class HealthWarning(BaseModel):
    code: str
    message: str
    severity: Literal["info", "warning", "error"]

```

### backend/scraper.py

```python
"""
Berkeley Dining scraper.

Discovery: fetch the main /menus/ page, extract data-location base64 attributes
→ decode to XML file paths → fetch each XML.

Data: each XML is EatecExchange format with full USDA-derived nutrition for every
recipe in pipe-delimited <nutrients> attrs. One request per location gives every
dish + macro for a given date. No AJAX, no N+1 calls, no estimation needed.
"""
from __future__ import annotations

import base64
import hashlib
import json
import logging
import re
from datetime import date as _date
from pathlib import Path
from typing import Optional
from xml.etree import ElementTree as ET

import requests

from models import MenuItem, NutritionInfo

logger = logging.getLogger(__name__)

CACHE_DIR = Path(__file__).parent / "cache"
BASE_URL = "https://dining.berkeley.edu"
MENUS_PAGE = f"{BASE_URL}/menus/"
HEADERS = {
    "User-Agent": (
        "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
        "AppleWebKit/537.36 (KHTML, like Gecko) "
        "Chrome/124.0 Safari/537.36"
    )
}

# Allergen <allergen id="..."> → our model key
ALLERGEN_MAP: dict[str, str] = {
    "Milk": "milk",
    "Egg": "egg",
    "Fish": "fish",
    "Shellfish": "shellfish",
    "Tree Nuts": "treenut",
    "Wheat": "wheat",
    "Peanuts": "peanut",
    "Soybeans": "soy",
    "Gluten": "gluten",
    "Alcohol": "alcohol",
    "Sesame": "sesame",
    "Pork": "pork",
}

# <dietaryChoice id="..."> → our model key
DIET_FLAG_MAP: dict[str, str] = {
    "Vegan Option": "vegan",
    "Vegetarian Option": "vegetarian",
    "Halal": "halal",
    "Kosher": "kosher",
}

# Normalize meal period names (may contain season prefix like "Summer - Dinner")
def _normalize_meal_period(raw: str) -> str:
    raw_lower = raw.lower()
    if "breakfast" in raw_lower or "brunch" in raw_lower:
        return "Brunch"
    if "lunch" in raw_lower:
        return "Lunch"
    if "dinner" in raw_lower:
        return "Dinner"
    return "All Day"


def _carbon_tier(kg_co2: Optional[float]) -> Optional[str]:
    if kg_co2 is None:
        return None
    if kg_co2 <= 0.08:
        return "low"
    if kg_co2 <= 0.20:
        return "medium"
    return "high"


def _make_id(name: str, station: str, location: str, target_date: str) -> str:
    key = f"{name}|{station}|{location}|{target_date}"
    return hashlib.md5(key.encode()).hexdigest()[:16]


def _discover_xml_urls(target_date: str) -> list[str]:
    """
    Fetch the /menus/ page and decode the data-location base64 attributes
    to obtain the list of XML URLs for the given date.
    """
    try:
        r = requests.get(MENUS_PAGE, headers=HEADERS, timeout=20)
        r.raise_for_status()
    except Exception as e:
        logger.error("Failed to fetch menus page: %s", e)
        return _fallback_xml_urls(target_date)

    locations_b64 = re.findall(r'data-location=["\']([A-Za-z0-9+/=]+)["\']', r.text)
    urls: set[str] = set()
    for b64 in set(locations_b64):
        try:
            path = base64.b64decode(b64 + "==").decode()
            # Strip the internal /code prefix and build the public URL
            url_path = path.replace("/code", "")
            urls.add(BASE_URL + url_path)
        except Exception:
            continue

    if not urls:
        logger.warning("No XML URLs discovered from menu page; using fallback list")
        return _fallback_xml_urls(target_date)

    return sorted(urls)


def _fallback_xml_urls(target_date: str) -> list[str]:
    """Known location names when discovery fails."""
    date_str = target_date.replace("-", "")
    known_locations = ["Crossroads", "ClarkKerr", "Foothill", "Cafe3", "Den", "Unit1"]
    base_path = f"{BASE_URL}/wp-content/uploads/menus-exportimport"
    urls = []
    for loc in known_locations:
        url = f"{base_path}/{loc}_{date_str}.xml"
        try:
            r = requests.head(url, headers=HEADERS, timeout=5)
            if r.status_code == 200:
                urls.append(url)
        except Exception:
            continue
    return urls


def _parse_nutrient_cols(menu_elem: ET.Element) -> list[str]:
    nutrients_elem = menu_elem.find("nutrients")
    if nutrients_elem is None or not nutrients_elem.text:
        return []
    return [c.strip() for c in nutrients_elem.text.split("|") if c.strip()]


MAX_SANE_KCAL = 2500   # single serving above this is a data entry error
MAX_NAME_LEN = 80      # longer names are usually venue descriptions, not dishes


def _parse_recipe(
    recipe_elem: ET.Element,
    nutrient_cols: list[str],
    location: str,
    meal_period: str,
    target_date: str,
) -> Optional[MenuItem]:
    name = recipe_elem.get("shortName", "").strip()
    if not name:
        name = recipe_elem.get("description", "").strip()
    if not name:
        return None

    # Skip venue blurbs (descriptions masquerading as recipes)
    if len(name) > MAX_NAME_LEN:
        return None

    station = recipe_elem.get("category", "").strip()
    serving_size = recipe_elem.get("servingSize", "")
    serving_unit = recipe_elem.get("servingSizeUnit", "")
    serving_desc = f"{serving_size} {serving_unit}".strip() if serving_size else ""

    # Parse pipe-delimited nutrient values
    raw_nutrients = recipe_elem.get("nutrients", "")
    vals = raw_nutrients.split("|")

    def _val(col_name: str) -> float:
        try:
            idx = next(i for i, c in enumerate(nutrient_cols) if col_name in c)
            v = vals[idx] if idx < len(vals) else ""
            return float(v) if v else 0.0
        except (StopIteration, ValueError):
            return 0.0

    kcal = _val("Calories")
    fat_g = _val("Total Lipid")
    sat_fat_g = _val("Saturated fatty")
    trans_fat_g = _val("Trans Fat")
    cholesterol_mg = _val("Cholesterol")
    sodium_mg = _val("Sodium")
    carb_g = _val("Carbohydrate")
    fiber_g = _val("Dietary Fiber")
    sugar_g = _val("Sugar")
    protein_g = _val("Protein")
    carbon_kg = _val("Carbon Footprint")

    # Allergens
    allergens: list[str] = []
    for alle
[truncated — 4760 more characters]
```

### ios/BearFuel/BearFuelApp.swift

```swift
import SwiftUI

@main
struct BearFuelApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

```

### ios/BearFuel/Config.swift

```swift
import Foundation

enum Config {
    // Point at localhost for Simulator, swap to deployed URL for device
    static let baseURL = URL(string: "http://localhost:8000")!
    static let apiTimeout: TimeInterval = 60
}

extension Notification.Name {
    static let bearfuelRegeneratePlan = Notification.Name("bearfuel.regeneratePlan")
}

```

### backend/planner.py

```python
from __future__ import annotations

import json
import os
from pathlib import Path
from typing import Optional

import anthropic

from models import (
    ActivityLevel, GoalType, MealPlan, MacroTargets, MacroTotals,
    MealSlot, MenuItem, Sex, UserProfile, HealthWarning
)

_client: Optional[anthropic.AsyncAnthropic] = None

PROMPTS_DIR = Path(__file__).parent / "prompts"
MODEL = os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4-6")

ACTIVITY_MULTIPLIERS: dict[ActivityLevel, float] = {
    "sedentary": 1.2,
    "light": 1.375,
    "moderate": 1.55,
    "active": 1.725,
    "very_active": 1.9,
}

CALORIE_FLOORS = {"male": 1500.0, "female": 1200.0, "unspecified": 1350.0}
MAX_DEFICIT_PCT = 0.20
MAX_SURPLUS_PCT = 0.10
LOW_BMI_THRESHOLD = 17.5
KCAL_PER_KG = 7700.0
MAX_WEEKLY_LOSS_KG = 0.75
MAX_WEEKLY_GAIN_KG = 0.35
MAX_AI_CANDIDATES = 64

MEAL_LABELS_BY_COUNT = {
    1: ["Dinner"],
    2: ["Brunch", "Dinner"],
    3: ["Brunch", "Lunch", "Dinner"],
    4: ["Brunch", "Lunch", "Dinner", "Late Snack"],
}

CATEGORY_KEYWORDS: list[tuple[str, tuple[str, ...]]] = [
    ("protein", ("chicken", "beef", "pork", "turkey", "fish", "salmon", "egg", "tofu", "bean", "lentil", "yogurt")),
    ("grain", ("rice", "quinoa", "pasta", "noodle", "bagel", "bread", "roll", "tortilla", "oat")),
    ("vegetable", ("salad", "greens", "bok choy", "broccoli", "spinach", "vegetable", "carrot", "pepper")),
    ("sauce", ("dressing", "vinaigrette", "sauce", "salsa", "gravy")),
    ("dessert", ("cake", "cookie", "brownie", "pie", "dessert", "pudding")),
    ("topping", ("seed", "almond", "nut", "granola")),
]

VISUAL_SYMBOL_BY_CATEGORY = {
    "protein": "fork.knife",
    "grain": "takeoutbag.and.cup.and.straw.fill",
    "vegetable": "leaf.fill",
    "sauce": "drop.fill",
    "dessert": "birthday.cake.fill",
    "topping": "circle.hexagongrid.fill",
    "other": "fork.knife.circle.fill",
}


def _get_client() -> anthropic.AsyncAnthropic:
    global _client
    if _client is None:
        _client = anthropic.AsyncAnthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
    return _client


def compute_bmr(profile: UserProfile) -> float:
    if profile.sex == "male":
        return 10 * profile.weight_kg + 6.25 * profile.height_cm - 5 * profile.age + 5
    elif profile.sex == "female":
        return 10 * profile.weight_kg + 6.25 * profile.height_cm - 5 * profile.age - 161
    else:
        return 10 * profile.weight_kg + 6.25 * profile.height_cm - 5 * profile.age - 78


def compute_targets(profile: UserProfile) -> tuple[MacroTargets, list[HealthWarning]]:
    warnings: list[HealthWarning] = []

    bmr = compute_bmr(profile)
    tdee = bmr * ACTIVITY_MULTIPLIERS[profile.activity_level]
    bmi = profile.weight_kg / ((profile.height_cm / 100) ** 2)

    # Low BMI guard: do not allow deficit plans
    if bmi < LOW_BMI_THRESHOLD and profile.goal_type == "cut":
        warnings.append(HealthWarning(
            code="low_bmi_cut_blocked",
            message=(
                "Your BMI suggests you should not be in a calorie deficit. "
                "We've switched to a maintenance plan. Please consider speaking with "
                "a Berkeley campus dietitian: https://dining.berkeley.edu/dietitian/"
            ),
            severity="warning",
        ))
        profile = profile.model_copy(update={"goal_type": "maintain"})

    goal_adjustments: dict[GoalType, float] = {
        "cut": -0.175,
        "maintain": 0.0,
        "lean_gain": 0.075,
        "recomp": 0.0,
        "athletic_performance": 0.10,
    }
    adj = goal_adjustments[profile.goal_type]

    if profile.goal_weight_kg is not None and profile.goal_timeline_weeks:
        delta_kg = profile.goal_weight_kg - profile.weight_kg
        weekly_delta = delta_kg / profile.goal_timeline_weeks

        if profile.goal_type == "cut" and delta_kg > 0.25:
            warnings.append(HealthWarning(
                code="goal_direction_conflict",
                message="Your goal weight is above your current weight, so the timeline does not match a cut goal.",
                severity="warning",
            ))
        elif profile.goal_type == "lean_gain" and delta_kg < -0.25:
            warnings.append(HealthWarning(
                code="goal_direction_conflict",
                message="Your goal weight is below your current weight, so the timeline does not match a lean gain goal.",
                severity="warning",
            ))

        if weekly_delta < -MAX_WEEKLY_LOSS_KG:
            warnings.append(HealthWarning(
                code="goal_timeline_too_aggressive",
                message=(
                    f"Your requested timeline implies losing about {abs(weekly_delta):.1f} kg/week. "
                    f"The plan is capped near {MAX_WEEKLY_LOSS_KG:.2f} kg/week for safety."
                ),
                severity="warning",
            ))
        elif weekly_delta > MAX_WEEKLY_GAIN_KG:
            warnings.append(HealthWarning(
                code="goal_timeline_too_aggressive",
                message=(
                    f"Your requested timeline implies gaining about {weekly_delta:.1f} kg/week. "
                    f"The plan is capped near {MAX_WEEKLY_GAIN_KG:.2f} kg/week for a more sustainable pace."
                ),
                severity="warning",
            ))

        daily_kcal_adjustment = delta_kg * KCAL_PER_KG / (profile.goal_timeline_weeks * 7)
        adj = daily_kcal_adjustment / max(tdee, 1)

    # Clamp adjustments to safety bounds
    if adj < -MAX_DEFICIT_PCT:
        warnings.append(HealthWarning(
            code="deficit_clamped",
            message="The requested deficit was above the app's safety limit, so calories were raised.",
            severity="warning",
        ))
        adj = -MAX_DEFICIT_PCT
    if adj > MAX_SURPLUS_PCT:
        warnings.append(HealthWarning(
            code="surplus_clamped",
            message="The requested surplus was above the app's safety limit, so calories were lowered.",
         
[truncated — 22879 more characters]
```

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