From 67465264dd6f79820d0beb5559c8a94becc6090a Mon Sep 17 00:00:00 2001 From: rd-code-23 Date: Tue, 8 Sep 2026 15:04:33 -0700 Subject: [PATCH 1/3] feat(web): save routine drafts --- .../routines/api/routines.api.test.ts | 52 ++++++++++++++- .../src/features/routines/api/routines.api.ts | 20 ++++++ .../routines/api/routines.mutation.ts | 15 +++++ .../create/editor/routine-editor.test.tsx | 3 + .../routines/create/editor/routine-editor.tsx | 66 +++++++++++++++++-- .../exercise-configuration.test.tsx | 1 + .../routines/create/routine-draft-form.tsx | 24 ++++++- .../create/routine-draft.mapper.test.ts | 39 +++++++++++ .../routines/create/routine-draft.mapper.ts | 19 ++++++ 9 files changed, 230 insertions(+), 9 deletions(-) create mode 100644 apps/web/src/features/routines/api/routines.mutation.ts create mode 100644 apps/web/src/features/routines/create/routine-draft.mapper.test.ts create mode 100644 apps/web/src/features/routines/create/routine-draft.mapper.ts diff --git a/apps/web/src/features/routines/api/routines.api.test.ts b/apps/web/src/features/routines/api/routines.api.test.ts index 61482fd..da55f33 100644 --- a/apps/web/src/features/routines/api/routines.api.test.ts +++ b/apps/web/src/features/routines/api/routines.api.test.ts @@ -1,7 +1,7 @@ /** Verifies routine requests include credentials and validate backend responses. */ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { fetchRoutines, RoutinesApiError } from './routines.api'; +import { createRoutine, fetchRoutines, RoutinesApiError } from './routines.api'; afterEach(() => vi.unstubAllGlobals()); @@ -54,3 +54,53 @@ describe('fetchRoutines', () => { await expect(fetchRoutines()).rejects.toThrow(); }); }); + +describe('createRoutine', () => { + it('creates a routine with CSRF protection and validates the response', async () => { + const response = { + id: '26d34dc0-8e4c-4bd0-9e3b-7b839b44e486', + name: 'Upper Body', + description: null, + exerciseCount: 1, + scheduledDays: [1], + createdAt: '2026-08-26T10:00:00.000Z', + updatedAt: '2026-08-26T10:00:00.000Z', + }; + const input = { + name: 'Upper Body', + exercises: [ + { + exerciseId: '16d34dc0-8e4c-4bd0-9e3b-7b839b44e486', + position: 0, + sets: 3, + targetReps: 8, + weight: 10, + weightUnit: 'lb' as const, + restBetweenSetsSeconds: 60, + restAfterExerciseSeconds: 0, + notes: null, + }, + ], + schedules: [{ dayOfWeek: 1, localTime: '18:00:00' }], + }; + const fetchMock = vi + .fn() + .mockResolvedValueOnce(Response.json({ csrfToken: 'csrf-token-123' })) + .mockResolvedValueOnce(Response.json(response, { status: 201 })); + vi.stubGlobal('fetch', fetchMock); + + await expect(createRoutine(input)).resolves.toEqual(response); + expect(fetchMock).toHaveBeenNthCalledWith(1, '/auth/csrf-token', { + credentials: 'include', + }); + + const requestInit = fetchMock.mock.calls[1]?.[1] as RequestInit; + expect(fetchMock.mock.calls[1]?.[0]).toBe('/api/routines'); + expect(requestInit.credentials).toBe('include'); + expect(requestInit.method).toBe('POST'); + expect(new Headers(requestInit.headers).get('X-CSRF-Token')).toBe( + 'csrf-token-123', + ); + expect(JSON.parse(requestInit.body as string)).toEqual(input); + }); +}); diff --git a/apps/web/src/features/routines/api/routines.api.ts b/apps/web/src/features/routines/api/routines.api.ts index 5c56dde..f68aeed 100644 --- a/apps/web/src/features/routines/api/routines.api.ts +++ b/apps/web/src/features/routines/api/routines.api.ts @@ -1,9 +1,15 @@ /** Fetches and validates browser-safe routine data from the backend API. */ import { + createRoutineInputSchema, routineListResponseSchema, + routineSummarySchema, + type CreateRoutineInput, type RoutineListResponse, + type RoutineSummary, } from '@goforlift/contracts'; +import { requestWithCsrf } from '@/features/auth/auth.api'; + export class RoutinesApiError extends Error { constructor(public readonly status: number) { super(`Routines API request failed with status ${status}`); @@ -26,3 +32,17 @@ export async function fetchRoutines( const data: unknown = await response.json(); return routineListResponseSchema.parse(data); } + +export async function createRoutine( + input: CreateRoutineInput, +): Promise { + const validatedInput = createRoutineInputSchema.parse(input); + const response = await requestWithCsrf('/api/routines', { + body: JSON.stringify(validatedInput), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }); + const data: unknown = await response.json(); + + return routineSummarySchema.parse(data); +} diff --git a/apps/web/src/features/routines/api/routines.mutation.ts b/apps/web/src/features/routines/api/routines.mutation.ts new file mode 100644 index 0000000..96a8997 --- /dev/null +++ b/apps/web/src/features/routines/api/routines.mutation.ts @@ -0,0 +1,15 @@ +/** Creates routines and marks the authenticated routine collection as stale. */ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { createRoutine } from './routines.api'; +import { routinesQueryKey } from './routines.query'; + +export function useCreateRoutineMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: createRoutine, + onSuccess: () => + queryClient.invalidateQueries({ queryKey: routinesQueryKey }), + }); +} diff --git a/apps/web/src/features/routines/create/editor/routine-editor.test.tsx b/apps/web/src/features/routines/create/editor/routine-editor.test.tsx index 4eca5e3..9746ad9 100644 --- a/apps/web/src/features/routines/create/editor/routine-editor.test.tsx +++ b/apps/web/src/features/routines/create/editor/routine-editor.test.tsx @@ -25,6 +25,7 @@ afterEach(() => { describe('routine editor', () => { it('shows the initial editor shell without enabling unfinished actions', async () => { + const user = userEvent.setup(); const fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); renderEditor(); @@ -40,6 +41,8 @@ describe('routine editor', () => { screen.getByRole('heading', { name: 'Exercises (0)' }), ).toBeVisible(); expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled(); + await user.type(screen.getByLabelText('Routine Name'), 'Push Day'); + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled(); expect( screen.queryByRole('button', { name: 'Preview' }), ).not.toBeInTheDocument(); diff --git a/apps/web/src/features/routines/create/editor/routine-editor.tsx b/apps/web/src/features/routines/create/editor/routine-editor.tsx index 89b4a5e..b09b2f2 100644 --- a/apps/web/src/features/routines/create/editor/routine-editor.tsx +++ b/apps/web/src/features/routines/create/editor/routine-editor.tsx @@ -8,17 +8,29 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { PageTitle } from '@/components/ui/page-title'; +import { useCreateRoutineMutation } from '@/features/routines/api/routines.mutation'; import { cn } from '@/lib/utils'; import type { + ParsedRoutineDraftFormValues, RoutineDraftExerciseFormValues, RoutineDraftFormValues, } from '../routine-draft-form'; +import { toCreateRoutineInput } from '../routine-draft.mapper'; import { RoutineExerciseList } from './routine-exercise-list'; import { RoutineScheduleField } from './routine-schedule-field'; export function RoutineEditor() { const navigate = useNavigate(); - const { reset } = useFormContext(); + const createRoutineMutation = useCreateRoutineMutation(); + const { + formState: { isValid }, + handleSubmit, + reset, + } = useFormContext< + RoutineDraftFormValues, + unknown, + ParsedRoutineDraftFormValues + >(); const addExercise = async () => { await navigate({ to: '/routines/new/exercises' }); @@ -32,11 +44,34 @@ export function RoutineEditor() { }); }; + function saveRoutine(draft: ParsedRoutineDraftFormValues) { + createRoutineMutation.mutate(toCreateRoutineInput(draft), { + onSuccess: () => { + reset(); + void navigate({ to: '/routines' }); + }, + }); + } + return (
- reset()} /> + reset()} + /> -
+ {createRoutineMutation.isError && ( +

+ Routine could not be saved. Please try again. +

+ )} + + void handleSubmit(saveRoutine)(event)} + > @@ -51,7 +86,15 @@ export function RoutineEditor() { ); } -function EditorHeader({ onClear }: { onClear: () => void }) { +function EditorHeader({ + canSave, + isSaving, + onClear, +}: { + canSave: boolean; + isSaving: boolean; + onClear: () => void; +}) { return (
Create Routine
- - +
); diff --git a/apps/web/src/features/routines/create/exercise-configuration/exercise-configuration.test.tsx b/apps/web/src/features/routines/create/exercise-configuration/exercise-configuration.test.tsx index dae0a35..554ac98 100644 --- a/apps/web/src/features/routines/create/exercise-configuration/exercise-configuration.test.tsx +++ b/apps/web/src/features/routines/create/exercise-configuration/exercise-configuration.test.tsx @@ -110,6 +110,7 @@ it('shows the selected exercise and initial configuration fields', async () => { expect(screen.getByText('Bicep Curl')).toBeVisible(); expect(screen.getByText('10 lb')).toBeVisible(); expect(screen.getByText('60s')).toBeVisible(); + expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled(); await user.click( screen.getByRole('button', { name: 'Actions for Bicep Curl' }), diff --git a/apps/web/src/features/routines/create/routine-draft-form.tsx b/apps/web/src/features/routines/create/routine-draft-form.tsx index 7ea85d9..c3d936f 100644 --- a/apps/web/src/features/routines/create/routine-draft-form.tsx +++ b/apps/web/src/features/routines/create/routine-draft-form.tsx @@ -1,6 +1,7 @@ /** Provides one routine draft form across every route in the creation flow. */ import { zodResolver } from '@hookform/resolvers/zod'; import { + createRoutineInputSchema, createRoutineExerciseInputSchema, createRoutineScheduleInputSchema, exerciseSummarySchema, @@ -19,9 +20,26 @@ const routineDraftExerciseSchema = createRoutineExerciseInputSchema.extend({ name: exerciseSummarySchema.shape.name, }); +const routineDraftExercisesSchema = z + .array(routineDraftExerciseSchema) + .superRefine((exercises, context) => { + const result = + createRoutineInputSchema.shape.exercises.safeParse(exercises); + + if (!result.success) { + for (const issue of result.error.issues) { + // Exercise fields are already validated by routineDraftExerciseSchema. + // Reuse only collection rules, such as the shared minimum of one exercise. + if (issue.path.length === 0) { + context.addIssue({ ...issue }); + } + } + } + }); + const routineDraftFormSchema = z.object({ name: routineNameSchema, - exercises: z.array(routineDraftExerciseSchema), + exercises: routineDraftExercisesSchema, schedules: z.array(createRoutineScheduleInputSchema), }); // Exercise defaults live in the shared Zod contract so every consumer uses the @@ -33,7 +51,9 @@ export type RoutineDraftFormValues = z.input; export type RoutineDraftExerciseFormValues = z.input< typeof routineDraftExerciseSchema >; -type ParsedRoutineDraftFormValues = z.output; +export type ParsedRoutineDraftFormValues = z.output< + typeof routineDraftFormSchema +>; export function RoutineDraftFormProvider({ children, diff --git a/apps/web/src/features/routines/create/routine-draft.mapper.test.ts b/apps/web/src/features/routines/create/routine-draft.mapper.test.ts new file mode 100644 index 0000000..72e9a27 --- /dev/null +++ b/apps/web/src/features/routines/create/routine-draft.mapper.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; + +import { toCreateRoutineInput } from './routine-draft.mapper'; + +describe('toCreateRoutineInput', () => { + it('removes the UI-only exercise name from the API request', () => { + const input = toCreateRoutineInput({ + name: 'Upper Body', + exercises: [ + { + exerciseId: '16d34dc0-8e4c-4bd0-9e3b-7b839b44e486', + name: 'Bicep Curl', + position: 0, + sets: 3, + targetReps: 8, + weight: 10, + weightUnit: 'lb', + restBetweenSetsSeconds: 60, + restAfterExerciseSeconds: 0, + notes: null, + }, + ], + schedules: [], + }); + + expect(input.exercises[0]).toEqual({ + exerciseId: '16d34dc0-8e4c-4bd0-9e3b-7b839b44e486', + position: 0, + sets: 3, + targetReps: 8, + weight: 10, + weightUnit: 'lb', + restBetweenSetsSeconds: 60, + restAfterExerciseSeconds: 0, + notes: null, + }); + expect(input.exercises[0]).not.toHaveProperty('name'); + }); +}); diff --git a/apps/web/src/features/routines/create/routine-draft.mapper.ts b/apps/web/src/features/routines/create/routine-draft.mapper.ts new file mode 100644 index 0000000..4208e2c --- /dev/null +++ b/apps/web/src/features/routines/create/routine-draft.mapper.ts @@ -0,0 +1,19 @@ +/** Converts UI-only routine draft data into the strict create-routine contract. */ +import { + createRoutineExerciseInputSchema, + createRoutineInputSchema, + type CreateRoutineInput, +} from '@goforlift/contracts'; + +import type { ParsedRoutineDraftFormValues } from './routine-draft-form'; + +export function toCreateRoutineInput( + draft: ParsedRoutineDraftFormValues, +): CreateRoutineInput { + return createRoutineInputSchema.parse({ + ...draft, + exercises: draft.exercises.map((exercise) => + createRoutineExerciseInputSchema.strip().parse(exercise), + ), + }); +} From a7571d0e8d39f616b989a13982648e05be8267c3 Mon Sep 17 00:00:00 2001 From: rd-code-23 Date: Wed, 9 Sep 2026 13:28:33 -0700 Subject: [PATCH 2/3] fix(web): show routine validation feedback on save --- .../create/editor/routine-editor.test.tsx | 30 +++++++- .../routines/create/editor/routine-editor.tsx | 72 ++++++++++++++----- .../src/routines/routine.contract.test.ts | 14 ++-- .../src/routines/routine.contract.ts | 4 +- 4 files changed, 92 insertions(+), 28 deletions(-) diff --git a/apps/web/src/features/routines/create/editor/routine-editor.test.tsx b/apps/web/src/features/routines/create/editor/routine-editor.test.tsx index 9746ad9..d8df598 100644 --- a/apps/web/src/features/routines/create/editor/routine-editor.test.tsx +++ b/apps/web/src/features/routines/create/editor/routine-editor.test.tsx @@ -40,9 +40,9 @@ describe('routine editor', () => { expect( screen.getByRole('heading', { name: 'Exercises (0)' }), ).toBeVisible(); - expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled(); await user.type(screen.getByLabelText('Routine Name'), 'Push Day'); - expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled(); expect( screen.queryByRole('button', { name: 'Preview' }), ).not.toBeInTheDocument(); @@ -54,6 +54,32 @@ describe('routine editor', () => { expect(fetchMock).not.toHaveBeenCalled(); }); + it('shows inline errors when saving an incomplete routine', async () => { + const user = userEvent.setup(); + renderEditor(); + + await user.click(await screen.findByRole('button', { name: 'Save' })); + + expect(screen.getByText('Enter a routine name.')).toBeVisible(); + expect(screen.getByText('Add at least one exercise.')).toBeVisible(); + expect(screen.getByText('Complete required fields.')).toBeVisible(); + + await user.type(screen.getByLabelText('Routine Name'), 'Push Day'); + expect(screen.getByText('Complete required fields.')).toHaveClass( + 'invisible', + ); + + await user.clear(screen.getByLabelText('Routine Name')); + expect(screen.getByText('Complete required fields.')).toHaveClass( + 'invisible', + ); + + await user.click(screen.getByRole('button', { name: 'Save' })); + expect(screen.getByText('Complete required fields.')).not.toHaveClass( + 'invisible', + ); + }); + it('shows live validation and clears the error after correction', async () => { const user = userEvent.setup(); renderEditor(); diff --git a/apps/web/src/features/routines/create/editor/routine-editor.tsx b/apps/web/src/features/routines/create/editor/routine-editor.tsx index b09b2f2..1cd105c 100644 --- a/apps/web/src/features/routines/create/editor/routine-editor.tsx +++ b/apps/web/src/features/routines/create/editor/routine-editor.tsx @@ -1,6 +1,7 @@ /** Presents the responsive visual shell for creating a registered-user routine. */ import { Link, useNavigate } from '@tanstack/react-router'; import { ArrowLeft, Plus } from 'lucide-react'; +import { useState } from 'react'; import { useFormContext, useWatch } from 'react-hook-form'; import { AddActionButton } from '@/components/ui/add-action-button'; @@ -21,12 +22,12 @@ import { RoutineScheduleField } from './routine-schedule-field'; export function RoutineEditor() { const navigate = useNavigate(); + + const [showRequiredFieldsMessage, setShowRequiredFieldsMessage] = + useState(false); + const createRoutineMutation = useCreateRoutineMutation(); - const { - formState: { isValid }, - handleSubmit, - reset, - } = useFormContext< + const { handleSubmit, reset } = useFormContext< RoutineDraftFormValues, unknown, ParsedRoutineDraftFormValues @@ -45,6 +46,7 @@ export function RoutineEditor() { }; function saveRoutine(draft: ParsedRoutineDraftFormValues) { + setShowRequiredFieldsMessage(false); createRoutineMutation.mutate(toCreateRoutineInput(draft), { onSuccess: () => { reset(); @@ -56,9 +58,12 @@ export function RoutineEditor() { return (
reset()} + onClear={() => { + reset(); + setShowRequiredFieldsMessage(false); + }} + showRequiredFieldsMessage={showRequiredFieldsMessage} /> {createRoutineMutation.isError && ( @@ -70,7 +75,13 @@ export function RoutineEditor() { void handleSubmit(saveRoutine)(event)} + onChange={() => setShowRequiredFieldsMessage(false)} + onClick={() => setShowRequiredFieldsMessage(false)} + onSubmit={(event) => + void handleSubmit(saveRoutine, () => + setShowRequiredFieldsMessage(true), + )(event) + } > @@ -87,13 +98,13 @@ export function RoutineEditor() { } function EditorHeader({ - canSave, isSaving, onClear, + showRequiredFieldsMessage, }: { - canSave: boolean; isSaving: boolean; onClear: () => void; + showRequiredFieldsMessage: boolean; }) { return (
@@ -112,13 +123,25 @@ function EditorHeader({ > Clear - +
+ +

+ Complete required fields. +

+
); @@ -168,9 +191,14 @@ function ExerciseSection({ onAddExercise: () => void; onEditExercise: (exercise: RoutineDraftExerciseFormValues) => void; }) { - const { control, getValues, setValue } = - useFormContext(); + const { + control, + formState: { errors, submitCount }, + getValues, + setValue, + } = useFormContext(); const exercises = useWatch({ control, name: 'exercises' }); + const errorMessage = errors.exercises?.message; function removeExercise(position: number) { const remainingExercises = getValues('exercises') @@ -192,6 +220,12 @@ function ExerciseSection({

Exercises ({exercises.length})

+ {submitCount > 0 && errorMessage && ( +

+ {errorMessage} +

+ )} + {exercises.length > 0 && ( { }); it('rejects invalid exercise configuration and schedules', () => { - expect(() => - createRoutineInputSchema.parse({ - ...validCreateRoutineInput, - exercises: [], - }), - ).toThrow(); + const emptyExercisesResult = createRoutineInputSchema.safeParse({ + ...validCreateRoutineInput, + exercises: [], + }); + + expect(emptyExercisesResult.error?.issues[0]?.message).toBe( + 'Add at least one exercise.', + ); expect(() => createRoutineInputSchema.parse({ ...validCreateRoutineInput, diff --git a/packages/contracts/src/routines/routine.contract.ts b/packages/contracts/src/routines/routine.contract.ts index 06877da..e31bd47 100644 --- a/packages/contracts/src/routines/routine.contract.ts +++ b/packages/contracts/src/routines/routine.contract.ts @@ -37,7 +37,9 @@ export const createRoutineInputSchema = z .object({ name: routineNameSchema, description: z.string().trim().nullable().optional(), - exercises: z.array(createRoutineExerciseInputSchema).min(1), + exercises: z + .array(createRoutineExerciseInputSchema) + .min(1, 'Add at least one exercise.'), schedules: z.array(createRoutineScheduleInputSchema).default([]), }) .strict() From 01cdd2dc0597396dcaf964f629f046f26b3ce788 Mon Sep 17 00:00:00 2001 From: rd-code-23 Date: Sat, 12 Sep 2026 14:13:53 -0700 Subject: [PATCH 3/3] feat(web): reorder routine exercises --- apps/web/package.json | 2 + .../routines/create/editor/routine-editor.tsx | 13 +++ .../create/editor/routine-exercise-list.tsx | 60 ++++++++++---- .../editor/routine-exercise-order.test.ts | 60 ++++++++++++++ .../create/editor/routine-exercise-order.ts | 14 ++++ .../exercise-configuration.tsx | 6 +- .../routines/create/routine-draft-form.tsx | 11 +-- .../create/routine-draft.mapper.test.ts | 4 +- apps/web/src/test/setup.ts | 6 ++ pnpm-lock.yaml | 79 +++++++++++++++++++ 10 files changed, 234 insertions(+), 21 deletions(-) create mode 100644 apps/web/src/features/routines/create/editor/routine-exercise-order.test.ts create mode 100644 apps/web/src/features/routines/create/editor/routine-exercise-order.ts diff --git a/apps/web/package.json b/apps/web/package.json index 0ef5e1f..a241576 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,6 +12,8 @@ "test": "vitest run" }, "dependencies": { + "@dnd-kit/helpers": "^0.5.0", + "@dnd-kit/react": "^0.5.0", "@goforlift/contracts": "workspace:*", "@hookform/resolvers": "^5.2.1", "@tanstack/react-query": "^5.85.5", diff --git a/apps/web/src/features/routines/create/editor/routine-editor.tsx b/apps/web/src/features/routines/create/editor/routine-editor.tsx index 1cd105c..e624447 100644 --- a/apps/web/src/features/routines/create/editor/routine-editor.tsx +++ b/apps/web/src/features/routines/create/editor/routine-editor.tsx @@ -17,6 +17,7 @@ import type { RoutineDraftFormValues, } from '../routine-draft-form'; import { toCreateRoutineInput } from '../routine-draft.mapper'; +import { reorderRoutineExercises } from './routine-exercise-order'; import { RoutineExerciseList } from './routine-exercise-list'; import { RoutineScheduleField } from './routine-schedule-field'; @@ -214,6 +215,17 @@ function ExerciseSection({ }); } + function reorderExercises(fromIndex: number, toIndex: number) { + setValue( + 'exercises', + reorderRoutineExercises(exercises, fromIndex, toIndex), + { + shouldDirty: true, + shouldValidate: true, + }, + ); + } + return (
); } function RoutineExerciseRow({ exercise, + index, onEdit, onRemove, }: { exercise: RoutineDraftExerciseFormValues; + index: number; onEdit: (exercise: RoutineDraftExerciseFormValues) => void; onRemove: (position: number) => void; }) { + const { handleRef, isDragging, ref } = useSortable({ + id: exercise.draftExerciseId, + index, + }); + return (
  • -