From 14e627ab75aa2d3a456a253db9e0ec8581f8695c Mon Sep 17 00:00:00 2001 From: rd-code-23 Date: Sun, 30 Aug 2026 09:50:28 -0700 Subject: [PATCH 1/7] refactor(web): organize routine feature files --- .../routines/{ => api}/routines.api.test.ts | 0 .../features/routines/{ => api}/routines.api.ts | 0 .../features/routines/{ => api}/routines.query.ts | 0 .../{ => create/editor}/routine-editor.test.tsx | 2 +- .../{ => create/editor}/routine-editor.tsx | 14 +++++++------- .../{ => create/editor}/routine-schedule-field.tsx | 4 ++-- .../exercise-configuration.test.tsx | 0 .../exercise-configuration.tsx | 14 +++++++------- .../exercise-picker}/exercise-picker.test.tsx | 0 .../exercise-picker}/exercise-picker.tsx | 12 ++++++------ .../routines/{ => create}/routine-draft-form.tsx | 0 .../{ => landing}/routines-landing.test.tsx | 4 ++-- .../routines/{ => landing}/routines-landing.tsx | 8 ++++---- apps/web/src/routes/_app._routine-create/route.tsx | 2 +- .../routes/_app._routine-create/routines_.new.tsx | 2 +- .../routines_.new_.exercises.tsx | 2 +- .../routines_.new_.exercises_.$exerciseId.tsx | 2 +- apps/web/src/routes/_app.routines.tsx | 2 +- 18 files changed, 34 insertions(+), 34 deletions(-) rename apps/web/src/features/routines/{ => api}/routines.api.test.ts (100%) rename apps/web/src/features/routines/{ => api}/routines.api.ts (100%) rename apps/web/src/features/routines/{ => api}/routines.query.ts (100%) rename apps/web/src/features/routines/{ => create/editor}/routine-editor.test.tsx (98%) rename apps/web/src/features/routines/{ => create/editor}/routine-editor.tsx (87%) rename apps/web/src/features/routines/{ => create/editor}/routine-schedule-field.tsx (85%) rename apps/web/src/features/routines/{ => create/exercise-configuration}/exercise-configuration.test.tsx (100%) rename apps/web/src/features/routines/{ => create/exercise-configuration}/exercise-configuration.tsx (93%) rename apps/web/src/features/routines/{ => create/exercise-picker}/exercise-picker.test.tsx (100%) rename apps/web/src/features/routines/{ => create/exercise-picker}/exercise-picker.tsx (91%) rename apps/web/src/features/routines/{ => create}/routine-draft-form.tsx (100%) rename apps/web/src/features/routines/{ => landing}/routines-landing.test.tsx (96%) rename apps/web/src/features/routines/{ => landing}/routines-landing.tsx (94%) diff --git a/apps/web/src/features/routines/routines.api.test.ts b/apps/web/src/features/routines/api/routines.api.test.ts similarity index 100% rename from apps/web/src/features/routines/routines.api.test.ts rename to apps/web/src/features/routines/api/routines.api.test.ts diff --git a/apps/web/src/features/routines/routines.api.ts b/apps/web/src/features/routines/api/routines.api.ts similarity index 100% rename from apps/web/src/features/routines/routines.api.ts rename to apps/web/src/features/routines/api/routines.api.ts diff --git a/apps/web/src/features/routines/routines.query.ts b/apps/web/src/features/routines/api/routines.query.ts similarity index 100% rename from apps/web/src/features/routines/routines.query.ts rename to apps/web/src/features/routines/api/routines.query.ts diff --git a/apps/web/src/features/routines/routine-editor.test.tsx b/apps/web/src/features/routines/create/editor/routine-editor.test.tsx similarity index 98% rename from apps/web/src/features/routines/routine-editor.test.tsx rename to apps/web/src/features/routines/create/editor/routine-editor.test.tsx index 9e760cd..edb455b 100644 --- a/apps/web/src/features/routines/routine-editor.test.tsx +++ b/apps/web/src/features/routines/create/editor/routine-editor.test.tsx @@ -13,7 +13,7 @@ import { cleanup, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, describe, expect, it } from 'vitest'; -import { RoutineDraftFormProvider } from './routine-draft-form'; +import { RoutineDraftFormProvider } from '../routine-draft-form'; import { RoutineEditor } from './routine-editor'; afterEach(() => { diff --git a/apps/web/src/features/routines/routine-editor.tsx b/apps/web/src/features/routines/create/editor/routine-editor.tsx similarity index 87% rename from apps/web/src/features/routines/routine-editor.tsx rename to apps/web/src/features/routines/create/editor/routine-editor.tsx index 79fadeb..7140059 100644 --- a/apps/web/src/features/routines/routine-editor.tsx +++ b/apps/web/src/features/routines/create/editor/routine-editor.tsx @@ -3,13 +3,13 @@ import { Link, useNavigate } from '@tanstack/react-router'; import { ArrowLeft, Plus } from 'lucide-react'; import { useFormContext } from 'react-hook-form'; -import { AddActionButton } from '../../components/ui/add-action-button'; -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 { cn } from '../../lib/utils'; -import type { RoutineDraftFormValues } from './routine-draft-form'; +import { AddActionButton } from '@/components/ui/add-action-button'; +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 { cn } from '@/lib/utils'; +import type { RoutineDraftFormValues } from '../routine-draft-form'; import { RoutineScheduleField } from './routine-schedule-field'; export function RoutineEditor() { diff --git a/apps/web/src/features/routines/routine-schedule-field.tsx b/apps/web/src/features/routines/create/editor/routine-schedule-field.tsx similarity index 85% rename from apps/web/src/features/routines/routine-schedule-field.tsx rename to apps/web/src/features/routines/create/editor/routine-schedule-field.tsx index 3054ee6..3546bb9 100644 --- a/apps/web/src/features/routines/routine-schedule-field.tsx +++ b/apps/web/src/features/routines/create/editor/routine-schedule-field.tsx @@ -1,8 +1,8 @@ /** Displays routine schedule selections and the entry point for adding one. */ import { Plus } from 'lucide-react'; -import { Button } from '../../components/ui/button'; -import { Label } from '../../components/ui/label'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; export function RoutineScheduleField() { return ( diff --git a/apps/web/src/features/routines/exercise-configuration.test.tsx b/apps/web/src/features/routines/create/exercise-configuration/exercise-configuration.test.tsx similarity index 100% rename from apps/web/src/features/routines/exercise-configuration.test.tsx rename to apps/web/src/features/routines/create/exercise-configuration/exercise-configuration.test.tsx diff --git a/apps/web/src/features/routines/exercise-configuration.tsx b/apps/web/src/features/routines/create/exercise-configuration/exercise-configuration.tsx similarity index 93% rename from apps/web/src/features/routines/exercise-configuration.tsx rename to apps/web/src/features/routines/create/exercise-configuration/exercise-configuration.tsx index 783825e..7bae624 100644 --- a/apps/web/src/features/routines/exercise-configuration.tsx +++ b/apps/web/src/features/routines/create/exercise-configuration/exercise-configuration.tsx @@ -5,13 +5,13 @@ 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'; +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 '@/features/exercises/exercises.query'; type ExerciseConfigurationProps = { exerciseId: string; diff --git a/apps/web/src/features/routines/exercise-picker.test.tsx b/apps/web/src/features/routines/create/exercise-picker/exercise-picker.test.tsx similarity index 100% rename from apps/web/src/features/routines/exercise-picker.test.tsx rename to apps/web/src/features/routines/create/exercise-picker/exercise-picker.test.tsx diff --git a/apps/web/src/features/routines/exercise-picker.tsx b/apps/web/src/features/routines/create/exercise-picker/exercise-picker.tsx similarity index 91% rename from apps/web/src/features/routines/exercise-picker.tsx rename to apps/web/src/features/routines/create/exercise-picker/exercise-picker.tsx index 15f0a4a..63e6112 100644 --- a/apps/web/src/features/routines/exercise-picker.tsx +++ b/apps/web/src/features/routines/create/exercise-picker/exercise-picker.tsx @@ -4,12 +4,12 @@ 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'; +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 '@/features/exercises/exercises.query'; export function ExercisePicker() { const [searchTerm, setSearchTerm] = useState(''); diff --git a/apps/web/src/features/routines/routine-draft-form.tsx b/apps/web/src/features/routines/create/routine-draft-form.tsx similarity index 100% rename from apps/web/src/features/routines/routine-draft-form.tsx rename to apps/web/src/features/routines/create/routine-draft-form.tsx diff --git a/apps/web/src/features/routines/routines-landing.test.tsx b/apps/web/src/features/routines/landing/routines-landing.test.tsx similarity index 96% rename from apps/web/src/features/routines/routines-landing.test.tsx rename to apps/web/src/features/routines/landing/routines-landing.test.tsx index 772326a..11680ae 100644 --- a/apps/web/src/features/routines/routines-landing.test.tsx +++ b/apps/web/src/features/routines/landing/routines-landing.test.tsx @@ -16,8 +16,8 @@ import { import type { PropsWithChildren } from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { ApplicationIdentityContext } from '../../app/application-identity'; -import { createPublicUser } from '../../test/fixtures/public-user.fixture'; +import { ApplicationIdentityContext } from '@/app/application-identity'; +import { createPublicUser } from '@/test/fixtures/public-user.fixture'; import { RoutinesLanding } from './routines-landing'; afterEach(() => { diff --git a/apps/web/src/features/routines/routines-landing.tsx b/apps/web/src/features/routines/landing/routines-landing.tsx similarity index 94% rename from apps/web/src/features/routines/routines-landing.tsx rename to apps/web/src/features/routines/landing/routines-landing.tsx index cd7ea93..b2f4c88 100644 --- a/apps/web/src/features/routines/routines-landing.tsx +++ b/apps/web/src/features/routines/landing/routines-landing.tsx @@ -4,10 +4,10 @@ import { Link } from '@tanstack/react-router'; import { CalendarDays, Dumbbell, Plus } from 'lucide-react'; import type { ReactNode } from 'react'; -import { useApplicationIdentity } from '../../app/application-identity'; -import { AddActionButton } from '../../components/ui/add-action-button'; -import { PageTitle } from '../../components/ui/page-title'; -import { useRoutines } from './routines.query'; +import { useApplicationIdentity } from '@/app/application-identity'; +import { AddActionButton } from '@/components/ui/add-action-button'; +import { PageTitle } from '@/components/ui/page-title'; +import { useRoutines } from '../api/routines.query'; const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; diff --git a/apps/web/src/routes/_app._routine-create/route.tsx b/apps/web/src/routes/_app._routine-create/route.tsx index fc8d670..fb57d35 100644 --- a/apps/web/src/routes/_app._routine-create/route.tsx +++ b/apps/web/src/routes/_app._routine-create/route.tsx @@ -1,7 +1,7 @@ /** Keeps routine draft form state mounted across the complete creation flow. */ import { createFileRoute, Outlet } from '@tanstack/react-router'; -import { RoutineDraftFormProvider } from '../../features/routines/routine-draft-form'; +import { RoutineDraftFormProvider } from '../../features/routines/create/routine-draft-form'; export const Route = createFileRoute('/_app/_routine-create')({ component: RoutineCreationLayout, diff --git a/apps/web/src/routes/_app._routine-create/routines_.new.tsx b/apps/web/src/routes/_app._routine-create/routines_.new.tsx index fb7e492..0361e8f 100644 --- a/apps/web/src/routes/_app._routine-create/routines_.new.tsx +++ b/apps/web/src/routes/_app._routine-create/routines_.new.tsx @@ -1,7 +1,7 @@ // Maps the routine-creation URL into the persistent routine form layout. import { createFileRoute } from '@tanstack/react-router'; -import { RoutineEditor } from '../../features/routines/routine-editor'; +import { RoutineEditor } from '../../features/routines/create/editor/routine-editor'; export const Route = createFileRoute('/_app/_routine-create/routines_/new')({ component: RoutineEditor, diff --git a/apps/web/src/routes/_app._routine-create/routines_.new_.exercises.tsx b/apps/web/src/routes/_app._routine-create/routines_.new_.exercises.tsx index 1cf856d..e29b67e 100644 --- a/apps/web/src/routes/_app._routine-create/routines_.new_.exercises.tsx +++ b/apps/web/src/routes/_app._routine-create/routines_.new_.exercises.tsx @@ -1,7 +1,7 @@ /** Registers the protected exercise-picker route inside routine creation. */ import { createFileRoute } from '@tanstack/react-router'; -import { ExercisePicker } from '../../features/routines/exercise-picker'; +import { ExercisePicker } from '../../features/routines/create/exercise-picker/exercise-picker'; export const Route = createFileRoute( '/_app/_routine-create/routines_/new_/exercises', diff --git a/apps/web/src/routes/_app._routine-create/routines_.new_.exercises_.$exerciseId.tsx b/apps/web/src/routes/_app._routine-create/routines_.new_.exercises_.$exerciseId.tsx index e4e0f4c..16eee46 100644 --- a/apps/web/src/routes/_app._routine-create/routines_.new_.exercises_.$exerciseId.tsx +++ b/apps/web/src/routes/_app._routine-create/routines_.new_.exercises_.$exerciseId.tsx @@ -1,7 +1,7 @@ /** Registers exercise configuration inside the persistent routine form flow. */ import { createFileRoute } from '@tanstack/react-router'; -import { ExerciseConfiguration } from '../../features/routines/exercise-configuration'; +import { ExerciseConfiguration } from '../../features/routines/create/exercise-configuration/exercise-configuration'; export const Route = createFileRoute( '/_app/_routine-create/routines_/new_/exercises_/$exerciseId', diff --git a/apps/web/src/routes/_app.routines.tsx b/apps/web/src/routes/_app.routines.tsx index 5be1bad..4a1aaff 100644 --- a/apps/web/src/routes/_app.routines.tsx +++ b/apps/web/src/routes/_app.routines.tsx @@ -1,7 +1,7 @@ // Maps the routines URL into the protected application layout. import { createFileRoute } from '@tanstack/react-router'; -import { RoutinesLanding } from '../features/routines/routines-landing'; +import { RoutinesLanding } from '../features/routines/landing/routines-landing'; export const Route = createFileRoute('/_app/routines')({ component: RoutinesLanding, From 5a9bd03cbf6ddb866f02d1d254820991457b6bea Mon Sep 17 00:00:00 2001 From: rd-code-23 Date: Sun, 30 Aug 2026 11:45:23 -0700 Subject: [PATCH 2/7] feat(web): preserve routine draft across app navigation --- .../create/editor/routine-editor.test.tsx | 58 +++++++++++++++---- .../routines/create/editor/routine-editor.tsx | 9 +-- .../src/routes/_app._routine-create/route.tsx | 10 +--- apps/web/src/routes/_app.tsx | 17 +++--- 4 files changed, 65 insertions(+), 29 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 edb455b..bea9163 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 @@ -35,6 +35,9 @@ describe('routine editor', () => { screen.getByRole('heading', { name: 'Exercises (0)' }), ).toBeVisible(); expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled(); + expect( + screen.queryByRole('button', { name: 'Preview' }), + ).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Add Exercise' })).toBeVisible(); expect(screen.getByRole('button', { name: 'Add schedule' })).toBeDisabled(); expect( @@ -68,6 +71,21 @@ describe('routine editor', () => { expect(routineNameInput).not.toHaveAttribute('aria-invalid'); }); + it('clears the draft and its validation state when requested', async () => { + const user = userEvent.setup(); + renderEditor(); + const routineNameInput = await screen.findByLabelText('Routine Name'); + + await user.type(routineNameInput, 'x'.repeat(ROUTINE_NAME_MAX_LENGTH + 1)); + expect(await screen.findByRole('alert')).toBeVisible(); + + await user.click(screen.getByRole('button', { name: 'Clear' })); + + expect(routineNameInput).toHaveValue(''); + expect(routineNameInput).not.toHaveAttribute('aria-invalid'); + expect(screen.queryByRole('alert')).toBeNull(); + }); + it('keeps the current draft and opens exercises when the name is invalid', async () => { const user = userEvent.setup(); const router = renderEditor(); @@ -89,29 +107,49 @@ describe('routine editor', () => { expect(await screen.findByRole('alert')).toBeVisible(); expect(screen.getByLabelText('Routine Name')).toHaveValue(invalidName); }); + + it('keeps the draft while visiting a different application page', async () => { + const user = userEvent.setup(); + const router = renderEditor(); + const invalidName = 'x'.repeat(ROUTINE_NAME_MAX_LENGTH + 1); + + await user.type(await screen.findByLabelText('Routine Name'), invalidName); + expect(await screen.findByRole('alert')).toBeVisible(); + + await router.navigate({ to: '/history' }); + expect( + await screen.findByRole('heading', { name: 'History' }), + ).toBeVisible(); + + await router.navigate({ to: '/routines/new' }); + expect(await screen.findByRole('alert')).toBeVisible(); + expect(screen.getByLabelText('Routine Name')).toHaveValue(invalidName); + }); }); function renderEditor() { - const rootRoute = createRootRoute(); - const routineCreationRoute = createRoute({ - getParentRoute: () => rootRoute, - id: 'routine-creation', - component: TestRoutineCreationLayout, - }); + const rootRoute = createRootRoute({ component: TestApplicationLayout }); const editorRoute = createRoute({ - getParentRoute: () => routineCreationRoute, + getParentRoute: () => rootRoute, path: '/routines/new', component: RoutineEditor, }); const exercisePickerRoute = createRoute({ - getParentRoute: () => routineCreationRoute, + getParentRoute: () => rootRoute, path: '/routines/new/exercises', component: () => Back to editor, }); + const historyRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/history', + component: () =>

History

, + }); const testRouter = createRouter({ history: createMemoryHistory({ initialEntries: ['/routines/new'] }), routeTree: rootRoute.addChildren([ - routineCreationRoute.addChildren([editorRoute, exercisePickerRoute]), + editorRoute, + exercisePickerRoute, + historyRoute, ]), }); @@ -120,7 +158,7 @@ function renderEditor() { return testRouter; } -function TestRoutineCreationLayout() { +function TestApplicationLayout() { return ( 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 7140059..7fe17ff 100644 --- a/apps/web/src/features/routines/create/editor/routine-editor.tsx +++ b/apps/web/src/features/routines/create/editor/routine-editor.tsx @@ -14,6 +14,7 @@ import { RoutineScheduleField } from './routine-schedule-field'; export function RoutineEditor() { const navigate = useNavigate(); + const { reset } = useFormContext(); const addExercise = async () => { await navigate({ to: '/routines/new/exercises' }); @@ -21,7 +22,7 @@ export function RoutineEditor() { return (
- + reset()} />
@@ -35,7 +36,7 @@ export function RoutineEditor() { ); } -function EditorHeader() { +function EditorHeader({ onClear }: { onClear: () => void }) { return (
Create Routine
-
diff --git a/apps/web/src/routes/_app._routine-create/route.tsx b/apps/web/src/routes/_app._routine-create/route.tsx index fb57d35..7bc496b 100644 --- a/apps/web/src/routes/_app._routine-create/route.tsx +++ b/apps/web/src/routes/_app._routine-create/route.tsx @@ -1,16 +1,10 @@ -/** Keeps routine draft form state mounted across the complete creation flow. */ +/** Groups the routine-creation pages without adding a public URL segment. */ import { createFileRoute, Outlet } from '@tanstack/react-router'; -import { RoutineDraftFormProvider } from '../../features/routines/create/routine-draft-form'; - export const Route = createFileRoute('/_app/_routine-create')({ component: RoutineCreationLayout, }); function RoutineCreationLayout() { - return ( - - - - ); + return ; } diff --git a/apps/web/src/routes/_app.tsx b/apps/web/src/routes/_app.tsx index 036050d..e040987 100644 --- a/apps/web/src/routes/_app.tsx +++ b/apps/web/src/routes/_app.tsx @@ -6,6 +6,7 @@ import { ApplicationIdentityContext } from '../app/application-identity'; import { ApplicationShell } from '../app/application-shell'; import { clearGuestSession } from '../features/auth/guest-session'; import { useLogoutMutation } from '../features/auth/logout.mutation'; +import { RoutineDraftFormProvider } from '../features/routines/create/routine-draft-form'; export const Route = createFileRoute('/_app')({ component: ProtectedApplicationLayout, @@ -28,13 +29,15 @@ function ProtectedApplicationLayout() { {(identity) => ( - - - + + + + + )} From 2658fa62c2b383596199169bf845ae6b088b08cf Mon Sep 17 00:00:00 2001 From: rd-code-23 Date: Sun, 30 Aug 2026 12:19:16 -0700 Subject: [PATCH 3/7] feat(web): add routine schedule modal UI --- apps/web/src/components/ui/dialog.tsx | 75 +++++++++++ apps/web/src/components/ui/switch.tsx | 29 +++++ .../create/editor/routine-editor.test.tsx | 2 +- .../editor/routine-schedule-field.test.tsx | 25 ++++ .../create/editor/routine-schedule-field.tsx | 117 +++++++++++++++++- 5 files changed, 242 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/components/ui/dialog.tsx create mode 100644 apps/web/src/components/ui/switch.tsx create mode 100644 apps/web/src/features/routines/create/editor/routine-schedule-field.test.tsx diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx new file mode 100644 index 0000000..1f6f415 --- /dev/null +++ b/apps/web/src/components/ui/dialog.tsx @@ -0,0 +1,75 @@ +import { X } from 'lucide-react'; +import type { ComponentProps } from 'react'; +import { Dialog as DialogPrimitive } from 'radix-ui'; + +import { cn } from '@/lib/utils'; + +const Dialog = DialogPrimitive.Root; +const DialogTrigger = DialogPrimitive.Trigger; +const DialogClose = DialogPrimitive.Close; + +function DialogContent({ + children, + className, + ...props +}: ComponentProps) { + return ( + + + + {children} + + + + + ); +} + +function DialogTitle({ + className, + ...props +}: ComponentProps) { + return ( + + ); +} + +function DialogDescription({ + className, + ...props +}: ComponentProps) { + return ( + + ); +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogTitle, + DialogTrigger, +}; diff --git a/apps/web/src/components/ui/switch.tsx b/apps/web/src/components/ui/switch.tsx new file mode 100644 index 0000000..ce8a481 --- /dev/null +++ b/apps/web/src/components/ui/switch.tsx @@ -0,0 +1,29 @@ +import type { ComponentProps } from 'react'; +import { Switch as SwitchPrimitive } from 'radix-ui'; + +import { cn } from '@/lib/utils'; + +export function Switch({ + className, + ...props +}: ComponentProps) { + return ( + + + + ); +} 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 bea9163..969f261 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 @@ -39,7 +39,7 @@ describe('routine editor', () => { screen.queryByRole('button', { name: 'Preview' }), ).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Add Exercise' })).toBeVisible(); - expect(screen.getByRole('button', { name: 'Add schedule' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Add schedule' })).toBeEnabled(); expect( screen.getByRole('link', { name: 'Back to routines' }), ).toHaveAttribute('href', '/routines'); diff --git a/apps/web/src/features/routines/create/editor/routine-schedule-field.test.tsx b/apps/web/src/features/routines/create/editor/routine-schedule-field.test.tsx new file mode 100644 index 0000000..f30e08d --- /dev/null +++ b/apps/web/src/features/routines/create/editor/routine-schedule-field.test.tsx @@ -0,0 +1,25 @@ +import { cleanup, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, expect, it } from 'vitest'; + +import { RoutineScheduleField } from './routine-schedule-field'; + +afterEach(cleanup); + +it('opens a weekly schedule dialog and reveals time controls for selected days', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Add schedule' })); + + expect( + screen.getByRole('heading', { name: 'Schedule Routine' }), + ).toBeVisible(); + expect(screen.getAllByRole('switch')).toHaveLength(7); + expect(screen.queryByLabelText('Monday time')).toBeNull(); + + await user.click(screen.getByRole('switch', { name: 'Schedule Monday' })); + + expect(screen.getByLabelText('Monday time')).toHaveValue('18:00'); + expect(screen.getByRole('button', { name: 'Save Schedule' })).toBeDisabled(); +}); diff --git a/apps/web/src/features/routines/create/editor/routine-schedule-field.tsx b/apps/web/src/features/routines/create/editor/routine-schedule-field.tsx index 3546bb9..0a85c60 100644 --- a/apps/web/src/features/routines/create/editor/routine-schedule-field.tsx +++ b/apps/web/src/features/routines/create/editor/routine-schedule-field.tsx @@ -1,8 +1,29 @@ -/** Displays routine schedule selections and the entry point for adding one. */ +/** Displays routine schedules and opens the responsive weekly schedule editor. */ import { Plus } from 'lucide-react'; +import { useState } from 'react'; import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { cn } from '@/lib/utils'; + +const WEEKDAYS = [ + { label: 'Sunday', shortLabel: 'Sun' }, + { label: 'Monday', shortLabel: 'Mon' }, + { label: 'Tuesday', shortLabel: 'Tue' }, + { label: 'Wednesday', shortLabel: 'Wed' }, + { label: 'Thursday', shortLabel: 'Thu' }, + { label: 'Friday', shortLabel: 'Fri' }, + { label: 'Saturday', shortLabel: 'Sat' }, +] as const; export function RoutineScheduleField() { return ( @@ -10,17 +31,103 @@ export function RoutineScheduleField() {
+ +
+
+ ); +} + +function ScheduleDialog() { + const [selectedDays, setSelectedDays] = useState>(new Set()); + + function setDaySelected(day: string, isSelected: boolean) { + setSelectedDays((currentDays) => { + const nextDays = new Set(currentDays); + + if (isSelected) { + nextDays.add(day); + } else { + nextDays.delete(day); + } + + return nextDays; + }); + } + + return ( + + - - + + + + Schedule Routine + + Choose the days and time for this routine. + + +
+ {WEEKDAYS.map((day) => { + const isSelected = selectedDays.has(day.label); + + return ( +
+ + setDaySelected(day.label, checked) + } + /> + {day.shortLabel} + + {isSelected ? ( + + ) : ( + + — + + )} +
+ ); + })} +
+ +
+ + + + +
+
+
); } From 0612e3bc9e5ce8987a203658a914f9099c881e81 Mon Sep 17 00:00:00 2001 From: rd-code-23 Date: Sun, 30 Aug 2026 12:50:02 -0700 Subject: [PATCH 4/7] feat(web): synchronize routine schedule times --- .../routine-schedule-editor.reducer.test.ts | 76 ++++++++++++++ .../editor/routine-schedule-editor.reducer.ts | 98 +++++++++++++++++++ .../editor/routine-schedule-field.test.tsx | 32 +++++- .../create/editor/routine-schedule-field.tsx | 78 +++++++++------ 4 files changed, 253 insertions(+), 31 deletions(-) create mode 100644 apps/web/src/features/routines/create/editor/routine-schedule-editor.reducer.test.ts create mode 100644 apps/web/src/features/routines/create/editor/routine-schedule-editor.reducer.ts diff --git a/apps/web/src/features/routines/create/editor/routine-schedule-editor.reducer.test.ts b/apps/web/src/features/routines/create/editor/routine-schedule-editor.reducer.test.ts new file mode 100644 index 0000000..1dc49e5 --- /dev/null +++ b/apps/web/src/features/routines/create/editor/routine-schedule-editor.reducer.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; + +import { + INITIAL_SCHEDULE_EDITOR_STATE, + scheduleEditorReducer, +} from './routine-schedule-editor.reducer'; + +describe('schedule editor reducer', () => { + it('selects a day with the current shared time', () => { + const mondaySelected = scheduleEditorReducer( + INITIAL_SCHEDULE_EDITOR_STATE, + { type: 'day-toggled', dayOfWeek: 1, isSelected: true }, + ); + const mondayTimeChanged = scheduleEditorReducer(mondaySelected, { + type: 'time-changed', + dayOfWeek: 1, + time: '07:30', + }); + const wednesdaySelected = scheduleEditorReducer(mondayTimeChanged, { + type: 'day-toggled', + dayOfWeek: 3, + isSelected: true, + }); + + expect(wednesdaySelected.days[3]).toMatchObject({ + isSelected: true, + time: '07:30', + }); + }); + + it('updates selected days together while shared time is enabled', () => { + let state = scheduleEditorReducer(INITIAL_SCHEDULE_EDITOR_STATE, { + type: 'day-toggled', + dayOfWeek: 1, + isSelected: true, + }); + state = scheduleEditorReducer(state, { + type: 'day-toggled', + dayOfWeek: 3, + isSelected: true, + }); + state = scheduleEditorReducer(state, { + type: 'time-changed', + dayOfWeek: 1, + time: '08:15', + }); + + expect(state.days[1]?.time).toBe('08:15'); + expect(state.days[3]?.time).toBe('08:15'); + }); + + it('updates only one day after shared time is disabled', () => { + let state = scheduleEditorReducer(INITIAL_SCHEDULE_EDITOR_STATE, { + type: 'day-toggled', + dayOfWeek: 1, + isSelected: true, + }); + state = scheduleEditorReducer(state, { + type: 'day-toggled', + dayOfWeek: 3, + isSelected: true, + }); + state = scheduleEditorReducer(state, { + type: 'shared-time-toggled', + isEnabled: false, + }); + state = scheduleEditorReducer(state, { + type: 'time-changed', + dayOfWeek: 1, + time: '09:00', + }); + + expect(state.days[1]?.time).toBe('09:00'); + expect(state.days[3]?.time).toBe('18:00'); + }); +}); diff --git a/apps/web/src/features/routines/create/editor/routine-schedule-editor.reducer.ts b/apps/web/src/features/routines/create/editor/routine-schedule-editor.reducer.ts new file mode 100644 index 0000000..f8d2c02 --- /dev/null +++ b/apps/web/src/features/routines/create/editor/routine-schedule-editor.reducer.ts @@ -0,0 +1,98 @@ +export const DEFAULT_SCHEDULE_TIME = '18:00'; + +export type ScheduleEditorDay = { + dayOfWeek: number; + isSelected: boolean; + time: string; +}; + +export type ScheduleEditorState = { + days: ScheduleEditorDay[]; + useSameTime: boolean; +}; + +export type ScheduleEditorAction = + | { type: 'day-toggled'; dayOfWeek: number; isSelected: boolean } + | { type: 'time-changed'; dayOfWeek: number; time: string } + | { type: 'shared-time-toggled'; isEnabled: boolean }; + +export const INITIAL_SCHEDULE_EDITOR_STATE: ScheduleEditorState = { + days: Array.from({ length: 7 }, (_, dayOfWeek) => ({ + dayOfWeek, + isSelected: false, + time: DEFAULT_SCHEDULE_TIME, + })), + useSameTime: true, +}; + +export function scheduleEditorReducer( + state: ScheduleEditorState, + action: ScheduleEditorAction, +): ScheduleEditorState { + switch (action.type) { + case 'day-toggled': + return { + ...state, + days: toggleDay(state.days, action, state.useSameTime), + }; + + case 'time-changed': + return { + ...state, + days: changeDayTime(state.days, action, state.useSameTime), + }; + + case 'shared-time-toggled': + return { + ...state, + useSameTime: action.isEnabled, + days: action.isEnabled + ? synchronizeSelectedDayTimes(state.days) + : state.days, + }; + } +} + +function toggleDay( + days: ScheduleEditorDay[], + action: Extract, + useSameTime: boolean, +) { + const sharedTime = getFirstSelectedTime(days); + + return days.map((day) => + day.dayOfWeek === action.dayOfWeek + ? { + ...day, + isSelected: action.isSelected, + time: action.isSelected && useSameTime ? sharedTime : day.time, + } + : day, + ); +} + +function changeDayTime( + days: ScheduleEditorDay[], + action: Extract, + useSameTime: boolean, +) { + return days.map((day) => { + const shouldChange = useSameTime + ? day.isSelected + : day.dayOfWeek === action.dayOfWeek; + + return shouldChange ? { ...day, time: action.time } : day; + }); +} + +function synchronizeSelectedDayTimes(days: ScheduleEditorDay[]) { + const sharedTime = getFirstSelectedTime(days); + + return days.map((day) => + day.isSelected ? { ...day, time: sharedTime } : day, + ); +} + +function getFirstSelectedTime(days: ScheduleEditorDay[]) { + return days.find((day) => day.isSelected)?.time ?? DEFAULT_SCHEDULE_TIME; +} diff --git a/apps/web/src/features/routines/create/editor/routine-schedule-field.test.tsx b/apps/web/src/features/routines/create/editor/routine-schedule-field.test.tsx index f30e08d..caae173 100644 --- a/apps/web/src/features/routines/create/editor/routine-schedule-field.test.tsx +++ b/apps/web/src/features/routines/create/editor/routine-schedule-field.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, render, screen } from '@testing-library/react'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, expect, it } from 'vitest'; @@ -15,7 +15,7 @@ it('opens a weekly schedule dialog and reveals time controls for selected days', expect( screen.getByRole('heading', { name: 'Schedule Routine' }), ).toBeVisible(); - expect(screen.getAllByRole('switch')).toHaveLength(7); + expect(screen.getAllByRole('switch', { name: /^Schedule / })).toHaveLength(7); expect(screen.queryByLabelText('Monday time')).toBeNull(); await user.click(screen.getByRole('switch', { name: 'Schedule Monday' })); @@ -23,3 +23,31 @@ it('opens a weekly schedule dialog and reveals time controls for selected days', expect(screen.getByLabelText('Monday time')).toHaveValue('18:00'); expect(screen.getByRole('button', { name: 'Save Schedule' })).toBeDisabled(); }); + +it('keeps selected days synchronized until shared time is disabled', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Add schedule' })); + await user.click(screen.getByRole('switch', { name: 'Schedule Monday' })); + await user.click(screen.getByRole('switch', { name: 'Schedule Wednesday' })); + + fireEvent.change(screen.getByLabelText('Monday time'), { + target: { value: '07:30' }, + }); + + expect(screen.getByLabelText('Monday time')).toHaveValue('07:30'); + expect(screen.getByLabelText('Wednesday time')).toHaveValue('07:30'); + + await user.click( + screen.getByRole('switch', { + name: 'Use same time for selected days', + }), + ); + fireEvent.change(screen.getByLabelText('Monday time'), { + target: { value: '08:15' }, + }); + + expect(screen.getByLabelText('Monday time')).toHaveValue('08:15'); + expect(screen.getByLabelText('Wednesday time')).toHaveValue('07:30'); +}); diff --git a/apps/web/src/features/routines/create/editor/routine-schedule-field.tsx b/apps/web/src/features/routines/create/editor/routine-schedule-field.tsx index 0a85c60..62b56d9 100644 --- a/apps/web/src/features/routines/create/editor/routine-schedule-field.tsx +++ b/apps/web/src/features/routines/create/editor/routine-schedule-field.tsx @@ -1,6 +1,6 @@ /** Displays routine schedules and opens the responsive weekly schedule editor. */ import { Plus } from 'lucide-react'; -import { useState } from 'react'; +import { useReducer } from 'react'; import { Button } from '@/components/ui/button'; import { @@ -14,15 +14,19 @@ import { import { Label } from '@/components/ui/label'; import { Switch } from '@/components/ui/switch'; import { cn } from '@/lib/utils'; +import { + INITIAL_SCHEDULE_EDITOR_STATE, + scheduleEditorReducer, +} from './routine-schedule-editor.reducer'; const WEEKDAYS = [ - { label: 'Sunday', shortLabel: 'Sun' }, - { label: 'Monday', shortLabel: 'Mon' }, - { label: 'Tuesday', shortLabel: 'Tue' }, - { label: 'Wednesday', shortLabel: 'Wed' }, - { label: 'Thursday', shortLabel: 'Thu' }, - { label: 'Friday', shortLabel: 'Fri' }, - { label: 'Saturday', shortLabel: 'Sat' }, + { dayOfWeek: 0, label: 'Sunday', shortLabel: 'Sun' }, + { dayOfWeek: 1, label: 'Monday', shortLabel: 'Mon' }, + { dayOfWeek: 2, label: 'Tuesday', shortLabel: 'Tue' }, + { dayOfWeek: 3, label: 'Wednesday', shortLabel: 'Wed' }, + { dayOfWeek: 4, label: 'Thursday', shortLabel: 'Thu' }, + { dayOfWeek: 5, label: 'Friday', shortLabel: 'Fri' }, + { dayOfWeek: 6, label: 'Saturday', shortLabel: 'Sat' }, ] as const; export function RoutineScheduleField() { @@ -38,21 +42,10 @@ export function RoutineScheduleField() { } function ScheduleDialog() { - const [selectedDays, setSelectedDays] = useState>(new Set()); - - function setDaySelected(day: string, isSelected: boolean) { - setSelectedDays((currentDays) => { - const nextDays = new Set(currentDays); - - if (isSelected) { - nextDays.add(day); - } else { - nextDays.delete(day); - } - - return nextDays; - }); - } + const [state, dispatch] = useReducer( + scheduleEditorReducer, + INITIAL_SCHEDULE_EDITOR_STATE, + ); return ( @@ -76,27 +69,31 @@ function ScheduleDialog() {
{WEEKDAYS.map((day) => { - const isSelected = selectedDays.has(day.label); + const scheduleDay = state.days[day.dayOfWeek]!; return (
- setDaySelected(day.label, checked) + dispatch({ + type: 'day-toggled', + dayOfWeek: day.dayOfWeek, + isSelected: checked, + }) } /> {day.shortLabel} - {isSelected ? ( + {scheduleDay.isSelected ? ( + dispatch({ + type: 'time-changed', + dayOfWeek: day.dayOfWeek, + time: event.target.value, + }) + } type="time" + value={scheduleDay.time} /> ) : ( @@ -117,6 +121,22 @@ function ScheduleDialog() { })}
+
+ + + dispatch({ + type: 'shared-time-toggled', + isEnabled: checked, + }) + } + /> +
+
@@ -151,3 +203,12 @@ function ScheduleDialog() {
); } + +function formatScheduleTime(localTime: string) { + const [hourText = '0', minute = '00'] = localTime.split(':'); + const hour = Number(hourText); + const period = hour >= 12 ? 'PM' : 'AM'; + const displayHour = hour % 12 || 12; + + return `${displayHour}:${minute} ${period}`; +} 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 3caa108..8686f2b 100644 --- a/apps/web/src/features/routines/create/routine-draft-form.tsx +++ b/apps/web/src/features/routines/create/routine-draft-form.tsx @@ -1,12 +1,16 @@ /** Provides one routine draft form across every route in the creation flow. */ import { zodResolver } from '@hookform/resolvers/zod'; -import { routineNameSchema } from '@goforlift/contracts'; +import { + createRoutineScheduleInputSchema, + routineNameSchema, +} from '@goforlift/contracts'; import type { ReactNode } from 'react'; import { FormProvider, useForm } from 'react-hook-form'; import { z } from 'zod'; const routineDraftFormSchema = z.object({ name: routineNameSchema, + schedules: z.array(createRoutineScheduleInputSchema), }); export type RoutineDraftFormValues = z.infer; @@ -17,7 +21,7 @@ export function RoutineDraftFormProvider({ children: ReactNode; }) { const form = useForm({ - defaultValues: { name: '' }, + defaultValues: { name: '', schedules: [] }, mode: 'onChange', // when to run validation. resolver: zodResolver(routineDraftFormSchema), }); From 58e483b94807d8701cc664f462891be64a75adf7 Mon Sep 17 00:00:00 2001 From: rd-code-23 Date: Sun, 30 Aug 2026 13:34:53 -0700 Subject: [PATCH 6/7] fix(web): allow empty routine schedules --- .../editor/routine-schedule-field.test.tsx | 17 +++++++++++++++++ .../create/editor/routine-schedule-field.tsx | 7 +------ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/apps/web/src/features/routines/create/editor/routine-schedule-field.test.tsx b/apps/web/src/features/routines/create/editor/routine-schedule-field.test.tsx index 57a6848..48d8a44 100644 --- a/apps/web/src/features/routines/create/editor/routine-schedule-field.test.tsx +++ b/apps/web/src/features/routines/create/editor/routine-schedule-field.test.tsx @@ -18,6 +18,7 @@ it('opens a weekly schedule dialog and reveals time controls for selected days', ).toBeVisible(); expect(screen.getAllByRole('switch', { name: /^Schedule / })).toHaveLength(7); expect(screen.queryByLabelText('Monday time')).toBeNull(); + expect(screen.getByRole('button', { name: 'Save Schedule' })).toBeEnabled(); await user.click(screen.getByRole('switch', { name: 'Schedule Monday' })); @@ -86,6 +87,22 @@ it('discards unsaved schedule changes when cancelled', async () => { expect(screen.queryByLabelText('Tuesday time')).toBeNull(); }); +it('allows every schedule to be removed because scheduling is optional', async () => { + const user = userEvent.setup(); + renderScheduleField(); + + await user.click(screen.getByRole('button', { name: 'Add schedule' })); + await user.click(screen.getByRole('switch', { name: 'Schedule Monday' })); + await user.click(screen.getByRole('button', { name: 'Save Schedule' })); + expect(screen.getByText('Mon')).toBeVisible(); + + await user.click(screen.getByRole('button', { name: 'Add schedule' })); + await user.click(screen.getByRole('switch', { name: 'Schedule Monday' })); + await user.click(screen.getByRole('button', { name: 'Save Schedule' })); + + expect(screen.queryByText('Mon')).toBeNull(); +}); + function renderScheduleField() { render( diff --git a/apps/web/src/features/routines/create/editor/routine-schedule-field.tsx b/apps/web/src/features/routines/create/editor/routine-schedule-field.tsx index b4d0512..21cf322 100644 --- a/apps/web/src/features/routines/create/editor/routine-schedule-field.tsx +++ b/apps/web/src/features/routines/create/editor/routine-schedule-field.tsx @@ -70,7 +70,6 @@ function ScheduleDialog() { // `control` tells useWatch which RHF form instance to read schedules from. const { control, setValue } = useFormContext(); const savedSchedules = useWatch({ control, name: 'schedules' }); - const hasSelectedDays = state.days.some((day) => day.isSelected); function handleOpenChange(nextIsOpen: boolean) { if (nextIsOpen) { @@ -191,11 +190,7 @@ function ScheduleDialog() { Cancel - From 837396d9cb9e46fe534cf40f399316f523234876 Mon Sep 17 00:00:00 2001 From: rd-code-23 Date: Thu, 3 Sep 2026 19:43:15 -0700 Subject: [PATCH 7/7] fix(web): preserve exercise names in routine draft --- .../exercise-configuration.test.tsx | 8 ++++++-- .../routines/create/routine-draft-form.tsx | 17 +++++++---------- 2 files changed, 13 insertions(+), 12 deletions(-) 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 3010698..4f988aa 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 @@ -29,7 +29,7 @@ function renderConfiguration(exerciseId: string) { const rootRoute = createRootRoute({ component: TestLayout }); const configurationRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/configure', + path: '/routines/new/exercises/$exerciseId', component: () => , }); const editorRoute = createRoute({ @@ -43,7 +43,7 @@ function renderConfiguration(exerciseId: string) { component: ExercisePicker, }); const router = createRouter({ - history: createMemoryHistory({ initialEntries: ['/configure'] }), + history: createMemoryHistory({ initialEntries: ['/routines/new'] }), routeTree: rootRoute.addChildren([ configurationRoute, editorRoute, @@ -85,6 +85,10 @@ it('shows the selected exercise and initial configuration fields', async () => { renderConfiguration(exerciseId); + await user.type(await screen.findByLabelText('Routine Name'), 'Upper Body'); + await user.click(screen.getByRole('button', { name: 'Add Exercise' })); + await user.click(await screen.findByRole('link', { name: /Bicep Curl/ })); + expect(await screen.findByText('Bicep Curl')).toBeVisible(); expect(screen.getByLabelText('Sets')).toHaveValue(3); expect(screen.getByLabelText('Reps')).toHaveValue(8); 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 e342733..7ea85d9 100644 --- a/apps/web/src/features/routines/create/routine-draft-form.tsx +++ b/apps/web/src/features/routines/create/routine-draft-form.tsx @@ -11,16 +11,13 @@ 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; - }); +// reading the exercise catalog again. It must remain in both the input and +// parsed form values because RHF runs this schema while the user edits. The +// eventual save handler will map the draft to createRoutineInputSchema and +// remove this UI-only field at the API boundary. +const routineDraftExerciseSchema = createRoutineExerciseInputSchema.extend({ + name: exerciseSummarySchema.shape.name, +}); const routineDraftFormSchema = z.object({ name: routineNameSchema,