Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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');
});
});
Original file line number Diff line number Diff line change
@@ -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<ScheduleEditorAction, { type: 'day-toggled' }>,
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<ScheduleEditorAction, { type: 'time-changed' }>,
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;
}
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -15,11 +15,39 @@ 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' }));

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(<RoutineScheduleField />);

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');
});
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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() {
Expand All @@ -38,21 +42,10 @@ export function RoutineScheduleField() {
}

function ScheduleDialog() {
const [selectedDays, setSelectedDays] = useState<Set<string>>(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 (
<Dialog>
Expand All @@ -76,36 +69,47 @@ function ScheduleDialog() {

<div className="mt-6 divide-y divide-border/40 overflow-hidden rounded-lg border border-border/60">
{WEEKDAYS.map((day) => {
const isSelected = selectedDays.has(day.label);
const scheduleDay = state.days[day.dayOfWeek]!;

return (
<div
className={cn(
'grid min-h-14 grid-cols-[auto_1fr_7.5rem] items-center gap-3 px-3 py-2',
'transition-colors',
isSelected && 'bg-surface/70',
scheduleDay.isSelected && 'bg-surface/70',
)}
key={day.label}
>
<Switch
aria-label={`Schedule ${day.label}`}
checked={isSelected}
checked={scheduleDay.isSelected}
onCheckedChange={(checked) =>
setDaySelected(day.label, checked)
dispatch({
type: 'day-toggled',
dayOfWeek: day.dayOfWeek,
isSelected: checked,
})
}
/>
<span className="text-sm sm:text-base">{day.shortLabel}</span>

{isSelected ? (
{scheduleDay.isSelected ? (
<input
aria-label={`${day.label} time`}
className={cn(
'h-9 w-full rounded-md border px-2 text-sm',
'border-input bg-surface-elevated text-foreground',
'outline-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/40',
)}
defaultValue="18:00"
onChange={(event) =>
dispatch({
type: 'time-changed',
dayOfWeek: day.dayOfWeek,
time: event.target.value,
})
}
type="time"
value={scheduleDay.time}
/>
) : (
<span className="pr-2 text-right text-muted-foreground">
Expand All @@ -117,6 +121,22 @@ function ScheduleDialog() {
})}
</div>

<div className="mt-4 flex items-center justify-between gap-4">
<Label htmlFor="use-shared-schedule-time">
Use same time for selected days
</Label>
<Switch
checked={state.useSameTime}
id="use-shared-schedule-time"
onCheckedChange={(checked) =>
dispatch({
type: 'shared-time-toggled',
isEnabled: checked,
})
}
/>
</div>

<div className="mt-6 grid grid-cols-2 gap-3">
<DialogClose asChild>
<Button type="button" variant="outline">
Expand Down