From Flask Monolith to Multiplatform App: Migration Journey Part 3
From Read-Only to Full Feature Parity
*Follow-up to Journey Part 2
Table of Contents
- What we're building
- New hooks — the full CRUD toolkit
- ExerciseForm — dynamic fields done right
- WorkoutExerciseForm — the smart form
- Milestone 3: Exercises & categories pages
- Milestone 4: Workout & template CRUD
- Milestone 5: Social — explore & follow
- Milestone 6: Messages & profile
- Backend changes
- The bugs that bit us
- 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:
| Milestone | What it adds |
|---|---|
| 3 | Exercises CRUD, categories management |
| 4 | Workout create/edit, template create/edit, "load from template" |
| 5 | Explore feed, user profiles, follow/unfollow |
| 6 | Direct 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
| File | Queries | Mutations |
|---|---|---|
use-workouts.ts | useWorkouts, useWorkout | useCreateWorkout, useUpdateWorkout, useToggleDone, useDeleteWorkout |
use-exercises.ts | useExercises, useExercise | useCreateExercise, useUpdateExercise, useDeleteExercise, useCopyExercise |
use-categories.ts | useCategories | useCreateCategory, useRenameCategory, useDeleteCategory |
use-templates.ts | useTemplates | useCreateTemplate, useUpdateTemplate, useDeleteTemplate, useUseTemplate |
use-social.ts | useExplore, useUserProfile | useFollow, useUnfollow |
use-messages.ts | useMessages | useSendMessage |
use-profile.ts | useProfile | — |
use-update-profile.ts | — | useUpdateProfile |
use-notifications.ts | useNotifications, 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;
useWatchvswatch: Both observe form values.watchtriggers a re-render of the entire form.useWatchonly 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 (
repsorduration) - 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.useMemocaches the result and only recalculates whencatDataorcategory_idschanges.
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
keyprop?react-hook-formreadsdefaultValuesonly on mount. If you changedefaultValuesafter mount, the form ignores it. Changing thekeyforces 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:
@usernameand 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
AttributeErrorat 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.username → self.athlete.username
11. Files changed across all milestones
New files created
| File | Milestone | Purpose |
|---|---|---|
hooks/use-categories.ts | 3 | Category CRUD hooks |
hooks/use-exercises.ts | 3 | Exercise CRUD + copy hooks |
hooks/use-templates.ts | 4 | Template CRUD + "use template" hook |
hooks/use-social.ts | 5 | Explore feed, user profile, follow/unfollow |
hooks/use-messages.ts | 6 | Message list + send hooks |
hooks/use-update-profile.ts | 6 | Profile update mutation |
components/exercise/exercise-form.tsx | 3 | Shared exercise form with dynamic fields |
components/exercise/exercise-card.tsx | 3 | Exercise list card |
components/workout/workout-exercise-form.tsx | 4 | Smart workout/template form |
components/template/template-card.tsx | 4 | Template list card |
components/social/explore-workout-card.tsx | 5 | Read-only workout card for explore feed |
components/social/user-profile-header.tsx | 5 | User profile header with follow button |
components/message/message-card.tsx | 6 | Message display card |
components/message/send-message-form.tsx | 6 | Message composition form |
app/(app)/exercises/page.tsx | 3 | Exercise list page |
app/(app)/exercises/new/page.tsx | 3 | Create exercise |
app/(app)/exercises/[id]/page.tsx | 3 | Exercise detail |
app/(app)/exercises/[id]/edit/page.tsx | 3 | Edit exercise |
app/(app)/categories/page.tsx | 3 | Category management |
app/(app)/templates/page.tsx | 4 | Template list |
app/(app)/templates/new/page.tsx | 4 | Create template |
app/(app)/templates/[id]/edit/page.tsx | 4 | Edit template |
app/(app)/workouts/new/page.tsx | 4 | Create workout (with template loading) |
app/(app)/workouts/[id]/edit/page.tsx | 4 | Edit workout |
app/(app)/explore/page.tsx | 5 | Social explore feed |
app/(app)/users/[username]/page.tsx | 5 | User profile |
app/(app)/messages/page.tsx | 6 | Message inbox |
app/(app)/messages/new/page.tsx | 6 | Send message |
app/(app)/profile/page.tsx | 6 | Own profile view/edit |
Modified files
| File | What changed |
|---|---|
hooks/use-workouts.ts | Added useCreateWorkout, useUpdateWorkout |
components/workout/workout-card.tsx | Exercise details, edit link, status badge, date format |
components/layout/navbar.tsx | Added Categories link |
app/(app)/workouts/page.tsx | Added "New Workout" button |
app/(app)/workouts/[id]/page.tsx | Edit link, counting-type-aware columns, date format |
types/index.ts | Added counting_type to Exercise, full type definitions |
backend/project/models.py | counting_type in Exercise.to_dict, athlete.username fix |
backend/project/api/workout_routes.py | include_exercises=True on list endpoint |
Tech stack additions (since Milestone 2)
No new dependencies were added. All features were built with the existing stack:
| Technology | Role |
|---|---|
| React 19 | UI framework |
| Next.js 16 | App router, SSR, file-based routing |
| TanStack Query 5 | Server state management |
| react-hook-form 7 | Form state + validation |
| date-fns 4 | Date formatting |
| Tailwind CSS 4 | Utility-first styling |
Next up: Go Mobile with Expo.