> Source URL: /unit-3/project-paths/duward-a/duward-a-2026-04-21.guide
# Duward's Project Guide

**Project:** GPT Workout Planner
**Category:** AI / Mobile Web App (Flask + Tailwind)
**Last updated:** April 21

---

> Note: This guide reflects the latest state of your project repo. It may not match the most up-to-date version if you've worked since.

## Where You Are

A couple things to address before working on the web app:

1. **Your OpenAI key is hardcoded in `main.py` line 50.** That file is in a public-ish class repo. We'll move this key to `.env` in Phase 1 so that we can make this repository public safely later.
2. **Your `planner.py` from the last guide never happened.** Everything still lives in `main.py`. That's okay, but we'll need to extract it into its own file so `app.py` can call it properly.

---

## Project Structure

Your project splits into two kinds of code:

- **Business logic (your code)** Everything in `planner.py` (NEW): loading workouts, picking a focus group that avoids repeats, building the prompt, validating the goal.
- **Library / view code (glue code agent can help with)** Flask routes in `app.py`, the OpenAI API call, the HTML templates, Tailwind classes. This plumbing is the same for any Flask app.

Example target layout:

```
final-project-angldu6/
├── app.py                          ← Flask routes + OpenAI call
├── planner.py                      ← business logic
├── pyproject.toml
├── templates/
│   ├── base.html                   ← Tailwind mobile layout — agent OK
│   ├── home.html                   ← goal + location form — agent OK
│   └── result.html                 ← show the plan — agent OK
├── workouts/
│   ├── options.json                (already done)
│   └── previous_workouts.json      (already done)
├── .env                            ← OPENAI_API_KEY (NOT committed)
└── .gitignore                      ← must include .env
```

---

## Phase 1: Move your key, then extract `planner.py`

> **Handwrite `planner.py` yourself.** The `.env` step is standard library setup.

### Objective

Let's move the `OPENAI_API_KEY` into its own `.env` file to make sure it doesn't leak when we make your project public.

### Instructions

- [ ] `uv add python-dotenv`
- [ ] Create `.env` at the project root with a single line: `OPENAI_API_KEY=sk-...class-api-key-here...`
- [ ] Make sure `.gitignore` has `.env` in it (add it if missing)
- [ ] Create `planner.py` at the project root
- [ ] Move these functions from `main.py` into `planner.py`: `load_options`, `load_previous_groups`, `save_previous_groups`, `build_prompt`
- [ ] Add three new functions to `planner.py`: `is_valid_goal(goal)`, `pick_focus_group(recent_groups)`, `workouts_for_group(options, location_key, group_name)`
- [ ] Move the `FOCUS_GROUPS` dict from inside `main()` to the top of `planner.py`
- [ ] **Delete** the hardcoded `api_key` line from `main.py` entirely

### Hints

**Top of `planner.py`:**

```python
import json
import random

OPTIONS_PATH = "workouts/options.json"
PREVIOUS_WORKOUTS_PATH = "workouts/previous_workouts.json"

FOCUS_GROUPS = {
    "chest_tricep_shoulders": ["chest", "tricep", "shoulders"],
    "back_bicep": ["back", "bicep"],
    "legs_core": ["legs", "core"],
    "cardio": ["cardio"],
}
```

**`is_valid_goal`:**

```python
def is_valid_goal(goal):
    goal = goal.strip()
    if not goal:
        return False
    if len(goal) < 3:
        return False
    if goal.isdigit():
        return False
    return True
```

**`pick_focus_group`:**

```python
def pick_focus_group(recent_groups):
    # Prefer groups that aren't in the last 2
    available = [g for g in FOCUS_GROUPS if g not in recent_groups]
    # If every group was recent, reset and pick from all of them
    if not available:
        available = list(FOCUS_GROUPS.keys())
    return random.choice(available)
```

**`workouts_for_group`:**

**`workouts_for_group` skeleton (fill in the loop):**

```python
def workouts_for_group(options, location_key, group_name):
    focus_area_map = options["workout_categories"][location_key]["focus_areas"]
    workouts = []
    # Loop through FOCUS_GROUPS[group_name] and extend workouts from focus_area_map
    # ... your code here ...
    return workouts
```

**`build_prompt` stays the same** — just move it over from `main.py` unchanged.

