From d92ed246eed7bf8f151d9f17d063e3c70de3ed3a Mon Sep 17 00:00:00 2001 From: rd-code-23 Date: Thu, 3 Sep 2026 18:59:04 -0700 Subject: [PATCH] feat(web): show configured routine exercises --- apps/web/package.json | 1 + .../src/features/exercises/exercises.query.ts | 2 + .../routines/exercise-configuration.test.tsx | 66 +++++++------- .../routines/exercise-configuration.tsx | 14 ++- .../features/routines/routine-draft-form.tsx | 18 +++- .../features/routines/routine-editor.test.tsx | 18 +++- .../src/features/routines/routine-editor.tsx | 6 +- .../routines/routine-exercise-list.tsx | 85 +++++++++++++++++++ apps/web/src/main.tsx | 2 + pnpm-lock.yaml | 21 +++++ 10 files changed, 196 insertions(+), 37 deletions(-) create mode 100644 apps/web/src/features/routines/routine-exercise-list.tsx diff --git a/apps/web/package.json b/apps/web/package.json index d35666d..0ef5e1f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -31,6 +31,7 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.1.12", + "@tanstack/react-query-devtools": "^5.102.8", "@tanstack/router-plugin": "^1.131.35", "@testing-library/jest-dom": "^6.8.0", "@testing-library/react": "^16.3.0", diff --git a/apps/web/src/features/exercises/exercises.query.ts b/apps/web/src/features/exercises/exercises.query.ts index f973e40..3078120 100644 --- a/apps/web/src/features/exercises/exercises.query.ts +++ b/apps/web/src/features/exercises/exercises.query.ts @@ -4,11 +4,13 @@ import { queryOptions, useQuery } from '@tanstack/react-query'; import { fetchExercises } from './exercises.api'; export const exercisesQueryKey = ['exercises'] as const; +const EXERCISES_STALE_TIME_MS = 2 * 60 * 1000; export const exercisesQueryOptions = queryOptions({ queryKey: exercisesQueryKey, queryFn: ({ signal }) => fetchExercises(signal), retry: false, + staleTime: EXERCISES_STALE_TIME_MS, }); export function useExercises() { diff --git a/apps/web/src/features/routines/exercise-configuration.test.tsx b/apps/web/src/features/routines/exercise-configuration.test.tsx index 50ad605..d1332ca 100644 --- a/apps/web/src/features/routines/exercise-configuration.test.tsx +++ b/apps/web/src/features/routines/exercise-configuration.test.tsx @@ -10,14 +10,12 @@ import { } from '@tanstack/react-router'; import { cleanup, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { useFormContext, useWatch } from 'react-hook-form'; import { afterEach, expect, it, vi } from 'vitest'; -import { - RoutineDraftFormProvider, - type RoutineDraftFormValues, -} from './routine-draft-form'; +import { RoutineDraftFormProvider } from './routine-draft-form'; +import { RoutineEditor } from './routine-editor'; import { ExerciseConfiguration } from './exercise-configuration'; +import { ExercisePicker } from './exercise-picker'; afterEach(() => { cleanup(); @@ -37,11 +35,20 @@ function renderConfiguration(exerciseId: string) { const editorRoute = createRoute({ getParentRoute: () => rootRoute, path: '/routines/new', - component: DraftExerciseCount, + component: RoutineEditor, + }); + const exercisePickerRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/routines/new/exercises', + component: ExercisePicker, }); const router = createRouter({ history: createMemoryHistory({ initialEntries: ['/configure'] }), - routeTree: rootRoute.addChildren([configurationRoute, editorRoute]), + routeTree: rootRoute.addChildren([ + configurationRoute, + editorRoute, + exercisePickerRoute, + ]), }); render( @@ -59,31 +66,22 @@ function TestLayout() { ); } -function DraftExerciseCount() { - const { control } = useFormContext(); - const exercises = useWatch({ control, name: 'exercises' }); - - return

Saved exercises: {exercises.length}

