From Flask Monolith to Multiplatform App: Migration Journey Part 3

learningpythonreactdocs

From Read-Only to Full Feature Parity

*Follow-up to Journey Part 2

Table of Contents

  1. What we're building
  2. New hooks — the full CRUD toolkit
  3. ExerciseForm — dynamic fields done right
  4. WorkoutExerciseForm — the smart form
  5. Milestone 3: Exercises & categories pages
  6. Milestone 4: Workout & template CRUD
  7. Milestone 5: Social — explore & follow
  8. Milestone 6: Messages & profile
  9. Backend changes
  10. The bugs that bit us
  11. Files changed across all milestones

1. What we're building

Milestone 2 proved the Next.js ↔ Flask data flow works. You could view workouts. But the Jinja2 frontend still ran circles around us — you couldn't create workouts, manage exercises, edit templates, follow users, or send messages from the Next.js app.

Milestones 3–6 close every gap, turning the Next.js frontend from a read-only viewer into a full replacement for the Jinja2 frontend:

MilestoneWhat it adds
3Exercises CRUD, categories management
4Workout create/edit, template create/edit, "load from template"
5Explore feed, user profiles, follow/unfollow
6Direct messages, user profile editing

By the end, every feature in the Flask/Jinja2 app has a Next.js equivalent — and several features are better (smart exercise forms, category filter chips, counting-type-aware inputs).


2. New hooks — the full CRUD toolkit

Milestone 2 gave us three hook files. Now we have nine, covering every API resource. The pattern is always the same: useQuery for reads, useMutation with cache invalidation for writes.

Analogy: Think of each hook file as a specialized librarian (from the Milestone 2 analogy). The workouts librarian handles workout requests. The exercises librarian handles exercise requests. They all use the same library card system (React Query), but each knows their own section of the stacks.

Hook file overview

FileQueriesMutations
use-workouts.tsuseWorkouts, useWorkoutuseCreateWorkout, useUpdateWorkout, useToggleDone, useDeleteWorkout
use-exercises.tsuseExercises, useExerciseuseCreateExercise, useUpdateExercise, useDeleteExercise, useCopyExercise
use-categories.tsuseCategoriesuseCreateCategory, useRenameCategory, useDeleteCategory
use-templates.tsuseTemplatesuseCreateTemplate, useUpdateTemplate, useDeleteTemplate, useUseTemplate
use-social.tsuseExplore, useUserProfileuseFollow, useUnfollow
use-messages.tsuseMessagesuseSendMessage
use-profile.tsuseProfile
use-update-profile.tsuseUpdateProfile
use-notifications.tsuseNotifications, useUnreadMessageCount

Pattern: create + update mutations

Every create/update mutation follows this template:

export function useCreateWorkout() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: (data: {
      title: string;
      exercises: unknown[];
    }) =>
      api.post<ApiResponse<Workout>>("/workouts", data),
    onSuccess: () => {
      queryClient.invalidateQueries({
        queryKey: ["workouts"],
      });
    },
  });
}

The onSuccess callback invalidates the cache, which triggers a background refetch of any mounted query whose key starts with ["workouts"]. This means the list page updates automatically after you create a new workout — no manual state management needed.

Pattern: parameterized queries

The exercises hook demonstrates parameterized queries — the same hook fetches different data based on filters:

export function useExercises(
  page: number = 1,
  userFilter: string = "mine",
  categoryIds?: number[]
) {
  const params: Record<string, string> = {
    page: String(page),
    user: userFilter,
  };
  if (categoryIds && categoryIds.length > 0) {
    params.category = categoryIds.join(",");
  }
  return useQuery({
    queryKey: [
      "exercises", page, userFilter, categoryIds
    ],
    queryFn: () =>
      api.get<PaginatedResponse<ExerciseDefinition>>(
        "/exercises", params
      ),
  });
}

Because categoryIds is part of the queryKey, React Query treats ["exercises", 1, "mine", [1,3]] and ["exercises", 1, "mine", [1]] as separate cache entries. Toggle a category chip → queryKey changes → new fetch fires → UI updates. No useEffect needed.

Learn more: TanStack Query — Query Keys


3. ExerciseForm — dynamic fields done right

The exercise form is where we first hit the complexity of dynamic nested arrays in react-hook-form. An exercise has:

  • Fixed fields: title, description, counting_type
  • A dynamic array: progression levels (0 to many)
  • A multi-select: category IDs

useFieldArray for progression levels