> **Optional — get help from your agent:**
>
> ```text
> Here is my workouts_for_group function in planner.py. Walk me through
> what it returns when I pass location_key="workouts_in_the_gym" and
> group_name="legs_core". Don't change my code — I want to verify my
> mental model before I hook it into Flask.
> ```

---

## Phase 2: `uv add flask` and stand up `app.py`

> **Agent-assisted is fine here** since this is library/glue code.

### Objective

Replace `main.py` (the CLI) with `app.py` (the Flask web app). Two routes: one to show the form, one to generate the plan.

### Instructions

- [ ] `uv add flask`
- [ ] Create `app.py` at the project root
- [ ] Add `GET /` → renders `home.html` with the form
- [ ] Add `POST /generate` → reads the form, calls `planner.is_valid_goal`, picks a focus group, builds the prompt, calls OpenAI, saves memory, renders `result.html`
- [ ] Delete `main.py` (or leave it only as a comment saying "moved to app.py")
- [ ] Run `uv run flask --app app run --debug` and confirm it starts on `http://127.0.0.1:5000`

### Hints

**Full `app.py`:**

```python
from flask import Flask, render_template, request
from dotenv import load_dotenv
from openai import OpenAI

from planner import (
    load_options,
    load_previous_groups,
    save_previous_groups,
    pick_focus_group,
    workouts_for_group,
    build_prompt,
    is_valid_goal,
)

load_dotenv()  # reads OPENAI_API_KEY from .env into the environment
app = Flask(__name__)


LOCATION_LABELS = {
    "workouts_in_the_gym": "Gym",
    "workouts_outside_of_the_gym": "Outside Gym",
}


@app.route("/")
def home():
    return render_template("home.html")


@app.route("/generate", methods=["POST"])
def generate():
    goal = request.form.get("goal", "")
    location_key = request.form.get("location", "workouts_in_the_gym")

    if not is_valid_goal(goal):
        return render_template(
            "home.html",
            error="Please enter a real goal (example: build muscle, lose weight).",
            goal=goal,
        )

    options = load_options()
    recent = load_previous_groups()
    group = pick_focus_group(recent)
    workouts = workouts_for_group(options, location_key, group)

    location_name = LOCATION_LABELS.get(location_key, "Gym")
    prompt = build_prompt(goal, location_name, group, workouts)

    client = OpenAI()  # picks up OPENAI_API_KEY from env
    response = client.responses.create(model="gpt-4.1-mini", input=prompt)
    plan = response.output_text.strip()

    # Update memory: keep only the last 2 groups
    recent.append(group)
    save_previous_groups(recent[-2:])

    return render_template(
        "result.html",
        goal=goal,
        location_name=location_name,
        group=group.replace("_", " "),
        plan=plan,
    )
```

> **Optional — get help from your agent:**
>
> ```text
> Read my app.py and planner.py. Explain in plain English what happens
> step-by-step when a user submits the form on /generate. Start from
> "Flask receives the POST request" and end with "the browser renders
> result.html". Don't change my code.
> ```

---

## Phase 3: Mobile-first UI with Tailwind

> **Agent-assisted is fine here.**

### Objective

Three templates that look good on a phone: `base.html` for the shared shell, `home.html` for the form, `result.html` for the plan.

### Instructions

- [ ] Create `templates/` folder at the project root
- [ ] Create `templates/base.html` with the Tailwind CDN and a mobile viewport meta tag
- [ ] Create `templates/home.html` that extends `base.html` and has the form
- [ ] Create `templates/result.html` that extends `base.html` and shows the plan
- [ ] Open the app in your phone's browser (or Chrome DevTools mobile preview) and confirm it looks good at phone widths

### Hints

**`templates/base.html`:**

```html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>{% block title %}GPT Workout Planner{% endblock %}</title>
    <script src="https://cdn.tailwindcss.com"></script>
  </head>
  <body class="bg-slate-50 min-h-screen text-slate-800">
    <header class="bg-slate-900 text-white sticky top-0 z-10">
      <div class="max-w-md mx-auto px-4 py-4">
        <h1 class="text-xl font-bold">GPT Workout Planner</h1>
      </div>
    </header>
    <main class="max-w-md mx-auto px-4 py-6">
      {% block content %}{% endblock %}
    </main>
  </body>
</html>
```

The `max-w-md mx-auto` pattern keeps the content narrow and centered — that's what makes it feel like a mobile app even on a desktop browser.

