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 969f261..19014f9 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 @@ -79,11 +79,17 @@ describe('routine editor', () => { 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 () => { 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 index f8d2c02..5febef7 100644 --- 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 @@ -1,5 +1,9 @@ +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; @@ -12,24 +16,21 @@ export type ScheduleEditorState = { }; 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: ScheduleEditorState = { - days: Array.from({ length: 7 }, (_, dayOfWeek) => ({ - dayOfWeek, - isSelected: false, - time: DEFAULT_SCHEDULE_TIME, - })), - useSameTime: true, -}; +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, @@ -53,6 +54,29 @@ export function scheduleEditorReducer( } } +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, 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 caae173..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 @@ -2,13 +2,14 @@ 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(); - render(); + renderScheduleField(); await user.click(screen.getByRole('button', { name: 'Add schedule' })); @@ -17,16 +18,17 @@ 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' })); expect(screen.getByLabelText('Monday time')).toHaveValue('18:00'); - expect(screen.getByRole('button', { name: 'Save Schedule' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Save Schedule' })).toBeEnabled(); }); it('keeps selected days synchronized until shared time is disabled', async () => { const user = userEvent.setup(); - render(); + renderScheduleField(); await user.click(screen.getByRole('button', { name: 'Add schedule' })); await user.click(screen.getByRole('switch', { name: 'Schedule Monday' })); @@ -51,3 +53,60 @@ it('keeps selected days synchronized until shared time is disabled', async () => 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 index 62b56d9..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 @@ -1,6 +1,7 @@ /** Displays routine schedules and opens the responsive weekly schedule editor. */ import { Plus } from 'lucide-react'; -import { useReducer } from 'react'; +import { useReducer, useState } from 'react'; +import { useFormContext, useWatch } from 'react-hook-form'; import { Button } from '@/components/ui/button'; import { @@ -14,6 +15,7 @@ import { 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, @@ -30,11 +32,29 @@ const WEEKDAYS = [ ] 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)} +

+
+ ))}
@@ -42,13 +62,40 @@ export function RoutineScheduleField() { } 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 ( - +
@@ -151,3 +198,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), });