From abf622272444b62ab1a5df649f5f5fd81d0d4531 Mon Sep 17 00:00:00 2001 From: rd-code-23 Date: Sat, 29 Aug 2026 11:30:36 -0700 Subject: [PATCH] feat(web): add routine exercise configuration shell --- apps/web/src/components/ui/number-stepper.tsx | 83 +++++++ .../components/ui/positive-integer-input.tsx | 42 ++++ .../routines/exercise-configuration.test.tsx | 63 +++++ .../routines/exercise-configuration.tsx | 220 ++++++++++++++++++ .../routines/exercise-picker.test.tsx | 4 + .../src/features/routines/exercise-picker.tsx | 28 ++- apps/web/src/routeTree.gen.ts | 23 ++ ....routines_.new_.exercises_.$exerciseId.tsx | 16 ++ 8 files changed, 468 insertions(+), 11 deletions(-) create mode 100644 apps/web/src/components/ui/number-stepper.tsx create mode 100644 apps/web/src/components/ui/positive-integer-input.tsx create mode 100644 apps/web/src/features/routines/exercise-configuration.test.tsx create mode 100644 apps/web/src/features/routines/exercise-configuration.tsx create mode 100644 apps/web/src/routes/_app.routines_.new_.exercises_.$exerciseId.tsx diff --git a/apps/web/src/components/ui/number-stepper.tsx b/apps/web/src/components/ui/number-stepper.tsx new file mode 100644 index 0000000..759b76a --- /dev/null +++ b/apps/web/src/components/ui/number-stepper.tsx @@ -0,0 +1,83 @@ +import { Minus, Plus } from 'lucide-react'; +import { useState } from 'react'; + +import { cn } from '@/lib/utils'; + +import { Button } from './button'; + +type NumberStepperProps = { + defaultValue: number; + id: string; + min?: number; +}; + +export function NumberStepper({ + defaultValue, + id, + min = 1, +}: NumberStepperProps) { + const [value, setValue] = useState(defaultValue); + + const decreaseValue = () => { + setValue((currentValue) => Math.max(min, Number(currentValue || min) - 1)); + }; + + const increaseValue = () => { + setValue((currentValue) => Number(currentValue || min) + 1); + }; + + const finishEditing = () => { + if (value === '' || Number(value) < min) { + setValue(min); + } + }; + + return ( +
+ + + { + const nextValue = event.target.value; + + if (nextValue === '' || /^\d+$/.test(nextValue)) { + setValue(nextValue); + } + }} + type="number" + value={value} + /> + + +
+ ); +} diff --git a/apps/web/src/components/ui/positive-integer-input.tsx b/apps/web/src/components/ui/positive-integer-input.tsx new file mode 100644 index 0000000..fab8697 --- /dev/null +++ b/apps/web/src/components/ui/positive-integer-input.tsx @@ -0,0 +1,42 @@ +import { useState } from 'react'; + +import { Input } from './input'; + +type PositiveIntegerInputProps = { + className?: string; + defaultValue: number; + id: string; +}; + +export function PositiveIntegerInput({ + defaultValue, + id, + className, +}: PositiveIntegerInputProps) { + const [value, setValue] = useState(defaultValue); + + const finishEditing = () => { + if (value === '' || Number(value) < 1) { + setValue(1); + } + }; + + return ( + { + const nextValue = event.target.value; + + if (nextValue === '' || /^\d+$/.test(nextValue)) { + setValue(nextValue); + } + }} + type="number" + value={value} + /> + ); +} diff --git a/apps/web/src/features/routines/exercise-configuration.test.tsx b/apps/web/src/features/routines/exercise-configuration.test.tsx new file mode 100644 index 0000000..56e4ae6 --- /dev/null +++ b/apps/web/src/features/routines/exercise-configuration.test.tsx @@ -0,0 +1,63 @@ +/** Verifies selected exercise data is shown in the configuration shell. */ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { + createMemoryHistory, + createRootRoute, + createRouter, + RouterProvider, +} from '@tanstack/react-router'; +import { cleanup, render, screen } from '@testing-library/react'; +import type { PropsWithChildren } from 'react'; +import { afterEach, expect, it, vi } from 'vitest'; + +import { ExerciseConfiguration } from './exercise-configuration'; + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +function Wrapper({ children }: PropsWithChildren) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const rootRoute = createRootRoute({ component: () => children }); + const router = createRouter({ + history: createMemoryHistory({ initialEntries: ['/'] }), + routeTree: rootRoute, + }); + + return ( + + + + ); +} + +it('shows the selected exercise and initial configuration fields', async () => { + 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, + }, + ], + }), + ), + ); + + render(, { + wrapper: Wrapper, + }); + + expect(await screen.findByText('Bicep Curl')).toBeVisible(); + expect(screen.getByLabelText('Sets')).toHaveValue(3); + expect(screen.getByLabelText('Reps')).toHaveValue(8); + expect(screen.getByRole('button', { name: 'Done' })).toBeDisabled(); +}); diff --git a/apps/web/src/features/routines/exercise-configuration.tsx b/apps/web/src/features/routines/exercise-configuration.tsx new file mode 100644 index 0000000..783825e --- /dev/null +++ b/apps/web/src/features/routines/exercise-configuration.tsx @@ -0,0 +1,220 @@ +/** Presents the configuration shell for an exercise selected for a routine. */ +import type { ExerciseSummary } from '@goforlift/contracts'; +import { Link } from '@tanstack/react-router'; +import { Dumbbell, X } from 'lucide-react'; + +import { cn } from '@/lib/utils'; + +import { Button } from '../../components/ui/button'; +import { Label } from '../../components/ui/label'; +import { LoadingState } from '../../components/ui/loading-state'; +import { NumberStepper } from '../../components/ui/number-stepper'; +import { PageTitle } from '../../components/ui/page-title'; +import { PositiveIntegerInput } from '../../components/ui/positive-integer-input'; +import { useExercises } from '../exercises/exercises.query'; + +type ExerciseConfigurationProps = { + exerciseId: string; +}; + +export function ExerciseConfiguration({ + exerciseId, +}: ExerciseConfigurationProps) { + const exercisesQuery = useExercises(); + const exercise = exercisesQuery.data?.exercises.find( + (availableExercise) => availableExercise.id === exerciseId, + ); + + let content = ; + + if (exercisesQuery.isPending) { + content = ; + } else if (exercisesQuery.isError) { + content = ( +

+ Exercise details could not be loaded. +

+ ); + } else if (exercise) { + content = ; + } + + return ( +
+ + {content} +
+ ); +} + +function ConfigurationHeader() { + return ( +
+ + Configure Exercise +
+ ); +} + +function ExerciseConfigurationForm({ + exercise, +}: { + exercise: ExerciseSummary; +}) { + return ( +
+ + +
+ +
+
+ ); +} + +function ExerciseIdentity({ exercise }: { exercise: ExerciseSummary }) { + return ( +
+
+
+
+

{exercise.name}

+ {exercise.isCustom && ( + + )} +
+
+ ); +} + +function ExerciseFields() { + return ( +
+
+ + + +
+ +
+ + +
+
+ + + +
+ +