**`templates/home.html`:**

```html
{% extends "base.html" %} {% block content %} {% if error %}
<div
  class="bg-red-100 border border-red-300 text-red-800 rounded-lg px-4 py-3 mb-4 text-sm"
>
  {{ error }}
</div>
{% endif %}

<form method="post" action="/generate" class="space-y-6">
  <div>
    <label for="goal" class="block text-sm font-semibold mb-2">
      What's your workout goal today?
    </label>
    <textarea
      id="goal"
      name="goal"
      rows="3"
      placeholder="e.g. build muscle, lose weight, improve endurance"
      class="w-full rounded-xl border border-slate-300 px-4 py-3 text-base focus:border-slate-900 focus:outline-none focus:ring-2 focus:ring-slate-900"
    >
{{ goal or "" }}</textarea
    >
  </div>

  <div>
    <p class="text-sm font-semibold mb-2">Where are you working out?</p>
    <div class="grid grid-cols-2 gap-3">
      <label
        class="flex items-center justify-center rounded-xl border-2 border-slate-300 py-4 font-medium cursor-pointer has-[:checked]:border-slate-900 has-[:checked]:bg-slate-900 has-[:checked]:text-white"
      >
        <input
          type="radio"
          name="location"
          value="workouts_in_the_gym"
          class="sr-only"
          checked
        />
        Gym
      </label>
      <label
        class="flex items-center justify-center rounded-xl border-2 border-slate-300 py-4 font-medium cursor-pointer has-[:checked]:border-slate-900 has-[:checked]:bg-slate-900 has-[:checked]:text-white"
      >
        <input
          type="radio"
          name="location"
          value="workouts_outside_of_the_gym"
          class="sr-only"
        />
        Outside
      </label>
    </div>
  </div>

  <button
    type="submit"
    class="w-full rounded-xl bg-slate-900 text-white text-lg font-semibold py-4 hover:bg-slate-800 active:bg-slate-700"
  >
    Generate my plan
  </button>
</form>
{% endblock %}
```

The trick here is `has-[:checked]:` — Tailwind styles the `<label>` based on whether the radio inside it is checked. That's how you get "radio card" buttons on mobile that feel tappable and obvious.

**`templates/result.html`:**

```html
{% extends "base.html" %} {% block content %}
<section class="space-y-4">
  <a
    href="/"
    class="inline-flex items-center text-sm text-slate-600 hover:text-slate-900"
  >
    &larr; Generate another
  </a>

  <div
    class="bg-white rounded-2xl shadow-sm border border-slate-200 p-5 space-y-3"
  >
    <div>
      <p class="text-xs uppercase tracking-wide text-slate-500">Goal</p>
      <p class="font-semibold">{{ goal }}</p>
    </div>
    <div class="flex gap-4">
      <div>
        <p class="text-xs uppercase tracking-wide text-slate-500">Location</p>
        <p class="font-semibold">{{ location_name }}</p>
      </div>
      <div>
        <p class="text-xs uppercase tracking-wide text-slate-500">Focus</p>
        <p class="font-semibold capitalize">{{ group }}</p>
      </div>
    </div>
  </div>

  <div class="bg-white rounded-2xl shadow-sm border border-slate-200 p-5">
    <h2 class="text-lg font-bold mb-3">Your Plan</h2>
    <pre class="whitespace-pre-wrap font-sans text-base leading-relaxed">
{{ plan }}</pre
    >
  </div>
</section>
{% endblock %}
```

> **Optional — get help from your agent (once the app is working):**
>
> ```text
> Here are my base.html, home.html, and result.html. Look at them in
> a mobile viewport and suggest 2-3 small polish changes — maybe a
> loading state, a nicer color accent, or a subtle animation. Keep
> the existing Tailwind CDN approach. Show me the diff.
> ```

---

## Phase 4: Confirm the memory feature still works

> **Handwrite the test.** Make sure Flask didn't break it.

### Objective

Verify that `pick_focus_group` + `save_previous_groups` together still avoid repeating the last 2 focus groups across web requests.

### Instructions

- [ ] Open `workouts/previous_workouts.json` — reset it to `{"recent_focus_groups": []}`
- [ ] Start the app: `uv run flask --app app run --debug`
- [ ] Submit the form 3 times in a row with the same goal/location
- [ ] After each submit, check `workouts/previous_workouts.json`
- [ ] Confirm: (a) the file grows, (b) it never holds more than 2 groups, (c) the focus group on screen isn't in the file before you submit