const { fields, append, remove } = useFieldArray({
  control,
  name: "progression_levels",
});

useFieldArray manages a list of form entries. Each entry gets a stable id for React's key prop (never use the array index — reordering would break it). The append and remove functions handle adding/removing without manual state management.

{fields.map((field, index) => (
  <div key={field.id} className="flex items-center gap-2">
    <span className="w-6 text-xs text-gray-400">
      {index + 1}.
    </span>
    <input
      {...register(
        `progression_levels.${index}.name`,
        { required: true }
      )}
      placeholder="Level name"
    />
    <button onClick={() => remove(index)}>
      Remove
    </button>
  </div>
))}
<button onClick={() => append({ name: "" })}>
  + Add level
</button>

Pattern name: useFieldArray — the react-hook-form way of handling dynamic lists. It avoids the common pitfall of managing array state manually with useState, which loses form validation, dirty tracking, and default value handling.

setValue for category toggles

Categories are multi-select pills. The first attempt used hidden checkboxes with DOM event dispatching — but react-hook-form never picked up the synthetic events. The fix: use setValue() directly.

const selectedCats: number[] =
  watch("category_ids") ?? [];

const toggleCategory = (catId: number) => {
  const next = selectedCats.includes(catId)
    ? selectedCats.filter((id) => id !== catId)
    : [...selectedCats, catId];
  setValue("category_ids", next, { shouldDirty: true });
};

Each category renders as a <button> (not a checkbox). Clicking toggles the ID in the array via setValue. The { shouldDirty: true } option tells react-hook-form to track this as a user change.

Lesson learned: When react-hook-form controls a field, always use setValue() to update it programmatically. DOM-level hacks (dispatchEvent, hidden inputs) are fragile and framework-dependent.

Learn more: react-hook-form useFieldArray, react-hook-form setValue


4. WorkoutExerciseForm — the smart form

This is the most complex component in the app. It's a shared form used by four pages:

  • /workouts/new — create workout
  • /workouts/[id]/edit — edit workout
  • /templates/new — create template
  • /templates/[id]/edit — edit template

The form manages a two-level nested array: a workout has exercises, and each exercise has sets. That's useFieldArray inside useFieldArray.

Architecture

WorkoutExerciseForm
├── Title input
├── Exercise filter controls
│   ├── "Show only my exercises" checkbox
│   └── Category filter chips
├── ExerciseBlock[] (one per exercise)
│   ├── Exercise <select> dropdown
│   └── SetRow[] (one per set)
│       ├── Progression (select or text input)
│       └── Reps OR Duration (based on counting_type)
└── Submit button

Smart exercise picker with filters

The Jinja2 form let you filter exercises by ownership and category. We replicate that:

const [showOnlyMine, setShowOnlyMine] = useState(true);
const [selectedCatIds, setSelectedCatIds] =
  useState<number[]>([]);

const userFilter = showOnlyMine ? "mine" : "all";
const { data: exData } = useExercises(
  1,
  userFilter,
  selectedCatIds.length > 0
    ? selectedCatIds
    : undefined
);

Toggling the checkbox or clicking a category chip changes the useExercises parameters, which changes the query key, which triggers a refetch. The exercise dropdown updates reactively.

useWatch for reactive set templates

When you pick an exercise from the dropdown, the set inputs need to adapt: show a progression <select> if the exercise has defined levels, show reps OR duration based on counting_type. This is where useWatch shines:

const selectedDefId = useWatch({
  control,
  name: `exercises.${exIndex}.exercise_definition_id`,
});

const selectedDef = selectedDefId
  ? exerciseDefMap.get(Number(selectedDefId))
  : undefined;

const countingType =
  selectedDef?.counting_type ?? "reps";
const progressionLevels =
  selectedDef?.progression_levels ?? [];
const hasProgressionLevels =
  progressionLevels.length > 0;

useWatch vs watch: Both observe form values. watch triggers a re-render of the entire form. useWatch only re-renders the component that called it. In a nested form with many exercises, this matters — changing exercise 3's dropdown shouldn't re-render exercises 1, 2, 4, and 5.

The progression column adapts:

{hasProgressionLevels ? (
  <select {...register(`...progression`)}>
    <option value=""></option>
    {progressionLevels.map((level) => (
      <option key={level.id} value={level.name}>
        {level.name}
      </option>
    ))}
  </select>
) : (
  <input
    {...register(`...progression`)}
    placeholder="e.g. Standard"
  />
)}

