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

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"
  >
    &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):

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:

  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

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

Checkpoint 2 Readiness

By Thursday April 23 at 3pm:

Helpful Resources