; -} - it('shows the selected exercise and initial configuration fields', async () => { const user = userEvent.setup(); const exerciseId = '26d34dc0-8e4c-4bd0-9e3b-7b839b44e486'; - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue( - Response.json({ - exercises: [ - { - id: exerciseId, - name: 'Bicep Curl', - description: null, - isCustom: false, - }, - ], - }), - ), + const fetchMock = vi.fn().mockResolvedValue( + Response.json({ + exercises: [ + { + id: exerciseId, + name: 'Bicep Curl', + description: null, + isCustom: false, + }, + ], + }), ); + vi.stubGlobal('fetch', fetchMock); renderConfiguration(exerciseId); @@ -94,5 +92,15 @@ it('shows the selected exercise and initial configuration fields', async () => { await user.click(screen.getByRole('button', { name: 'Done' })); - expect(await screen.findByText('Saved exercises: 1')).toBeVisible(); + expect( + await screen.findByRole('heading', { name: 'Exercises (1)' }), + ).toBeVisible(); + expect(screen.getByText('Bicep Curl')).toBeVisible(); + expect(screen.getByText('10 lb')).toBeVisible(); + expect(screen.getByText('60s')).toBeVisible(); + + await user.click(screen.getByRole('button', { name: 'Add Exercise' })); + + expect(await screen.findByLabelText('Search exercises')).toBeVisible(); + expect(fetchMock).toHaveBeenCalledTimes(1); }); diff --git a/apps/web/src/features/routines/exercise-configuration.tsx b/apps/web/src/features/routines/exercise-configuration.tsx index 9be171f..77b453e 100644 --- a/apps/web/src/features/routines/exercise-configuration.tsx +++ b/apps/web/src/features/routines/exercise-configuration.tsx @@ -91,10 +91,12 @@ function ExerciseConfigurationForm({ const navigate = useNavigate(); const { getValues, setValue: setDraftValue } = useFormContext(); + const defaultValues = createRoutineExerciseInputSchema.parse({ exerciseId: exercise.id, position: getValues('exercises').length, }); + const { handleSubmit, register, @@ -106,10 +108,14 @@ function ExerciseConfigurationForm({ }); async function saveExercise(input: RoutineExerciseInput) { - setDraftValue('exercises', [...getValues('exercises'), input], { - shouldDirty: true, - shouldValidate: true, - }); + setDraftValue( + 'exercises', + [...getValues('exercises'), { ...input, name: exercise.name }], + { + shouldDirty: true, + shouldValidate: true, + }, + ); await navigate({ to: '/routines/new' }); } diff --git a/apps/web/src/features/routines/routine-draft-form.tsx b/apps/web/src/features/routines/routine-draft-form.tsx index 37a2a74..e342733 100644 --- a/apps/web/src/features/routines/routine-draft-form.tsx +++ b/apps/web/src/features/routines/routine-draft-form.tsx @@ -3,15 +3,28 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { createRoutineExerciseInputSchema, createRoutineScheduleInputSchema, + exerciseSummarySchema, routineNameSchema, } from '@goforlift/contracts'; import type { ReactNode } from 'react'; import { FormProvider, useForm } from 'react-hook-form'; import { z } from 'zod'; +// The form draft keeps the exercise name so the editor can display it without +// reading the exercise catalog again. The create-routine API accepts only the +// exercise ID and configuration, so the transform removes this UI-only name +// from the validated output before that data is submitted. +// When the complete form is successfully parsed by the Zod resolver, .transform() removes name. +const routineDraftExerciseSchema = createRoutineExerciseInputSchema + .extend({ name: exerciseSummarySchema.shape.name }) + .transform(({ name, ...exercise }) => { + void name; + return exercise; + }); + const routineDraftFormSchema = z.object({ name: routineNameSchema, - exercises: z.array(createRoutineExerciseInputSchema), + exercises: z.array(routineDraftExerciseSchema), schedules: z.array(createRoutineScheduleInputSchema), }); // Exercise defaults live in the shared Zod contract so every consumer uses the @@ -20,6 +33,9 @@ const routineDraftFormSchema = z.object({ // therefore use separate input and output types; z.infer describes only the // output and causes a resolver type mismatch when it is also used as the input. export type RoutineDraftFormValues = z.input; +export type RoutineDraftExerciseFormValues = z.input< + typeof routineDraftExerciseSchema +>; type ParsedRoutineDraftFormValues = z.output; export function RoutineDraftFormProvider({ diff --git a/apps/web/src/features/routines/routine-editor.test.tsx b/apps/web/src/features/routines/routine-editor.test.tsx index 9e760cd..e9e397a 100644 --- a/apps/web/src/features/routines/routine-editor.test.tsx +++ b/apps/web/src/features/routines/routine-editor.test.tsx @@ -1,5 +1,6 @@ /** Verifies the routine editor shell exposes its initial responsive workflow. */ import { ROUTINE_NAME_MAX_LENGTH } from '@goforlift/contracts'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { createMemoryHistory, createRootRoute, @@ -11,17 +12,21 @@ import { } from '@tanstack/react-router'; import { cleanup, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { RoutineDraftFormProvider } from './routine-draft-form'; import { RoutineEditor } from './routine-editor'; +import { exercisesQueryKey } from '../exercises/exercises.query'; afterEach(() => { cleanup(); + vi.unstubAllGlobals(); }); describe('routine editor', () => { it('shows the initial editor shell without enabling unfinished actions', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); renderEditor(); expect( @@ -40,6 +45,7 @@ describe('routine editor', () => { expect( screen.getByRole('link', { name: 'Back to routines' }), ).toHaveAttribute('href', '/routines'); + expect(fetchMock).not.toHaveBeenCalled(); }); it('shows live validation and clears the error after correction', async () => { @@ -92,6 +98,10 @@ describe('routine editor', () => { }); function renderEditor() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + queryClient.setQueryData(exercisesQueryKey, { exercises: [] }); const rootRoute = createRootRoute(); const routineCreationRoute = createRoute({ getParentRoute: () => rootRoute, @@ -115,7 +125,11 @@ function renderEditor() { ]), }); - render(); + render( + + + , + ); return testRouter; } diff --git a/apps/web/src/features/routines/routine-editor.tsx b/apps/web/src/features/routines/routine-editor.tsx index 58238d3..92ca08e 100644 --- a/apps/web/src/features/routines/routine-editor.tsx +++ b/apps/web/src/features/routines/routine-editor.tsx @@ -10,6 +10,7 @@ import { Label } from '../../components/ui/label'; import { PageTitle } from '../../components/ui/page-title'; import { cn } from '../../lib/utils'; import type { RoutineDraftFormValues } from './routine-draft-form'; +import { RoutineExerciseList } from './routine-exercise-list'; import { RoutineScheduleField } from './routine-schedule-field'; export function RoutineEditor() { @@ -100,7 +101,10 @@ function ExerciseSection({ onAddExercise }: { onAddExercise: () => void }) { -
+ + {exercises.length > 0 && } + +
0 ? 'mt-1' : 'mt-3'}>