And the count column adapts:

{countingType === "duration" ? (
  <input
    {...register(`...duration`, {
      pattern: /^\d{1,2}:\d{2}$/,
    })}
    placeholder="0:00"
  />
) : (
  <input
    {...register(`...reps`)}
    type="number"
    placeholder="0"
  />
)}

Duration mm:ss ↔ seconds conversion

The Flask backend stores duration in seconds (an integer). The UI shows mm:ss (what users expect). Two helper functions handle the conversion:

function secondsToMmss(totalSeconds: number): string {
  const mins = Math.floor(totalSeconds / 60);
  const secs = totalSeconds % 60;
  return `${String(mins).padStart(1, "0")}:${
    String(secs).padStart(2, "0")
  }`;
}

function mmssToSeconds(value: string): number | null {
  const match = value.match(/^(\d{1,2}):(\d{2})$/);
  if (!match) return null;
  const mins = Number(match[1]);
  const secs = Number(match[2]);
  if (secs >= 60) return null;
  return mins * 60 + secs;
}

Default values convert seconds → mm:ss when loading:

duration: s.duration != null
  ? secondsToMmss(s.duration)
  : ""

The submit handler converts mm:ss → seconds before sending to the API:

duration: s.duration
  ? mmssToSeconds(s.duration)
  : null

Pattern name: presentation layer conversion. The API speaks seconds, the UI speaks mm:ss. The form component is the translator between the two. Neither the backend nor the parent page needs to know about the conversion.

Learn more: react-hook-form useWatch, react-hook-form useFieldArray


5. Milestone 3: Exercises & categories pages

5a. Exercise list page (/exercises)

The exercises page has two features the workouts page doesn't: a mine/all toggle and category filter chips.

const [page, setPage] = useState(1);
const [userFilter, setUserFilter] =
  useState<string>("mine");
const [selectedCatIds, setSelectedCatIds] =
  useState<number[]>([]);

const { data, isLoading, error } = useExercises(
  page, userFilter, selectedCatIds
);

The category chips come from useCategories():

{categories.map((cat) => (
  <button
    key={cat.id}
    onClick={() => toggleCategory(cat.id)}
    className={
      selectedCatIds.includes(cat.id)
        ? "bg-blue-100 text-blue-700"
        : "bg-gray-100 text-gray-600"
    }
  >
    {cat.name}
  </button>
))}

Switching between "My Exercises" and "All Exercises" resets to page 1 (because the total count changes).

5b. Exercise detail page (/exercises/[id])

Shows the full exercise definition: title, counting type badge, description, progression levels chain ("Level 1 → Level 2 → Level 3"), and action buttons.

Owner sees Edit/Archive. Non-owner sees Copy (which creates a personal copy via POST /exercises/{id}/copy).

5c. Exercise create & edit pages

Both use the shared <ExerciseForm> component. The key difference is in how progression levels are submitted. The Flask API expects an array of strings (just the names), but the form works with objects ({ name: string }):

// exercises/new/page.tsx
createExercise.mutate({
  ...data,
  progression_levels:
    data.progression_levels.map((p) => p.name),
});

This mismatch caused the first major bug — see section 10.

5d. ExerciseCard component

Each exercise in the list renders as a card showing:

  • Title (clickable link to detail)
  • Counting type badge (reps or duration)
  • Category name badges (blue pills)
  • Progression chain in detail
{exercise.progression_levels.length > 0 && (
  <p className="mt-1 text-xs text-gray-400">
    Progressions:{" "}
    {exercise.progression_levels
      .sort((a, b) => a.level_order - b.level_order)
      .map((l) => l.name)
      .join(" → ")}
  </p>
)}

The category names come from a useMemo mapping:

const categoryNames = useMemo(() => {
  const allCats = catData?.data ?? [];
  const catMap = new Map(
    allCats.map((c) => [c.id, c.name])
  );
  return exercise.category_ids
    .map((id) => catMap.get(id))
    .filter(Boolean) as string[];
}, [catData, exercise.category_ids]);

Why useMemo? Without it, the mapping runs on every render. With dozens of exercise cards, each doing a .map() + .filter(), that adds up. useMemo caches the result and only recalculates when catData or category_ids changes.

5e. Categories page (/categories)

A simple CRUD page with inline editing. The interesting parts:

Inline rename — clicking "Rename" swaps the text for an input field with keyboard support:

<input
  value={editingName}
  onChange={(e) => setEditingName(e.target.value)}
  autoFocus
  onKeyDown={(e) => {
    if (e.key === "Enter") handleRename();
    if (e.key === "Escape") setEditingId(null);
  }}
/>

Delete error handling — the API returns 409 Conflict if a category is in use. We catch that:

const handleDelete = (id: number) => {
  setDeleteError(null);
  deleteCategory.mutate(id, {
    onError: (err) => {
      setDeleteError(
        err instanceof Error
          ? err.message
          : "Cannot delete: category is in use."
      );
    },
  });
};

Learn more: Next.js Dynamic Routes


6. Milestone 4: Workout & template CRUD

6a. Workout create page (/workouts/new)

This page has a unique feature: load from template.

const [selectedTemplateId, setSelectedTemplateId] =
  useState<number | null>(null);
const [loadedTemplate, setLoadedTemplate] =
  useState<Partial<Workout> | null>(null);

const { data: templateDetail } =
  useWorkout(selectedTemplateId ?? 0);

When the user selects a template and clicks "Load", the template's exercises/sets pre-fill the form. The trick is using React's key prop to force a re-mount:

<WorkoutExerciseForm
  key={loadedTemplate
    ? `tpl-${selectedTemplateId}`
    : "empty"}
  defaultValues={loadedTemplate ?? undefined}
  onSubmit={(data) => {
    createWorkout.mutate(data, {
      onSuccess: () => router.push("/workouts"),
    });
  }}
/>

Why the key prop? react-hook-form reads defaultValues only on mount. If you change defaultValues after mount, the form ignores it. Changing the key forces React to destroy the old component and create a new one — effectively a "re-mount". This is a common react-hook-form pattern.

6b. Workout edit page (/workouts/[id]/edit)

Same pattern as template edit — fetch with useWorkout, save with useUpdateWorkout:

const { data, isLoading } = useWorkout(workoutId);
const updateWorkout = useUpdateWorkout();

<WorkoutExerciseForm
  defaultValues={data?.data}
  isPending={updateWorkout.isPending}
  submitLabel="Update Workout"
  onSubmit={(formData) => {
    updateWorkout.mutate(
      { id: workoutId, ...formData },
      { onSuccess: () => router.push("/workouts") }
    );
  }}
/>

6c. Template pages

Templates are workouts with is_template=true. The template pages (/templates, /templates/new, /templates/[id]/edit) use the same <WorkoutExerciseForm> but with template-specific hooks (useCreateTemplate, useUpdateTemplate).

6d. WorkoutCard upgrades

The workout card was rewritten to show exercise details inline (not just a count):

{workout.exercises.map((ex, i) => (
  <p key={ex.id}>
    <span className="text-gray-400">
      {i + 1}.
    </span>{" "}
    <span className="font-medium">
      {ex.exercise_definition_title ?? "Exercise"}
    </span>
    <span className="text-gray-400"></span>
    <span>{formatSetSummary(ex)}</span>
  </p>
))}

The formatSetSummary() helper totals reps and duration across sets:

function formatSetSummary(exercise: Exercise): string {
  const sets = exercise.sets ?? [];
  if (sets.length === 0) return "No sets";

  const totalReps = sets.reduce(
    (sum, s) => sum + (s.reps ?? 0), 0
  );
  const totalDuration = sets.reduce(
    (sum, s) => sum + (s.duration ?? 0), 0
  );

  const parts: string[] = [
    `${sets.length} ${
      sets.length === 1 ? "Set" : "Sets"
    }`
  ];
  if (totalReps > 0) parts.push(`${totalReps} Reps`);
  if (totalDuration > 0) {
    const mins = Math.floor(totalDuration / 60);
    const secs = totalDuration % 60;
    parts.push(
      mins > 0
        ? `${mins}m ${secs}s`
        : `${totalDuration}s`
    );
  }
  return parts.join(" · ");
}

Each card also shows a status badge (green "done" or yellow "pendent"), an Edit link, and the date in dd.MM.yyyy, HH:mm format.

Learn more: react-hook-form defaultValues


7. Milestone 5: Social — explore & follow

7a. Explore feed (/explore)

The explore page shows workouts from users you follow — a simple social feed. It uses useExplore() which hits GET /explore:

const { data, isLoading, error } = useExplore(page);
const workouts = data?.data ?? [];

{workouts.map((w) => (
  <ExploreWorkoutCard key={w.id} workout={w} />
))}

7b. ExploreWorkoutCard

