From c3745880070091511c75d6e1eb71c2c70708f8ea Mon Sep 17 00:00:00 2001 From: rd-code-23 Date: Fri, 28 Aug 2026 14:51:17 -0700 Subject: [PATCH] feat(web): add routine exercise picker --- apps/web/src/components/ui/loading-state.tsx | 30 +++++ .../features/exercises/exercises.api.test.ts | 46 ++++++++ .../src/features/exercises/exercises.api.ts | 28 +++++ .../src/features/exercises/exercises.query.ts | 16 +++ .../routines/exercise-picker.test.tsx | 74 ++++++++++++ .../src/features/routines/exercise-picker.tsx | 108 ++++++++++++++++++ .../features/routines/routine-editor.test.tsx | 5 +- .../src/features/routines/routine-editor.tsx | 8 +- apps/web/src/routeTree.gen.ts | 21 ++++ .../routes/_app.routines_.new_.exercises.tsx | 8 ++ 10 files changed, 340 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/components/ui/loading-state.tsx create mode 100644 apps/web/src/features/exercises/exercises.api.test.ts create mode 100644 apps/web/src/features/exercises/exercises.api.ts create mode 100644 apps/web/src/features/exercises/exercises.query.ts create mode 100644 apps/web/src/features/routines/exercise-picker.test.tsx create mode 100644 apps/web/src/features/routines/exercise-picker.tsx create mode 100644 apps/web/src/routes/_app.routines_.new_.exercises.tsx diff --git a/apps/web/src/components/ui/loading-state.tsx b/apps/web/src/components/ui/loading-state.tsx new file mode 100644 index 0000000..15887b1 --- /dev/null +++ b/apps/web/src/components/ui/loading-state.tsx @@ -0,0 +1,30 @@ +// Displays a consistent centered spinner while page content is loading. +import { LoaderCircle } from 'lucide-react'; +import type { ComponentProps } from 'react'; + +import { cn } from '../../lib/utils'; + +type LoadingStateProps = ComponentProps<'div'> & { + label?: string; +}; + +export function LoadingState({ + className, + label = 'Loading…', + ...props +}: LoadingStateProps) { + return ( +
+
+ ); +} diff --git a/apps/web/src/features/exercises/exercises.api.test.ts b/apps/web/src/features/exercises/exercises.api.test.ts new file mode 100644 index 0000000..969adaf --- /dev/null +++ b/apps/web/src/features/exercises/exercises.api.test.ts @@ -0,0 +1,46 @@ +/** Verifies exercise API status and shared-contract handling. */ +import { afterEach, expect, it, vi } from 'vitest'; + +import { ExercisesApiError, fetchExercises } from './exercises.api'; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +it('returns exercises that satisfy the shared contract', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + Response.json({ + exercises: [ + { + id: '26d34dc0-8e4c-4bd0-9e3b-7b839b44e486', + name: 'Bicep Curl', + description: null, + isCustom: false, + }, + ], + }), + ), + ); + + await expect(fetchExercises()).resolves.toEqual({ + exercises: [ + { + id: '26d34dc0-8e4c-4bd0-9e3b-7b839b44e486', + name: 'Bicep Curl', + description: null, + isCustom: false, + }, + ], + }); +}); + +it('throws a typed error when the request fails', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(new Response(null, { status: 500 })), + ); + + await expect(fetchExercises()).rejects.toEqual(new ExercisesApiError(500)); +}); diff --git a/apps/web/src/features/exercises/exercises.api.ts b/apps/web/src/features/exercises/exercises.api.ts new file mode 100644 index 0000000..5259b52 --- /dev/null +++ b/apps/web/src/features/exercises/exercises.api.ts @@ -0,0 +1,28 @@ +/** Fetches and validates exercises available to the authenticated user. */ +import { + exerciseListResponseSchema, + type ExerciseListResponse, +} from '@goforlift/contracts'; + +export class ExercisesApiError extends Error { + constructor(public readonly status: number) { + super(`Exercises API request failed with status ${status}`); + this.name = 'ExercisesApiError'; + } +} + +export async function fetchExercises( + signal?: AbortSignal, +): Promise { + const response = await fetch('/api/exercises', { + credentials: 'include', + signal, + }); + + if (!response.ok) { + throw new ExercisesApiError(response.status); + } + + const data: unknown = await response.json(); + return exerciseListResponseSchema.parse(data); +} diff --git a/apps/web/src/features/exercises/exercises.query.ts b/apps/web/src/features/exercises/exercises.query.ts new file mode 100644 index 0000000..f973e40 --- /dev/null +++ b/apps/web/src/features/exercises/exercises.query.ts @@ -0,0 +1,16 @@ +/** Provides the TanStack Query source for exercises available to the user. */ +import { queryOptions, useQuery } from '@tanstack/react-query'; + +import { fetchExercises } from './exercises.api'; + +export const exercisesQueryKey = ['exercises'] as const; + +export const exercisesQueryOptions = queryOptions({ + queryKey: exercisesQueryKey, + queryFn: ({ signal }) => fetchExercises(signal), + retry: false, +}); + +export function useExercises() { + return useQuery(exercisesQueryOptions); +} diff --git a/apps/web/src/features/routines/exercise-picker.test.tsx b/apps/web/src/features/routines/exercise-picker.test.tsx new file mode 100644 index 0000000..1925225 --- /dev/null +++ b/apps/web/src/features/routines/exercise-picker.test.tsx @@ -0,0 +1,74 @@ +/** Verifies exercise loading and client-side search in the routine picker. */ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { + createMemoryHistory, + createRootRoute, + createRouter, + RouterProvider, +} from '@tanstack/react-router'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import type { PropsWithChildren } from 'react'; +import { afterEach, expect, it, vi } from 'vitest'; + +import { ExercisePicker } from './exercise-picker'; + +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('filters available exercises by name', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + Response.json({ + exercises: [ + { + id: '26d34dc0-8e4c-4bd0-9e3b-7b839b44e486', + name: 'Bicep Curl', + description: null, + isCustom: false, + }, + { + id: '43805735-8ee9-4cbd-9da1-c7ec5965cb03', + name: 'Shoulder Press', + description: null, + isCustom: false, + }, + ], + }), + ), + ); + + render(, { wrapper: Wrapper }); + + expect(await screen.findByText('Bicep Curl')).toBeVisible(); + expect(screen.getByText('Shoulder Press')).toBeVisible(); + + fireEvent.change( + screen.getByRole('searchbox', { name: 'Search exercises' }), + { + target: { value: 'shoulder' }, + }, + ); + + expect(screen.queryByText('Bicep Curl')).not.toBeInTheDocument(); + expect(screen.getByText('Shoulder Press')).toBeVisible(); +}); diff --git a/apps/web/src/features/routines/exercise-picker.tsx b/apps/web/src/features/routines/exercise-picker.tsx new file mode 100644 index 0000000..6578bbd --- /dev/null +++ b/apps/web/src/features/routines/exercise-picker.tsx @@ -0,0 +1,108 @@ +/** Lets a routine author search the exercises available to add to a routine. */ +import type { ExerciseSummary } from '@goforlift/contracts'; +import { Link } from '@tanstack/react-router'; +import { ChevronRight, Dumbbell, Plus, Search, X } from 'lucide-react'; +import { useState } from 'react'; + +import { AddActionButton } from '../../components/ui/add-action-button'; +import { Button } from '../../components/ui/button'; +import { Input } from '../../components/ui/input'; +import { LoadingState } from '../../components/ui/loading-state'; +import { PageTitle } from '../../components/ui/page-title'; +import { useExercises } from '../exercises/exercises.query'; + +export function ExercisePicker() { + const [searchTerm, setSearchTerm] = useState(''); + const exercisesQuery = useExercises(); + + const normalizedSearchTerm = searchTerm.trim().toLocaleLowerCase(); + const exercises = exercisesQuery.data?.exercises ?? []; + const visibleExercises = exercises.filter((exercise) => + exercise.name.toLocaleLowerCase().includes(normalizedSearchTerm), + ); + + let content = ; + + if (exercisesQuery.isPending) { + content = ; + } else if (exercisesQuery.isError) { + content = ( +

Exercises could not be loaded.

+ ); + } else if (visibleExercises.length === 0) { + content = ( +

No exercises found.

+ ); + } + + return ( +
+
+ + Add Exercise +
+ +
+ setSearchTerm(event.target.value)} + placeholder="Search exercises…" + type="search" + value={searchTerm} + /> +
+ +
{content}
+ +
+ + +
+
+ ); +} + +function ExerciseList({ exercises }: { exercises: ExerciseSummary[] }) { + return ( +
    + {exercises.map((exercise) => ( +
  • +
    +
    +

    + {exercise.name} +

    +
  • + ))} +
+ ); +} diff --git a/apps/web/src/features/routines/routine-editor.test.tsx b/apps/web/src/features/routines/routine-editor.test.tsx index cbf6a80..de5c7a3 100644 --- a/apps/web/src/features/routines/routine-editor.test.tsx +++ b/apps/web/src/features/routines/routine-editor.test.tsx @@ -28,7 +28,10 @@ describe('routine editor', () => { screen.getByRole('heading', { name: 'Exercises (0)' }), ).toBeVisible(); expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled(); - expect(screen.getByRole('button', { name: 'Add Exercise' })).toBeDisabled(); + expect(screen.getByRole('link', { name: 'Add Exercise' })).toHaveAttribute( + 'href', + '/routines/new/exercises', + ); expect(screen.getByRole('button', { name: 'Add schedule' })).toBeDisabled(); expect( screen.getByRole('link', { name: 'Back to routines' }), diff --git a/apps/web/src/features/routines/routine-editor.tsx b/apps/web/src/features/routines/routine-editor.tsx index e306fd4..20cd7d3 100644 --- a/apps/web/src/features/routines/routine-editor.tsx +++ b/apps/web/src/features/routines/routine-editor.tsx @@ -66,9 +66,11 @@ function ExerciseSection() {

Exercises (0)

- -
diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 6523e83..0992daf 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -16,6 +16,7 @@ import { Route as AppHistoryRouteImport } from './routes/_app.history' import { Route as AppRoutinesRouteImport } from './routes/_app.routines' import { Route as AppSettingsRouteImport } from './routes/_app.settings' import { Route as AppRoutinesNewRouteImport } from './routes/_app.routines_.new' +import { Route as AppRoutinesNewExercisesRouteImport } from './routes/_app.routines_.new_.exercises' const IndexRoute = IndexRouteImport.update({ id: '/', @@ -51,6 +52,11 @@ const AppRoutinesNewRoute = AppRoutinesNewRouteImport.update({ path: '/routines/new', getParentRoute: () => AppRoute, } as any) +const AppRoutinesNewExercisesRoute = AppRoutinesNewExercisesRouteImport.update({ + id: '/routines_/new_/exercises', + path: '/routines/new/exercises', + getParentRoute: () => AppRoute, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute @@ -59,6 +65,7 @@ export interface FileRoutesByFullPath { '/routines': typeof AppRoutinesRoute '/settings': typeof AppSettingsRoute '/routines/new': typeof AppRoutinesNewRoute + '/routines/new/exercises': typeof AppRoutinesNewExercisesRoute } export interface FileRoutesByTo { '/': typeof IndexRoute @@ -67,6 +74,7 @@ export interface FileRoutesByTo { '/routines': typeof AppRoutinesRoute '/settings': typeof AppSettingsRoute '/routines/new': typeof AppRoutinesNewRoute + '/routines/new/exercises': typeof AppRoutinesNewExercisesRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -77,6 +85,7 @@ export interface FileRoutesById { '/_app/routines': typeof AppRoutinesRoute '/_app/settings': typeof AppSettingsRoute '/_app/routines_/new': typeof AppRoutinesNewRoute + '/_app/routines_/new_/exercises': typeof AppRoutinesNewExercisesRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -87,6 +96,7 @@ export interface FileRouteTypes { | '/routines' | '/settings' | '/routines/new' + | '/routines/new/exercises' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -95,6 +105,7 @@ export interface FileRouteTypes { | '/routines' | '/settings' | '/routines/new' + | '/routines/new/exercises' id: | '__root__' | '/' @@ -104,6 +115,7 @@ export interface FileRouteTypes { | '/_app/routines' | '/_app/settings' | '/_app/routines_/new' + | '/_app/routines_/new_/exercises' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -162,6 +174,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppRoutinesNewRouteImport parentRoute: typeof AppRoute } + '/_app/routines_/new_/exercises': { + id: '/_app/routines_/new_/exercises' + path: '/routines/new/exercises' + fullPath: '/routines/new/exercises' + preLoaderRoute: typeof AppRoutinesNewExercisesRouteImport + parentRoute: typeof AppRoute + } } } @@ -171,6 +190,7 @@ interface AppRouteChildren { AppRoutinesRoute: typeof AppRoutinesRoute AppSettingsRoute: typeof AppSettingsRoute AppRoutinesNewRoute: typeof AppRoutinesNewRoute + AppRoutinesNewExercisesRoute: typeof AppRoutinesNewExercisesRoute } const AppRouteChildren: AppRouteChildren = { @@ -179,6 +199,7 @@ const AppRouteChildren: AppRouteChildren = { AppRoutinesRoute: AppRoutinesRoute, AppSettingsRoute: AppSettingsRoute, AppRoutinesNewRoute: AppRoutinesNewRoute, + AppRoutinesNewExercisesRoute: AppRoutinesNewExercisesRoute, } const AppRouteWithChildren = AppRoute._addFileChildren(AppRouteChildren) diff --git a/apps/web/src/routes/_app.routines_.new_.exercises.tsx b/apps/web/src/routes/_app.routines_.new_.exercises.tsx new file mode 100644 index 0000000..7b7d5f8 --- /dev/null +++ b/apps/web/src/routes/_app.routines_.new_.exercises.tsx @@ -0,0 +1,8 @@ +/** Registers the protected exercise-picker route for routine creation. */ +import { createFileRoute } from '@tanstack/react-router'; + +import { ExercisePicker } from '../features/routines/exercise-picker'; + +export const Route = createFileRoute('/_app/routines_/new_/exercises')({ + component: ExercisePicker, +});