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:
- Your OpenAI key is hardcoded in
main.pyline 50. That file is in a public-ish class repo. We'll move this key to.envin Phase 1 so that we can make this repository public safely later. - Your
planner.pyfrom the last guide never happened. Everything still lives inmain.py. That's okay, but we'll need to extract it into its own file soapp.pycan 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.pyyourself. The.envstep 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
Hints
Top of planner.py:
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:
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:
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):
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:
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
Hints
Full app.py:
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:
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
Hints
templates/base.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:
{% 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:
{% 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"
>
← 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):
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
Hints
What you should see after 3 submissions:
- First submit → JSON:
{"recent_focus_groups": ["legs_core"]}(for example) - Second submit → JSON:
{"recent_focus_groups": ["legs_core", "back_bicep"]} - Third submit → JSON:
{"recent_focus_groups": ["back_bicep", "cardio"]}— note thatlegs_coreis now OUT of the list (only 2 kept), and the picked group won't beback_bicepeither.
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
Hints
project.spec.md MVP rewrite:
**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:
# 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
Helpful Resources
- Checkpoint 2 Instructions
- Lecture 1: The MVP
- Flask Setup Guide — for a slower walkthrough of routes + templates
- Tailwind CSS — utility classes reference — searchable list of every class used above
- python-dotenv docs — for Phase 1