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
Expand Up @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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,
Expand All @@ -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<ScheduleEditorAction, { type: 'day-toggled' }>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(<RoutineScheduleField />);
renderScheduleField();

await user.click(screen.getByRole('button', { name: 'Add schedule' }));

Expand All @@ -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(<RoutineScheduleField />);
renderScheduleField();

await user.click(screen.getByRole('button', { name: 'Add schedule' }));
await user.click(screen.getByRole('switch', { name: 'Schedule Monday' }));
Expand All @@ -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(
<RoutineDraftFormProvider>
<RoutineScheduleField />
</RoutineDraftFormProvider>,
);
}
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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,
Expand All @@ -30,25 +32,70 @@ 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<RoutineDraftFormValues>();
const schedules = useWatch({ control, name: 'schedules' });

return (
<div>
<Label isOptional>Schedule</Label>

<div className="mt-2 flex min-h-14 items-center">
<div className="mt-2 flex min-h-14 flex-wrap items-center gap-2">
{schedules.map((schedule) => (
<div
className="min-w-20 rounded-lg border border-border/60 bg-surface-elevated px-3 py-2 text-center"
key={schedule.dayOfWeek}
>
<p className="text-sm font-medium">
{WEEKDAYS[schedule.dayOfWeek]?.shortLabel}
</p>
<p className="text-xs text-muted-foreground">
{formatScheduleTime(schedule.localTime)}
</p>
</div>
))}
<ScheduleDialog />
</div>
</div>
);
}

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<RoutineDraftFormValues>();
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 (
<Dialog>
<Dialog onOpenChange={handleOpenChange} open={isOpen}>
<DialogTrigger asChild>
<Button
aria-label="Add schedule"
Expand Down Expand Up @@ -143,11 +190,20 @@ function ScheduleDialog() {
Cancel
</Button>
</DialogClose>
<Button disabled type="button">
<Button onClick={saveSchedules} type="button">
Save Schedule
</Button>
</div>
</DialogContent>
</Dialog>
);
}

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}`;
}
8 changes: 6 additions & 2 deletions apps/web/src/features/routines/create/routine-draft-form.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof routineDraftFormSchema>;
Expand All @@ -17,7 +21,7 @@ export function RoutineDraftFormProvider({
children: ReactNode;
}) {
const form = useForm<RoutineDraftFormValues>({
defaultValues: { name: '' },
defaultValues: { name: '', schedules: [] },
mode: 'onChange', // when to run validation.
resolver: zodResolver(routineDraftFormSchema),
});
Expand Down