A read-only card showing another user's workout: username link, timestamp, exercise list, and a "Done" badge if completed. Unlike WorkoutCard, there are no action buttons (you can't edit someone else's workout).

7c. User profile page (/users/[username])

Shows a user's profile header with follow/unfollow button, stats (followers/following), and their workout history:

const { data } = useUserProfile(username, page);
const user = data?.user;
const workouts = data?.workouts ?? [];

The follow/unfollow buttons use optimistic updates from React Query:

<button
  onClick={() =>
    user.is_following
      ? unfollow.mutate(username)
      : follow.mutate(username)
  }
>
  {user.is_following ? "Unfollow" : "Follow"}
</button>

7d. UserProfileHeader

A reusable component showing:

  • @username and about_me
  • Follower/following counts
  • Last seen timestamp
  • Follow/Unfollow button (hidden for your own profile)

Learn more: Next.js Link component


8. Milestone 6: Messages & profile

8a. Messages list (/messages)

Paginated inbox using useMessages():

const { data, isLoading } = useMessages(page);
const messages = data?.data ?? [];

{messages.map((msg) => (
  <MessageCard key={msg.id} message={msg} />
))}

8b. MessageCard

Displays sender as a link to their profile, timestamp, and message body with whitespace-pre-wrap to preserve line breaks.

8c. Send message (/messages/new)

The form supports a ?to=username query parameter for pre-filling the recipient:

const searchParams = useSearchParams();
const defaultRecipient =
  searchParams.get("to") ?? "";

This enables the "Send Message" link on user profiles to pre-fill the recipient field.

8d. Profile page (/profile)

A view/edit toggle pattern:

const [editing, setEditing] = useState(false);

{!editing ? (
  // Display mode: username, email, about_me,
  //   follower/following counts, member since
  <button onClick={startEditing}>
    Edit Profile
  </button>
) : (
  // Edit mode: username input, about_me textarea
  <button onClick={handleSave}>Save</button>
)}

The sign-out button calls Supabase directly:

const handleLogout = async () => {
  const supabase = createClient();
  await supabase.auth.signOut();
  router.push("/login");
};

Learn more: Supabase Auth signOut, Next.js useSearchParams


9. Backend changes

Milestones 3–6 required three surgical backend changes (no migrations, no new endpoints):

9a. counting_type in Exercise serialization

The workout detail page needs to know whether an exercise tracks reps or duration. We added one field to Exercise.to_dict():

"counting_type": (
    self.exercise_definition.counting_type
    if self.exercise_definition
    else "reps"
),

This lets the frontend conditionally show "Reps" or "Duration" columns — not both.

9b. Username via backref

Workout.to_dict() needed the athlete's username for workout cards. But the User backref on Workout is named athlete (not user):

"username": (
    self.athlete.username
    if self.athlete
    else None
),

This tripped us up — self.user raised AttributeError. The backref name comes from the users table's relationship definition:

workouts = db.relationship(
    "Workout", backref="athlete", lazy="dynamic"
)

Lesson: Always check the backref name in the parent model, not the child. SQLAlchemy doesn't warn you if you access a non-existent attribute on a model instance — it just raises AttributeError at runtime.

9c. include_exercises=True on workout list

The workout list endpoint originally returned workouts without exercise details. The enhanced WorkoutCard needs exercise names and set summaries, so we changed:

# Before
return jsonify(data=[w.to_dict() for w in ...])

# After
return jsonify(
    data=[
        w.to_dict(include_exercises=True)
        for w in ...
    ]
)

This adds exercises and their sets to each workout in the list response. The tradeoff is a larger payload, but the alternative (N+1 API calls to fetch exercise details per card) is worse.

Learn more: SQLAlchemy relationships


10. The bugs that bit us

Bug 1: Progression levels not saving

Symptom: Create exercise → add progression levels → save → reopen edit → levels are gone.

Root cause: The Flask API expects progression_levels as an array of strings (["Level 1", "Level 2"]). The frontend was sending objects ([{name: "Level 1", level_order: 0}]). The backend's isinstance(name, str) check silently skipped them.

Fix: Map to strings before submitting:

progression_levels:
  data.progression_levels.map((p) => p.name)

And update the hook's TypeScript type from { name: string; level_order: number }[] to string[].

Lesson: Silent failures are the worst. The API returned 200 OK but quietly dropped the data. Adding validation errors for wrong types would have caught this immediately.

Bug 2: Template sets disappearing

