Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/features/exercises/exercises.query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
66 changes: 37 additions & 29 deletions apps/web/src/features/routines/exercise-configuration.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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(
Expand All @@ -59,31 +66,22 @@ function TestLayout() {
);
}

function DraftExerciseCount() {
const { control } = useFormContext<RoutineDraftFormValues>();
const exercises = useWatch({ control, name: 'exercises' });

return <p>Saved exercises: {exercises.length}</p>;
}

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);

Expand All @@ -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);
});
14 changes: 10 additions & 4 deletions apps/web/src/features/routines/exercise-configuration.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,10 +91,12 @@ function ExerciseConfigurationForm({
const navigate = useNavigate();
const { getValues, setValue: setDraftValue } =
useFormContext<RoutineDraftFormValues>();

const defaultValues = createRoutineExerciseInputSchema.parse({
exerciseId: exercise.id,
position: getValues('exercises').length,
});

const {
handleSubmit,
register,
Expand All @@ -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' });
}

Expand Down
18 changes: 17 additions & 1 deletion apps/web/src/features/routines/routine-draft-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<typeof routineDraftFormSchema>;
export type RoutineDraftExerciseFormValues = z.input<
typeof routineDraftExerciseSchema
>;
type ParsedRoutineDraftFormValues = z.output<typeof routineDraftFormSchema>;

export function RoutineDraftFormProvider({
Expand Down
18 changes: 16 additions & 2 deletions apps/web/src/features/routines/routine-editor.test.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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(
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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,
Expand All @@ -115,7 +125,11 @@ function renderEditor() {
]),
});

render(<RouterProvider router={testRouter} />);
render(
<QueryClientProvider client={queryClient}>
<RouterProvider router={testRouter} />
</QueryClientProvider>,
);

return testRouter;
}
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/features/routines/routine-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -100,7 +101,10 @@ function ExerciseSection({ onAddExercise }: { onAddExercise: () => void }) {
<Label asChild>
<h2>Exercises ({exercises.length})</h2>
</Label>
<div className="mt-3">

{exercises.length > 0 && <RoutineExerciseList exercises={exercises} />}

<div className={exercises.length > 0 ? 'mt-1' : 'mt-3'}>
<AddActionButton onClick={onAddExercise} type="button">
<Plus aria-hidden="true" />
Add Exercise
Expand Down
85 changes: 85 additions & 0 deletions apps/web/src/features/routines/routine-exercise-list.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/** Displays configured exercises in the routine draft. */
import { Dumbbell, GripVertical, MoreHorizontal } from 'lucide-react';

import { cn } from '../../lib/utils';
import type { RoutineDraftExerciseFormValues } from './routine-draft-form';

export function RoutineExerciseList({
exercises,
}: {
exercises: RoutineDraftExerciseFormValues[];
}) {
return (
<div className="mt-3 overflow-hidden rounded-lg bg-surface-elevated">
<div
className={cn(
'hidden grid-cols-[minmax(0,1fr)_3.5rem_3.5rem_5rem_4rem_2rem] items-center gap-2 px-3 py-2',
'text-xs text-muted-foreground',
'sm:grid',
)}
>
<span className="pl-8">Exercise</span>
<span className="text-center">Sets</span>
<span className="text-center">Reps</span>
<span className="text-center">Weight</span>
<span className="text-center">Rest</span>
<span />
</div>

<ul>
{exercises.map((exercise) => (
<RoutineExerciseRow exercise={exercise} key={exercise.position} />
))}
</ul>
</div>
);
}

function RoutineExerciseRow({
exercise,
}: {
exercise: RoutineDraftExerciseFormValues;
}) {
return (
<li
className={cn(
'grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 border-t border-border/30 px-3 py-3 first:border-t-0',
'sm:grid-cols-[minmax(0,1fr)_3.5rem_3.5rem_5rem_4rem_2rem] sm:gap-2 sm:py-2.5',
)}
>
<div className="flex min-w-0 items-center gap-2.5">
<GripVertical
aria-hidden="true"
className="size-4 shrink-0 text-muted-foreground"
/>
<div className="flex size-9 shrink-0 items-center justify-center rounded-md bg-background text-primary">
<Dumbbell aria-hidden="true" className="size-5" />
</div>
<div className="min-w-0">
<p className="truncate text-sm font-medium">{exercise.name}</p>
<p className="text-xs text-muted-foreground sm:hidden">
{exercise.sets} × {exercise.targetReps} · {exercise.weight}{' '}
{exercise.weightUnit} · {exercise.restBetweenSetsSeconds}s rest
</p>
</div>
</div>

<span className="hidden text-center text-sm sm:block">
{exercise.sets}
</span>
<span className="hidden text-center text-sm sm:block">
{exercise.targetReps}
</span>
<span className="hidden text-center text-sm sm:block">
{exercise.weight} {exercise.weightUnit}
</span>
<span className="hidden text-center text-sm sm:block">
{exercise.restBetweenSetsSeconds}s
</span>
<MoreHorizontal
aria-hidden="true"
className="size-4 text-muted-foreground"
/>
</li>
);
}
2 changes: 2 additions & 0 deletions apps/web/src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { RouterProvider } from '@tanstack/react-router';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
Expand All @@ -17,6 +18,7 @@ createRoot(rootElement).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
{import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />}
</QueryClientProvider>
</StrictMode>,
);
Loading