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 (
+
+
+ {label}
+
+ );
+}
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}
+
+
+
+
+ Create Custom Exercise
+
+
+
+ );
+}
+
+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)
-
-
- Add Exercise
+
+
+
+ Add Exercise
+
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,
+});