From 2afa650161eb487919b3a2e42119e387c47c36b3 Mon Sep 17 00:00:00 2001 From: rd-code-23 Date: Sun, 30 Aug 2026 09:34:15 -0700 Subject: [PATCH] feat(web): preserve routine form across creation routes --- AGENTS.md | 7 + .../features/routines/routine-draft-form.tsx | 26 ++++ .../features/routines/routine-editor.test.tsx | 92 +++++++++++-- .../src/features/routines/routine-editor.tsx | 54 ++++++-- .../routines/routines-landing.test.tsx | 1 + apps/web/src/routeTree.gen.ts | 124 +++++++++++------- .../src/routes/_app._routine-create/route.tsx | 16 +++ .../_app._routine-create/routines_.new.tsx | 8 ++ .../routines_.new_.exercises.tsx | 10 ++ ...routines_.new_.exercises_.$exerciseId.tsx} | 6 +- apps/web/src/routes/_app.routines_.new.tsx | 8 -- .../routes/_app.routines_.new_.exercises.tsx | 8 -- .../src/routines/routine.contract.test.ts | 19 +++ .../src/routines/routine.contract.ts | 11 +- 14 files changed, 302 insertions(+), 88 deletions(-) create mode 100644 apps/web/src/features/routines/routine-draft-form.tsx create mode 100644 apps/web/src/routes/_app._routine-create/route.tsx create mode 100644 apps/web/src/routes/_app._routine-create/routines_.new.tsx create mode 100644 apps/web/src/routes/_app._routine-create/routines_.new_.exercises.tsx rename apps/web/src/routes/{_app.routines_.new_.exercises_.$exerciseId.tsx => _app._routine-create/routines_.new_.exercises_.$exerciseId.tsx} (55%) delete mode 100644 apps/web/src/routes/_app.routines_.new.tsx delete mode 100644 apps/web/src/routes/_app.routines_.new_.exercises.tsx diff --git a/AGENTS.md b/AGENTS.md index d45b95e..2f8965d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -281,6 +281,13 @@ defining the appropriate Zod schema in: and infer TypeScript types from that schema. +Form validation that represents an API or domain constraint must reuse the +field schemas from `packages/contracts`. Do not recreate constraints such as +required values, trimming, minimums, maximums, or allowed values inside a +frontend feature. Frontend-only presentation behavior may wrap shared field +schemas in a form-specific object schema, but the underlying validation rules +must remain shared. + Example: export const CreateRoutineSchema = z.object({ diff --git a/apps/web/src/features/routines/routine-draft-form.tsx b/apps/web/src/features/routines/routine-draft-form.tsx new file mode 100644 index 0000000..3caa108 --- /dev/null +++ b/apps/web/src/features/routines/routine-draft-form.tsx @@ -0,0 +1,26 @@ +/** Provides one routine draft form across every route in the creation flow. */ +import { zodResolver } from '@hookform/resolvers/zod'; +import { 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, +}); + +export type RoutineDraftFormValues = z.infer; + +export function RoutineDraftFormProvider({ + children, +}: { + children: ReactNode; +}) { + const form = useForm({ + defaultValues: { name: '' }, + mode: 'onChange', // when to run validation. + resolver: zodResolver(routineDraftFormSchema), + }); + + return {children}; +} diff --git a/apps/web/src/features/routines/routine-editor.test.tsx b/apps/web/src/features/routines/routine-editor.test.tsx index de5c7a3..9e760cd 100644 --- a/apps/web/src/features/routines/routine-editor.test.tsx +++ b/apps/web/src/features/routines/routine-editor.test.tsx @@ -1,17 +1,24 @@ /** Verifies the routine editor shell exposes its initial responsive workflow. */ +import { ROUTINE_NAME_MAX_LENGTH } from '@goforlift/contracts'; import { createMemoryHistory, createRootRoute, createRoute, createRouter, + Link, + Outlet, RouterProvider, } from '@tanstack/react-router'; -import { cleanup, render, screen } from '@testing-library/react'; +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 { RoutineEditor } from './routine-editor'; -afterEach(cleanup); +afterEach(() => { + cleanup(); +}); describe('routine editor', () => { it('shows the initial editor shell without enabling unfinished actions', async () => { @@ -28,28 +35,95 @@ describe('routine editor', () => { screen.getByRole('heading', { name: 'Exercises (0)' }), ).toBeVisible(); expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled(); - expect(screen.getByRole('link', { name: 'Add Exercise' })).toHaveAttribute( - 'href', - '/routines/new/exercises', - ); + expect(screen.getByRole('button', { name: 'Add Exercise' })).toBeVisible(); expect(screen.getByRole('button', { name: 'Add schedule' })).toBeDisabled(); expect( screen.getByRole('link', { name: 'Back to routines' }), ).toHaveAttribute('href', '/routines'); }); + + it('shows live validation and clears the error after correction', async () => { + const user = userEvent.setup(); + renderEditor(); + + const routineNameInput = await screen.findByLabelText('Routine Name'); + + await user.type(routineNameInput, 'x'); + await user.clear(routineNameInput); + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Enter a routine name.', + ); + expect(routineNameInput).toHaveAttribute('aria-invalid', 'true'); + + await user.type(routineNameInput, 'x'.repeat(ROUTINE_NAME_MAX_LENGTH + 1)); + + expect(await screen.findByRole('alert')).toBeVisible(); + expect(routineNameInput).toHaveAttribute('aria-invalid', 'true'); + + await user.clear(routineNameInput); + await user.type(routineNameInput, 'Push'); + + await waitFor(() => expect(screen.queryByRole('alert')).toBeNull()); + expect(routineNameInput).not.toHaveAttribute('aria-invalid'); + }); + + it('keeps the current draft and opens exercises when the name is invalid', async () => { + const user = userEvent.setup(); + const router = renderEditor(); + const invalidName = 'x'.repeat(ROUTINE_NAME_MAX_LENGTH + 1); + const routineNameInput = await screen.findByLabelText('Routine Name'); + + await user.type(routineNameInput, invalidName); + + expect(await screen.findByRole('alert')).toBeVisible(); + + await user.click(screen.getByRole('button', { name: 'Add Exercise' })); + + await waitFor(() => + expect(router.state.location.pathname).toBe('/routines/new/exercises'), + ); + + await user.click(screen.getByRole('link', { name: 'Back to editor' })); + + expect(await screen.findByRole('alert')).toBeVisible(); + expect(screen.getByLabelText('Routine Name')).toHaveValue(invalidName); + }); }); function renderEditor() { const rootRoute = createRootRoute(); - const editorRoute = createRoute({ + const routineCreationRoute = createRoute({ getParentRoute: () => rootRoute, + id: 'routine-creation', + component: TestRoutineCreationLayout, + }); + const editorRoute = createRoute({ + getParentRoute: () => routineCreationRoute, path: '/routines/new', component: RoutineEditor, }); + const exercisePickerRoute = createRoute({ + getParentRoute: () => routineCreationRoute, + path: '/routines/new/exercises', + component: () => Back to editor, + }); const testRouter = createRouter({ history: createMemoryHistory({ initialEntries: ['/routines/new'] }), - routeTree: rootRoute.addChildren([editorRoute]), + routeTree: rootRoute.addChildren([ + routineCreationRoute.addChildren([editorRoute, exercisePickerRoute]), + ]), }); - return render(); + render(); + + return testRouter; +} + +function TestRoutineCreationLayout() { + return ( + + + + ); } diff --git a/apps/web/src/features/routines/routine-editor.tsx b/apps/web/src/features/routines/routine-editor.tsx index 20cd7d3..79fadeb 100644 --- a/apps/web/src/features/routines/routine-editor.tsx +++ b/apps/web/src/features/routines/routine-editor.tsx @@ -1,27 +1,36 @@ /** Presents the responsive visual shell for creating a registered-user routine. */ -import { Link } from '@tanstack/react-router'; +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 { RoutineScheduleField } from './routine-schedule-field'; export function RoutineEditor() { + const navigate = useNavigate(); + + const addExercise = async () => { + await navigate({ to: '/routines/new/exercises' }); + }; + return (
-
+
- + void addExercise()} />
-
+
); } @@ -46,31 +55,52 @@ function EditorHeader() { } function RoutineDetails() { + const { + formState: { errors }, + register, + } = useFormContext(); + + const errorMessage = errors.name?.message; + return (
+ {errorMessage && ( + + )}
); } -function ExerciseSection() { +function ExerciseSection({ onAddExercise }: { onAddExercise: () => void }) { return (
- - -
diff --git a/apps/web/src/features/routines/routines-landing.test.tsx b/apps/web/src/features/routines/routines-landing.test.tsx index 8574f69..772326a 100644 --- a/apps/web/src/features/routines/routines-landing.test.tsx +++ b/apps/web/src/features/routines/routines-landing.test.tsx @@ -74,6 +74,7 @@ describe('routines landing', () => { expect( await screen.findByRole('link', { name: 'Add Routine' }), ).toBeVisible(); + expect(screen.queryByText('No routines yet.')).not.toBeInTheDocument(); }); diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 64981bf..6572bdc 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -11,13 +11,14 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as IndexRouteImport } from './routes/index' import { Route as AppRouteImport } from './routes/_app' +import { Route as AppRoutineCreateRouteRouteImport } from './routes/_app._routine-create/route' import { Route as AppDashboardRouteImport } from './routes/_app.dashboard' 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' -import { Route as AppRoutinesNewExercisesExerciseIdRouteImport } from './routes/_app.routines_.new_.exercises_.$exerciseId' +import { Route as AppRoutineCreateRoutinesNewRouteImport } from './routes/_app._routine-create/routines_.new' +import { Route as AppRoutineCreateRoutinesNewExercisesRouteImport } from './routes/_app._routine-create/routines_.new_.exercises' +import { Route as AppRoutineCreateRoutinesNewExercisesExerciseIdRouteImport } from './routes/_app._routine-create/routines_.new_.exercises_.$exerciseId' const IndexRoute = IndexRouteImport.update({ id: '/', @@ -28,6 +29,10 @@ const AppRoute = AppRouteImport.update({ id: '/_app', getParentRoute: () => rootRouteImport, } as any) +const AppRoutineCreateRouteRoute = AppRoutineCreateRouteRouteImport.update({ + id: '/_routine-create', + getParentRoute: () => AppRoute, +} as any) const AppDashboardRoute = AppDashboardRouteImport.update({ id: '/dashboard', path: '/dashboard', @@ -48,21 +53,23 @@ const AppSettingsRoute = AppSettingsRouteImport.update({ path: '/settings', getParentRoute: () => AppRoute, } as any) -const AppRoutinesNewRoute = AppRoutinesNewRouteImport.update({ - id: '/routines_/new', - path: '/routines/new', - getParentRoute: () => AppRoute, -} as any) -const AppRoutinesNewExercisesRoute = AppRoutinesNewExercisesRouteImport.update({ - id: '/routines_/new_/exercises', - path: '/routines/new/exercises', - getParentRoute: () => AppRoute, -} as any) -const AppRoutinesNewExercisesExerciseIdRoute = - AppRoutinesNewExercisesExerciseIdRouteImport.update({ +const AppRoutineCreateRoutinesNewRoute = + AppRoutineCreateRoutinesNewRouteImport.update({ + id: '/routines_/new', + path: '/routines/new', + getParentRoute: () => AppRoutineCreateRouteRoute, + } as any) +const AppRoutineCreateRoutinesNewExercisesRoute = + AppRoutineCreateRoutinesNewExercisesRouteImport.update({ + id: '/routines_/new_/exercises', + path: '/routines/new/exercises', + getParentRoute: () => AppRoutineCreateRouteRoute, + } as any) +const AppRoutineCreateRoutinesNewExercisesExerciseIdRoute = + AppRoutineCreateRoutinesNewExercisesExerciseIdRouteImport.update({ id: '/routines_/new_/exercises_/$exerciseId', path: '/routines/new/exercises/$exerciseId', - getParentRoute: () => AppRoute, + getParentRoute: () => AppRoutineCreateRouteRoute, } as any) export interface FileRoutesByFullPath { @@ -71,9 +78,9 @@ export interface FileRoutesByFullPath { '/history': typeof AppHistoryRoute '/routines': typeof AppRoutinesRoute '/settings': typeof AppSettingsRoute - '/routines/new': typeof AppRoutinesNewRoute - '/routines/new/exercises': typeof AppRoutinesNewExercisesRoute - '/routines/new/exercises/$exerciseId': typeof AppRoutinesNewExercisesExerciseIdRoute + '/routines/new': typeof AppRoutineCreateRoutinesNewRoute + '/routines/new/exercises': typeof AppRoutineCreateRoutinesNewExercisesRoute + '/routines/new/exercises/$exerciseId': typeof AppRoutineCreateRoutinesNewExercisesExerciseIdRoute } export interface FileRoutesByTo { '/': typeof IndexRoute @@ -81,21 +88,22 @@ export interface FileRoutesByTo { '/history': typeof AppHistoryRoute '/routines': typeof AppRoutinesRoute '/settings': typeof AppSettingsRoute - '/routines/new': typeof AppRoutinesNewRoute - '/routines/new/exercises': typeof AppRoutinesNewExercisesRoute - '/routines/new/exercises/$exerciseId': typeof AppRoutinesNewExercisesExerciseIdRoute + '/routines/new': typeof AppRoutineCreateRoutinesNewRoute + '/routines/new/exercises': typeof AppRoutineCreateRoutinesNewExercisesRoute + '/routines/new/exercises/$exerciseId': typeof AppRoutineCreateRoutinesNewExercisesExerciseIdRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/_app': typeof AppRouteWithChildren + '/_app/_routine-create': typeof AppRoutineCreateRouteRouteWithChildren '/_app/dashboard': typeof AppDashboardRoute '/_app/history': typeof AppHistoryRoute '/_app/routines': typeof AppRoutinesRoute '/_app/settings': typeof AppSettingsRoute - '/_app/routines_/new': typeof AppRoutinesNewRoute - '/_app/routines_/new_/exercises': typeof AppRoutinesNewExercisesRoute - '/_app/routines_/new_/exercises_/$exerciseId': typeof AppRoutinesNewExercisesExerciseIdRoute + '/_app/_routine-create/routines_/new': typeof AppRoutineCreateRoutinesNewRoute + '/_app/_routine-create/routines_/new_/exercises': typeof AppRoutineCreateRoutinesNewExercisesRoute + '/_app/_routine-create/routines_/new_/exercises_/$exerciseId': typeof AppRoutineCreateRoutinesNewExercisesExerciseIdRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -122,13 +130,14 @@ export interface FileRouteTypes { | '__root__' | '/' | '/_app' + | '/_app/_routine-create' | '/_app/dashboard' | '/_app/history' | '/_app/routines' | '/_app/settings' - | '/_app/routines_/new' - | '/_app/routines_/new_/exercises' - | '/_app/routines_/new_/exercises_/$exerciseId' + | '/_app/_routine-create/routines_/new' + | '/_app/_routine-create/routines_/new_/exercises' + | '/_app/_routine-create/routines_/new_/exercises_/$exerciseId' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -152,6 +161,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppRouteImport parentRoute: typeof rootRouteImport } + '/_app/_routine-create': { + id: '/_app/_routine-create' + path: '' + fullPath: '/' + preLoaderRoute: typeof AppRoutineCreateRouteRouteImport + parentRoute: typeof AppRoute + } '/_app/dashboard': { id: '/_app/dashboard' path: '/dashboard' @@ -180,49 +196,63 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppSettingsRouteImport parentRoute: typeof AppRoute } - '/_app/routines_/new': { - id: '/_app/routines_/new' + '/_app/_routine-create/routines_/new': { + id: '/_app/_routine-create/routines_/new' path: '/routines/new' fullPath: '/routines/new' - preLoaderRoute: typeof AppRoutinesNewRouteImport - parentRoute: typeof AppRoute + preLoaderRoute: typeof AppRoutineCreateRoutinesNewRouteImport + parentRoute: typeof AppRoutineCreateRouteRoute } - '/_app/routines_/new_/exercises': { - id: '/_app/routines_/new_/exercises' + '/_app/_routine-create/routines_/new_/exercises': { + id: '/_app/_routine-create/routines_/new_/exercises' path: '/routines/new/exercises' fullPath: '/routines/new/exercises' - preLoaderRoute: typeof AppRoutinesNewExercisesRouteImport - parentRoute: typeof AppRoute + preLoaderRoute: typeof AppRoutineCreateRoutinesNewExercisesRouteImport + parentRoute: typeof AppRoutineCreateRouteRoute } - '/_app/routines_/new_/exercises_/$exerciseId': { - id: '/_app/routines_/new_/exercises_/$exerciseId' + '/_app/_routine-create/routines_/new_/exercises_/$exerciseId': { + id: '/_app/_routine-create/routines_/new_/exercises_/$exerciseId' path: '/routines/new/exercises/$exerciseId' fullPath: '/routines/new/exercises/$exerciseId' - preLoaderRoute: typeof AppRoutinesNewExercisesExerciseIdRouteImport - parentRoute: typeof AppRoute + preLoaderRoute: typeof AppRoutineCreateRoutinesNewExercisesExerciseIdRouteImport + parentRoute: typeof AppRoutineCreateRouteRoute } } } +interface AppRoutineCreateRouteRouteChildren { + AppRoutineCreateRoutinesNewRoute: typeof AppRoutineCreateRoutinesNewRoute + AppRoutineCreateRoutinesNewExercisesRoute: typeof AppRoutineCreateRoutinesNewExercisesRoute + AppRoutineCreateRoutinesNewExercisesExerciseIdRoute: typeof AppRoutineCreateRoutinesNewExercisesExerciseIdRoute +} + +const AppRoutineCreateRouteRouteChildren: AppRoutineCreateRouteRouteChildren = { + AppRoutineCreateRoutinesNewRoute: AppRoutineCreateRoutinesNewRoute, + AppRoutineCreateRoutinesNewExercisesRoute: + AppRoutineCreateRoutinesNewExercisesRoute, + AppRoutineCreateRoutinesNewExercisesExerciseIdRoute: + AppRoutineCreateRoutinesNewExercisesExerciseIdRoute, +} + +const AppRoutineCreateRouteRouteWithChildren = + AppRoutineCreateRouteRoute._addFileChildren( + AppRoutineCreateRouteRouteChildren, + ) + interface AppRouteChildren { + AppRoutineCreateRouteRoute: typeof AppRoutineCreateRouteRouteWithChildren AppDashboardRoute: typeof AppDashboardRoute AppHistoryRoute: typeof AppHistoryRoute AppRoutinesRoute: typeof AppRoutinesRoute AppSettingsRoute: typeof AppSettingsRoute - AppRoutinesNewRoute: typeof AppRoutinesNewRoute - AppRoutinesNewExercisesRoute: typeof AppRoutinesNewExercisesRoute - AppRoutinesNewExercisesExerciseIdRoute: typeof AppRoutinesNewExercisesExerciseIdRoute } const AppRouteChildren: AppRouteChildren = { + AppRoutineCreateRouteRoute: AppRoutineCreateRouteRouteWithChildren, AppDashboardRoute: AppDashboardRoute, AppHistoryRoute: AppHistoryRoute, AppRoutinesRoute: AppRoutinesRoute, AppSettingsRoute: AppSettingsRoute, - AppRoutinesNewRoute: AppRoutinesNewRoute, - AppRoutinesNewExercisesRoute: AppRoutinesNewExercisesRoute, - AppRoutinesNewExercisesExerciseIdRoute: - AppRoutinesNewExercisesExerciseIdRoute, } const AppRouteWithChildren = AppRoute._addFileChildren(AppRouteChildren) diff --git a/apps/web/src/routes/_app._routine-create/route.tsx b/apps/web/src/routes/_app._routine-create/route.tsx new file mode 100644 index 0000000..fc8d670 --- /dev/null +++ b/apps/web/src/routes/_app._routine-create/route.tsx @@ -0,0 +1,16 @@ +/** 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'; + +export const Route = createFileRoute('/_app/_routine-create')({ + component: RoutineCreationLayout, +}); + +function RoutineCreationLayout() { + return ( + + + + ); +} diff --git a/apps/web/src/routes/_app._routine-create/routines_.new.tsx b/apps/web/src/routes/_app._routine-create/routines_.new.tsx new file mode 100644 index 0000000..fb7e492 --- /dev/null +++ b/apps/web/src/routes/_app._routine-create/routines_.new.tsx @@ -0,0 +1,8 @@ +// Maps the routine-creation URL into the persistent routine form layout. +import { createFileRoute } from '@tanstack/react-router'; + +import { RoutineEditor } from '../../features/routines/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 new file mode 100644 index 0000000..1cf856d --- /dev/null +++ b/apps/web/src/routes/_app._routine-create/routines_.new_.exercises.tsx @@ -0,0 +1,10 @@ +/** Registers the protected exercise-picker route inside routine creation. */ +import { createFileRoute } from '@tanstack/react-router'; + +import { ExercisePicker } from '../../features/routines/exercise-picker'; + +export const Route = createFileRoute( + '/_app/_routine-create/routines_/new_/exercises', +)({ + component: ExercisePicker, +}); diff --git a/apps/web/src/routes/_app.routines_.new_.exercises_.$exerciseId.tsx b/apps/web/src/routes/_app._routine-create/routines_.new_.exercises_.$exerciseId.tsx similarity index 55% rename from apps/web/src/routes/_app.routines_.new_.exercises_.$exerciseId.tsx rename to apps/web/src/routes/_app._routine-create/routines_.new_.exercises_.$exerciseId.tsx index 1dcf65a..e4e0f4c 100644 --- a/apps/web/src/routes/_app.routines_.new_.exercises_.$exerciseId.tsx +++ b/apps/web/src/routes/_app._routine-create/routines_.new_.exercises_.$exerciseId.tsx @@ -1,10 +1,10 @@ -/** Registers the selected-exercise configuration route. */ +/** 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/exercise-configuration'; export const Route = createFileRoute( - '/_app/routines_/new_/exercises_/$exerciseId', + '/_app/_routine-create/routines_/new_/exercises_/$exerciseId', )({ component: ExerciseConfigurationRoute, }); diff --git a/apps/web/src/routes/_app.routines_.new.tsx b/apps/web/src/routes/_app.routines_.new.tsx deleted file mode 100644 index ef30a47..0000000 --- a/apps/web/src/routes/_app.routines_.new.tsx +++ /dev/null @@ -1,8 +0,0 @@ -// Maps the routine-creation URL into the protected application layout. -import { createFileRoute } from '@tanstack/react-router'; - -import { RoutineEditor } from '../features/routines/routine-editor'; - -export const Route = createFileRoute('/_app/routines_/new')({ - component: RoutineEditor, -}); diff --git a/apps/web/src/routes/_app.routines_.new_.exercises.tsx b/apps/web/src/routes/_app.routines_.new_.exercises.tsx deleted file mode 100644 index 7b7d5f8..0000000 --- a/apps/web/src/routes/_app.routines_.new_.exercises.tsx +++ /dev/null @@ -1,8 +0,0 @@ -/** 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, -}); diff --git a/packages/contracts/src/routines/routine.contract.test.ts b/packages/contracts/src/routines/routine.contract.test.ts index b698422..84f5ac1 100644 --- a/packages/contracts/src/routines/routine.contract.test.ts +++ b/packages/contracts/src/routines/routine.contract.test.ts @@ -3,7 +3,9 @@ import { describe, expect, it } from 'vitest'; import { createRoutineInputSchema, + ROUTINE_NAME_MAX_LENGTH, routineListResponseSchema, + routineNameSchema, routineSummarySchema, } from './routine.contract.js'; @@ -23,6 +25,23 @@ const validCreateRoutineInput = { schedules: [{ dayOfWeek: 1, localTime: '18:30:00' }], }; +describe('routineNameSchema', () => { + it('normalizes valid names and rejects empty names', () => { + expect(routineNameSchema.parse(' Upper Body ')).toBe('Upper Body'); + expect(() => routineNameSchema.parse(' ')).toThrow(); + }); + + it('returns a clear maximum-length message', () => { + const result = routineNameSchema.safeParse( + 'x'.repeat(ROUTINE_NAME_MAX_LENGTH + 1), + ); + + expect(result.error?.issues[0]?.message).toBe( + `Maximum length is ${ROUTINE_NAME_MAX_LENGTH} characters`, + ); + }); +}); + describe('createRoutineInputSchema', () => { it('normalizes a valid routine and applies rest defaults', () => { expect(createRoutineInputSchema.parse(validCreateRoutineInput)).toEqual({ diff --git a/packages/contracts/src/routines/routine.contract.ts b/packages/contracts/src/routines/routine.contract.ts index f41cb2f..80dd669 100644 --- a/packages/contracts/src/routines/routine.contract.ts +++ b/packages/contracts/src/routines/routine.contract.ts @@ -3,6 +3,15 @@ import { z } from 'zod'; export const ROUTINE_NAME_MAX_LENGTH = 100; +export const routineNameSchema = z + .string() + .trim() + .min(1, 'Enter a routine name.') + .max( + ROUTINE_NAME_MAX_LENGTH, + `Maximum length is ${ROUTINE_NAME_MAX_LENGTH} characters`, + ); + export const createRoutineExerciseInputSchema = z .object({ exerciseId: z.uuid(), @@ -26,7 +35,7 @@ export const createRoutineScheduleInputSchema = z export const createRoutineInputSchema = z .object({ - name: z.string().trim().min(1).max(ROUTINE_NAME_MAX_LENGTH), + name: routineNameSchema, description: z.string().trim().nullable().optional(), exercises: z.array(createRoutineExerciseInputSchema).min(1), schedules: z.array(createRoutineScheduleInputSchema).default([]),