From 0612e3bc9e5ce8987a203658a914f9099c881e81 Mon Sep 17 00:00:00 2001 From: rd-code-23 Date: Sun, 30 Aug 2026 12:50:02 -0700 Subject: [PATCH] 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, + }) + } + /> +
+