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/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 65% 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 e9e397a..4eca5e3 100644 --- a/apps/web/src/features/routines/routine-editor.test.tsx +++ b/apps/web/src/features/routines/create/editor/routine-editor.test.tsx @@ -14,9 +14,9 @@ import { cleanup, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { RoutineDraftFormProvider } from './routine-draft-form'; +import { exercisesQueryKey } from '@/features/exercises/exercises.query'; +import { RoutineDraftFormProvider } from '../routine-draft-form'; import { RoutineEditor } from './routine-editor'; -import { exercisesQueryKey } from '../exercises/exercises.query'; afterEach(() => { cleanup(); @@ -40,8 +40,11 @@ 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(screen.getByRole('button', { name: 'Add schedule' })).toBeEnabled(); expect( screen.getByRole('link', { name: 'Back to routines' }), ).toHaveAttribute('href', '/routines'); @@ -74,6 +77,27 @@ 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: '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: 'Clear' })); + + expect(routineNameInput).toHaveValue(''); + expect(routineNameInput).not.toHaveAttribute('aria-invalid'); + expect(screen.queryByRole('alert')).toBeNull(); + expect(screen.queryByText('Mon')).toBeNull(); + }); + it('keeps the current draft and opens exercises when the name is invalid', async () => { const user = userEvent.setup(); const router = renderEditor(); @@ -95,6 +119,24 @@ 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() { @@ -102,26 +144,28 @@ function renderEditor() { defaultOptions: { queries: { retry: false } }, }); queryClient.setQueryData(exercisesQueryKey, { exercises: [] }); - 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, ]), }); @@ -134,7 +178,7 @@ function renderEditor() { return testRouter; } -function TestRoutineCreationLayout() { +function TestApplicationLayout() { return ( diff --git a/apps/web/src/features/routines/routine-editor.tsx b/apps/web/src/features/routines/create/editor/routine-editor.tsx similarity index 82% rename from apps/web/src/features/routines/routine-editor.tsx rename to apps/web/src/features/routines/create/editor/routine-editor.tsx index 92ca08e..c6f667f 100644 --- a/apps/web/src/features/routines/routine-editor.tsx +++ b/apps/web/src/features/routines/create/editor/routine-editor.tsx @@ -3,18 +3,19 @@ import { Link, useNavigate } from '@tanstack/react-router'; import { ArrowLeft, Plus } from 'lucide-react'; import { useFormContext, useWatch } 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 { RoutineExerciseList } from './routine-exercise-list'; import { RoutineScheduleField } from './routine-schedule-field'; export function RoutineEditor() { const navigate = useNavigate(); + const { reset } = useFormContext(); const addExercise = async () => { await navigate({ to: '/routines/new/exercises' }); @@ -22,7 +23,7 @@ export function RoutineEditor() { return (
- + reset()} />
@@ -36,7 +37,7 @@ export function RoutineEditor() { ); } -function EditorHeader() { +function EditorHeader({ onClear }: { onClear: () => void }) { return (
Create Routine
-
diff --git a/apps/web/src/features/routines/routine-exercise-list.tsx b/apps/web/src/features/routines/create/editor/routine-exercise-list.tsx similarity index 95% rename from apps/web/src/features/routines/routine-exercise-list.tsx rename to apps/web/src/features/routines/create/editor/routine-exercise-list.tsx index aecd3f0..84529a8 100644 --- a/apps/web/src/features/routines/routine-exercise-list.tsx +++ b/apps/web/src/features/routines/create/editor/routine-exercise-list.tsx @@ -1,8 +1,8 @@ /** Displays configured exercises in the routine draft. */ import { Dumbbell, GripVertical, MoreHorizontal } from 'lucide-react'; -import { cn } from '../../lib/utils'; -import type { RoutineDraftExerciseFormValues } from './routine-draft-form'; +import { cn } from '@/lib/utils'; +import type { RoutineDraftExerciseFormValues } from '../routine-draft-form'; export function RoutineExerciseList({ exercises, 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..5febef7 --- /dev/null +++ b/apps/web/src/features/routines/create/editor/routine-schedule-editor.reducer.ts @@ -0,0 +1,122 @@ +import type { CreateRoutineInput } from '@goforlift/contracts'; + +export const DEFAULT_SCHEDULE_TIME = '18:00'; + +type RoutineSchedule = CreateRoutineInput['schedules'][number]; + +export type ScheduleEditorDay = { + dayOfWeek: number; + isSelected: boolean; + time: string; +}; + +export type ScheduleEditorState = { + days: ScheduleEditorDay[]; + useSameTime: boolean; +}; + +export type ScheduleEditorAction = + | { type: 'draft-loaded'; schedules: RoutineSchedule[] } + | { 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 = createScheduleEditorState([]); + +export function scheduleEditorReducer( + state: ScheduleEditorState, + action: ScheduleEditorAction, +): ScheduleEditorState { + switch (action.type) { + case 'draft-loaded': + return createScheduleEditorState(action.schedules); + + 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, + }; + } +} + +export function createScheduleEditorState( + schedules: RoutineSchedule[], +): ScheduleEditorState { + return { + days: Array.from({ length: 7 }, (_, dayOfWeek) => { + const savedSchedule = schedules.find( + (schedule) => schedule.dayOfWeek === dayOfWeek, + ); + + return { + dayOfWeek, + isSelected: Boolean(savedSchedule), + time: savedSchedule?.localTime.slice(0, 5) ?? DEFAULT_SCHEDULE_TIME, + }; + }), + useSameTime: hasOneSelectedTime(schedules), + }; +} + +function hasOneSelectedTime(schedules: RoutineSchedule[]) { + return new Set(schedules.map((schedule) => schedule.localTime)).size <= 1; +} + +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 new file mode 100644 index 0000000..48d8a44 --- /dev/null +++ b/apps/web/src/features/routines/create/editor/routine-schedule-field.test.tsx @@ -0,0 +1,112 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, expect, it } from 'vitest'; + +import { RoutineDraftFormProvider } from '../routine-draft-form'; +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(); + renderScheduleField(); + + await user.click(screen.getByRole('button', { name: 'Add schedule' })); + + expect( + screen.getByRole('heading', { name: 'Schedule Routine' }), + ).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' })); + + expect(screen.getByLabelText('Monday time')).toHaveValue('18:00'); + expect(screen.getByRole('button', { name: 'Save Schedule' })).toBeEnabled(); +}); + +it('keeps selected days synchronized until shared time is disabled', 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('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'); +}); + +it('saves selected schedules into the routine draft', async () => { + const user = userEvent.setup(); + renderScheduleField(); + + await user.click(screen.getByRole('button', { name: 'Add schedule' })); + await user.click(screen.getByRole('switch', { name: 'Schedule Monday' })); + fireEvent.change(screen.getByLabelText('Monday time'), { + target: { value: '07:30' }, + }); + await user.click(screen.getByRole('button', { name: 'Save Schedule' })); + + expect( + screen.queryByRole('heading', { name: 'Schedule Routine' }), + ).toBeNull(); + expect(screen.getByText('Mon')).toBeVisible(); + expect(screen.getByText('7:30 AM')).toBeVisible(); +}); + +it('discards unsaved schedule changes when cancelled', async () => { + const user = userEvent.setup(); + renderScheduleField(); + + await user.click(screen.getByRole('button', { name: 'Add schedule' })); + await user.click(screen.getByRole('switch', { name: 'Schedule Tuesday' })); + await user.click(screen.getByRole('button', { name: 'Cancel' })); + await user.click(screen.getByRole('button', { name: 'Add schedule' })); + + expect( + screen.getByRole('switch', { name: 'Schedule Tuesday' }), + ).not.toBeChecked(); + 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 new file mode 100644 index 0000000..21cf322 --- /dev/null +++ b/apps/web/src/features/routines/create/editor/routine-schedule-field.tsx @@ -0,0 +1,209 @@ +/** Displays routine schedules and opens the responsive weekly schedule editor. */ +import { Plus } from 'lucide-react'; +import { useReducer, useState } from 'react'; +import { useFormContext, useWatch } from 'react-hook-form'; + +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'; +import type { RoutineDraftFormValues } from '../routine-draft-form'; +import { + INITIAL_SCHEDULE_EDITOR_STATE, + scheduleEditorReducer, +} from './routine-schedule-editor.reducer'; + +const WEEKDAYS = [ + { 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() { + // `control` is RHF's reference to this specific form instance. useWatch uses + // it to subscribe to this form's schedules and rerender when they change. + const { control } = useFormContext(); + const schedules = useWatch({ control, name: 'schedules' }); + + return ( +
+ + +
+ {schedules.map((schedule) => ( +
+

+ {WEEKDAYS[schedule.dayOfWeek]?.shortLabel} +

+

+ {formatScheduleTime(schedule.localTime)} +

+
+ ))} + +
+
+ ); +} + +function ScheduleDialog() { + const [isOpen, setIsOpen] = useState(false); + const [state, dispatch] = useReducer( + scheduleEditorReducer, + INITIAL_SCHEDULE_EDITOR_STATE, + ); + // `control` tells useWatch which RHF form instance to read schedules from. + const { control, setValue } = useFormContext(); + const savedSchedules = useWatch({ control, name: 'schedules' }); + + function handleOpenChange(nextIsOpen: boolean) { + if (nextIsOpen) { + dispatch({ type: 'draft-loaded', schedules: savedSchedules }); + } + + setIsOpen(nextIsOpen); + } + + function saveSchedules() { + const schedules = state.days + .filter((day) => day.isSelected) + .map((day) => ({ + dayOfWeek: day.dayOfWeek, + localTime: `${day.time}:00`, + })); + + setValue('schedules', schedules, { + shouldDirty: true, + shouldValidate: true, + }); + setIsOpen(false); + } + + return ( + + + + + + + Schedule Routine + + Choose the days and time for this routine. + + +
+ {WEEKDAYS.map((day) => { + const scheduleDay = state.days[day.dayOfWeek]!; + + return ( +
+ + dispatch({ + type: 'day-toggled', + dayOfWeek: day.dayOfWeek, + isSelected: checked, + }) + } + /> + {day.shortLabel} + + {scheduleDay.isSelected ? ( + + dispatch({ + type: 'time-changed', + dayOfWeek: day.dayOfWeek, + time: event.target.value, + }) + } + type="time" + value={scheduleDay.time} + /> + ) : ( + + — + + )} +
+ ); + })} +
+ +
+ + + dispatch({ + type: 'shared-time-toggled', + isEnabled: checked, + }) + } + /> +
+ +
+ + + + +
+
+
+ ); +} + +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/exercise-configuration.test.tsx b/apps/web/src/features/routines/create/exercise-configuration/exercise-configuration.test.tsx similarity index 84% 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 index d1332ca..4f988aa 100644 --- a/apps/web/src/features/routines/exercise-configuration.test.tsx +++ b/apps/web/src/features/routines/create/exercise-configuration/exercise-configuration.test.tsx @@ -12,10 +12,10 @@ import { cleanup, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, expect, it, vi } from 'vitest'; -import { RoutineDraftFormProvider } from './routine-draft-form'; -import { RoutineEditor } from './routine-editor'; +import { RoutineEditor } from '../editor/routine-editor'; +import { ExercisePicker } from '../exercise-picker/exercise-picker'; +import { RoutineDraftFormProvider } from '../routine-draft-form'; import { ExerciseConfiguration } from './exercise-configuration'; -import { ExercisePicker } from './exercise-picker'; afterEach(() => { cleanup(); @@ -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/exercise-configuration.tsx b/apps/web/src/features/routines/create/exercise-configuration/exercise-configuration.tsx similarity index 99% rename from apps/web/src/features/routines/exercise-configuration.tsx rename to apps/web/src/features/routines/create/exercise-configuration/exercise-configuration.tsx index 77b453e..99e239f 100644 --- a/apps/web/src/features/routines/exercise-configuration.tsx +++ b/apps/web/src/features/routines/create/exercise-configuration/exercise-configuration.tsx @@ -22,9 +22,10 @@ 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'; -import type { RoutineDraftFormValues } from './routine-draft-form'; import { z } from 'zod'; +import type { RoutineDraftFormValues } from '../routine-draft-form'; + type RoutineExerciseFormValues = z.input< typeof createRoutineExerciseInputSchema >; 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 79% rename from apps/web/src/features/routines/routine-draft-form.tsx rename to apps/web/src/features/routines/create/routine-draft-form.tsx index e342733..7ea85d9 100644 --- a/apps/web/src/features/routines/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, 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/features/routines/routine-schedule-field.tsx b/apps/web/src/features/routines/routine-schedule-field.tsx deleted file mode 100644 index 3054ee6..0000000 --- a/apps/web/src/features/routines/routine-schedule-field.tsx +++ /dev/null @@ -1,26 +0,0 @@ -/** 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'; - -export function RoutineScheduleField() { - return ( -
- - -
- -
-
- ); -} diff --git a/apps/web/src/routes/_app._routine-create/route.tsx b/apps/web/src/routes/_app._routine-create/route.tsx index fc8d670..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/routine-draft-form'; - export const Route = createFileRoute('/_app/_routine-create')({ component: RoutineCreationLayout, }); function RoutineCreationLayout() { - return ( - - - - ); + 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 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, 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) => ( - - - + + + + + )}