### Hints

**What you should see after 3 submissions:**

1. First submit → JSON: `{"recent_focus_groups": ["legs_core"]}` (for example)
2. Second submit → JSON: `{"recent_focus_groups": ["legs_core", "back_bicep"]}`
3. Third submit → JSON: `{"recent_focus_groups": ["back_bicep", "cardio"]}` — note that `legs_core` is now OUT of the list (only 2 kept), and the picked group won't be `back_bicep` either.

If the JSON grows beyond 2 entries, check `app.py` — the line `save_previous_groups(recent[-2:])` is what caps it.

If the same focus group shows up twice in a row, check `pick_focus_group` in `planner.py` — the `[g for g in FOCUS_GROUPS if g not in recent_groups]` filter is what prevents that.

---

## Phase 5: Update spec, journal, and README

**Write this yourself.**

### Objective

Align the written story with what you actually built.

### Instructions

- [ ] Edit `project.spec.md`: add a line about mobile-first web app
- [ ] Fill the **Checkpoint 2** section of `project.journal.md` — a short paragraph on the CLI→Flask pivot, what you handwrote in `planner.py`, what the agent helped scaffold
- [ ] Update `README.md` with one paragraph + a how-to-run block
- [ ] Commit and push

### Hints

**`project.spec.md` MVP rewrite:**

```markdown
**Must have (MVP):**

- [ ] Mobile-friendly Flask web app (goal + location form → AI-generated plan)
- [ ] Workout generation based on user goal and gym / outside-gym selection
- [ ] Focus groups defined in code; workouts loaded from `workouts/options.json`
- [ ] Memory: don't repeat the last two focus groups across sessions
- [ ] Input validation (empty / too-short / all-digits goals rejected)
- [ ] Styled with Tailwind CSS, tested at mobile widths
```

**README one-paragraph + run block:**

````markdown
# GPT Workout Planner

A mobile-friendly Flask web app that generates beginner-friendly workout plans. You pick a goal and a location (gym or outside), and the app asks an OpenAI model to draft a warm-up / main / cooldown plan using only workouts from a curated list. It remembers the last two focus groups so you don't get the same area two days in a row.

## Run locally

```bash
uv sync
uv run flask --app app run --debug
```

Then open http://127.0.0.1:5000 on your phone or laptop. Requires an `OPENAI_API_KEY` in `.env`.
````

**Commit message idea:**

```
checkpoint 2: flask + tailwind mobile app, planner.py extracted, key moved to .env
```

---

## Checkpoint 2 Readiness

By Thursday April 23 at 3pm:

- [ ] `.env` contains the key; `.env` is in `.gitignore`; old hardcoded key **rotated**
- [ ] `planner.py` exists with `load_options`, `load_previous_groups`, `save_previous_groups`, `build_prompt`, `is_valid_goal`, `pick_focus_group`, `workouts_for_group`, and `FOCUS_GROUPS`
- [ ] `planner.py` does **not** import `flask` or `openai`
- [ ] `app.py` runs via `uv run flask --app app run --debug`
- [ ] `/` shows the form, `/generate` returns a plan
- [ ] Empty / too-short / all-digit goals show an error on the form
- [ ] App looks clean at a phone width (test in Chrome DevTools responsive mode)
- [ ] Memory file still gets updated; never holds more than 2 groups
- [ ] Checkpoint 2 entry in `project.journal.md`
- [ ] `project.spec.md` MVP updated to include the web app
- [ ] `README.md` has a short description + how-to-run
- [ ] Committed and pushed

---

## Helpful Resources

- [Checkpoint 2 Instructions](../../projects/final-project-checkpoint-2.project.md)
- [Lecture 1: The MVP](../../lectures/01-the-mvp/01-the-mvp.lecture.md)
- [Flask Setup Guide](../../resources/flask-setup.guide.md) — for a slower walkthrough of routes + templates
- [Tailwind CSS — utility classes reference](https://tailwindcss.com/docs/utility-first) — searchable list of every class used above
- [python-dotenv docs](https://pypi.org/project/python-dotenv/) — for Phase 1


---

## Backlinks

The following sources link to this document:

- [April 21 -- CLI → Flask Mobile Web App](/unit-3/project-paths/duward-a/duward-a.path.llm.md)