Symptom: Create template with 3 empty sets → save → reopen → only exercises remain, sets are gone.

Root cause: The submit handler had a filter:

.filter((s) => s.reps || s.duration)

This stripped sets where both reps and duration were empty — which is exactly what template sets look like (they're placeholders). The filter was "helpful" but destructive.

Fix: Remove the filter entirely. If the user added a set, they meant to add a set — even if it's empty.

Bug 3: Category toggle not working

Symptom: Click category pill on exercise form → nothing happens. Category doesn't get selected.

Root cause: Original implementation used hidden <input type="checkbox"> elements with manual dispatchEvent() calls. React's synthetic event system didn't propagate these to react-hook-form's internal handlers.

Fix: Replace with <button> elements + direct setValue() calls:

const toggleCategory = (catId: number) => {
  const next = selectedCats.includes(catId)
    ? selectedCats.filter((id) => id !== catId)
    : [...selectedCats, catId];
  setValue("category_ids", next, {
    shouldDirty: true,
  });
};

Bug 4: Duration shown as raw seconds

Symptom: Exercise with 90 seconds duration shows "90" in the input field instead of "1:30".

Root cause: The duration input was type="number", displaying the raw seconds value from the API.

Fix: Changed to a text input with mm:ss pattern validation, plus secondsToMmss / mmssToSeconds conversion helpers (see section 4).

Bug 5: self.athlete not self.user

Symptom: 500 Internal Server Error on workout list. Backend traceback: AttributeError: 'Workout' object has no attribute 'user'.

Root cause: SQLAlchemy backref is named athlete, not user. See section 9b.

Fix: self.user.usernameself.athlete.username


11. Files changed across all milestones

New files created

FileMilestonePurpose
hooks/use-categories.ts3Category CRUD hooks
hooks/use-exercises.ts3Exercise CRUD + copy hooks
hooks/use-templates.ts4Template CRUD + "use template" hook
hooks/use-social.ts5Explore feed, user profile, follow/unfollow
hooks/use-messages.ts6Message list + send hooks
hooks/use-update-profile.ts6Profile update mutation
components/exercise/exercise-form.tsx3Shared exercise form with dynamic fields
components/exercise/exercise-card.tsx3Exercise list card
components/workout/workout-exercise-form.tsx4Smart workout/template form
components/template/template-card.tsx4Template list card
components/social/explore-workout-card.tsx5Read-only workout card for explore feed
components/social/user-profile-header.tsx5User profile header with follow button
components/message/message-card.tsx6Message display card
components/message/send-message-form.tsx6Message composition form
app/(app)/exercises/page.tsx3Exercise list page
app/(app)/exercises/new/page.tsx3Create exercise
app/(app)/exercises/[id]/page.tsx3Exercise detail
app/(app)/exercises/[id]/edit/page.tsx3Edit exercise
app/(app)/categories/page.tsx3Category management
app/(app)/templates/page.tsx4Template list
app/(app)/templates/new/page.tsx4Create template
app/(app)/templates/[id]/edit/page.tsx4Edit template
app/(app)/workouts/new/page.tsx4Create workout (with template loading)
app/(app)/workouts/[id]/edit/page.tsx4Edit workout
app/(app)/explore/page.tsx5Social explore feed
app/(app)/users/[username]/page.tsx5User profile
app/(app)/messages/page.tsx6Message inbox
app/(app)/messages/new/page.tsx6Send message
app/(app)/profile/page.tsx6Own profile view/edit

Modified files

FileWhat changed
hooks/use-workouts.tsAdded useCreateWorkout, useUpdateWorkout
components/workout/workout-card.tsxExercise details, edit link, status badge, date format
components/layout/navbar.tsxAdded Categories link
app/(app)/workouts/page.tsxAdded "New Workout" button
app/(app)/workouts/[id]/page.tsxEdit link, counting-type-aware columns, date format
types/index.tsAdded counting_type to Exercise, full type definitions
backend/project/models.pycounting_type in Exercise.to_dict, athlete.username fix
backend/project/api/workout_routes.pyinclude_exercises=True on list endpoint

Tech stack additions (since Milestone 2)

No new dependencies were added. All features were built with the existing stack:

TechnologyRole
React 19UI framework
Next.js 16App router, SSR, file-based routing
TanStack Query 5Server state management
react-hook-form 7Form state + validation
date-fns 4Date formatting
Tailwind CSS 4Utility-first styling

Next up: Go Mobile with Expo.