Skip to content

Commit 8164435

Browse files
Add weekday filtering to event creation (#66)
* Add weekday filter to create event form * Address create form review feedback
1 parent b7b0460 commit 8164435

3 files changed

Lines changed: 254 additions & 15 deletions

File tree

src/components/create-event-form.test.tsx

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ describe("CreateEventForm", () => {
5858
renderCreateEventForm("de");
5959

6060
expect(screen.getByLabelText("Event-Titel")).toBeInTheDocument();
61+
expect(screen.getByRole("group", { name: "Verfügbare Wochentage" })).toBeInTheDocument();
62+
expect(screen.getByRole("checkbox", { name: "Montag" })).toBeChecked();
63+
expect(screen.getByRole("checkbox", { name: "Samstag" })).not.toBeChecked();
6164
expect(screen.getByRole("button", { name: "Event erstellen" })).toBeInTheDocument();
6265
});
6366

@@ -67,6 +70,18 @@ describe("CreateEventForm", () => {
6770
expect(screen.getByRole("combobox", { name: "Slot size" })).toHaveTextContent("60 min");
6871
});
6972

73+
it("defaults weekdays to Monday through Friday", () => {
74+
renderCreateEventForm();
75+
76+
expect(screen.getByRole("checkbox", { name: "Monday" })).toBeChecked();
77+
expect(screen.getByRole("checkbox", { name: "Tuesday" })).toBeChecked();
78+
expect(screen.getByRole("checkbox", { name: "Wednesday" })).toBeChecked();
79+
expect(screen.getByRole("checkbox", { name: "Thursday" })).toBeChecked();
80+
expect(screen.getByRole("checkbox", { name: "Friday" })).toBeChecked();
81+
expect(screen.getByRole("checkbox", { name: "Saturday" })).not.toBeChecked();
82+
expect(screen.getByRole("checkbox", { name: "Sunday" })).not.toBeChecked();
83+
});
84+
7085
it("shows a friendly inline title validation message before submitting", () => {
7186
const fetchMock = vi.fn();
7287
global.fetch = fetchMock as typeof fetch;
@@ -191,6 +206,68 @@ describe("CreateEventForm", () => {
191206
});
192207
});
193208

209+
it("submits only enabled weekdays from the selected date range", async () => {
210+
vi.useFakeTimers();
211+
vi.setSystemTime(new Date("2026-04-02T10:00:00.000Z"));
212+
213+
const fetchMock = vi.fn().mockResolvedValue({
214+
ok: true,
215+
json: async () => ({
216+
manageKey: "manage-key-123",
217+
}),
218+
});
219+
global.fetch = fetchMock as typeof fetch;
220+
221+
renderCreateEventForm();
222+
vi.useRealTimers();
223+
const user = userEvent.setup();
224+
225+
await user.type(screen.getByLabelText("Event title"), "Sprint planning");
226+
await user.click(screen.getByRole("checkbox", { name: "Friday" }));
227+
await user.click(screen.getByRole("checkbox", { name: "Sunday" }));
228+
await user.click(screen.getByRole("button", { name: "Create event" }));
229+
230+
await waitFor(() => {
231+
expect(fetchMock).toHaveBeenCalledTimes(1);
232+
});
233+
234+
const [, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit];
235+
const payload = JSON.parse(String(requestInit.body)) as {
236+
dates: string[];
237+
};
238+
239+
expect(payload.dates).toEqual(["2026-04-05"]);
240+
});
241+
242+
it("requires at least one enabled weekday inside the selected date range", async () => {
243+
vi.useFakeTimers();
244+
vi.setSystemTime(new Date("2026-04-02T10:00:00.000Z"));
245+
246+
const fetchMock = vi.fn();
247+
global.fetch = fetchMock as typeof fetch;
248+
249+
renderCreateEventForm();
250+
vi.useRealTimers();
251+
const user = userEvent.setup();
252+
253+
await user.type(screen.getByLabelText("Event title"), "Sprint planning");
254+
await user.click(screen.getByRole("checkbox", { name: "Friday" }));
255+
await user.click(screen.getByRole("button", { name: "Create event" }));
256+
257+
expect(
258+
screen.getByText("Select at least one available weekday inside the date range."),
259+
).toBeInTheDocument();
260+
expect(screen.getByRole("group", { name: "Available weekdays" })).toHaveAttribute(
261+
"aria-invalid",
262+
"true",
263+
);
264+
expect(screen.getByRole("group", { name: "Available weekdays" })).toHaveAttribute(
265+
"aria-describedby",
266+
"weekday-filter-description weekday-filter-error",
267+
);
268+
expect(fetchMock).not.toHaveBeenCalled();
269+
});
270+
194271
it("submits the optional notification email when alerts are available", async () => {
195272
const user = userEvent.setup();
196273
const fetchMock = vi.fn().mockResolvedValue({

src/components/create-event-form.tsx

Lines changed: 139 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { DateRange } from "react-day-picker";
55
import {
66
CalendarRangeIcon,
77
ChevronDownIcon,
8+
CheckIcon,
89
Loader2Icon,
910
SparklesIcon,
1011
} from "lucide-react";
@@ -50,6 +51,7 @@ const eventFieldOrder = [
5051
"notificationEmail",
5152
"timezone",
5253
"dates",
54+
"weekdays",
5355
"dayStartMinutes",
5456
"dayEndMinutes",
5557
"slotMinutes",
@@ -64,6 +66,7 @@ const eventFieldIds: Record<EventField, string> = {
6466
notificationEmail: "notification-email",
6567
timezone: "timezone-trigger",
6668
dates: "date-range-trigger",
69+
weekdays: "weekday-filter",
6770
dayStartMinutes: "day-start-trigger",
6871
dayEndMinutes: "day-end-trigger",
6972
slotMinutes: "slot-size-trigger",
@@ -72,6 +75,28 @@ const eventFieldIds: Record<EventField, string> = {
7275

7376
const eventFieldSet = new Set<EventField>(eventFieldOrder);
7477

78+
const weekdayOptions = [
79+
{ value: 1, messageKey: "monday", defaultSelected: true },
80+
{ value: 2, messageKey: "tuesday", defaultSelected: true },
81+
{ value: 3, messageKey: "wednesday", defaultSelected: true },
82+
{ value: 4, messageKey: "thursday", defaultSelected: true },
83+
{ value: 5, messageKey: "friday", defaultSelected: true },
84+
{ value: 6, messageKey: "saturday", defaultSelected: false },
85+
{ value: 0, messageKey: "sunday", defaultSelected: false },
86+
] as const;
87+
88+
const defaultSelectedWeekdays = weekdayOptions
89+
.filter((weekday) => weekday.defaultSelected)
90+
.map((weekday) => weekday.value);
91+
92+
function sortWeekdays(values: number[]) {
93+
return [...values].sort(
94+
(left, right) =>
95+
weekdayOptions.findIndex((weekday) => weekday.value === left) -
96+
weekdayOptions.findIndex((weekday) => weekday.value === right),
97+
);
98+
}
99+
75100
function getRangeLabel(
76101
range: DateRange | undefined,
77102
localeLabel: string,
@@ -103,6 +128,34 @@ function getRangeDays(range: DateRange | undefined) {
103128
}).length;
104129
}
105130

131+
function getFilteredRangeDays(range: DateRange | undefined, selectedWeekdays: number[]) {
132+
if (!range?.from || !range?.to) {
133+
return 0;
134+
}
135+
136+
const selectedWeekdaySet = new Set(selectedWeekdays);
137+
138+
return eachDayOfInterval({
139+
start: range.from,
140+
end: range.to,
141+
}).filter((date) => selectedWeekdaySet.has(date.getDay())).length;
142+
}
143+
144+
function expandRangeToDateKeys(range: DateRange, selectedWeekdays: number[]) {
145+
if (!range.from || !range.to) {
146+
return [];
147+
}
148+
149+
const selectedWeekdaySet = new Set(selectedWeekdays);
150+
151+
return eachDayOfInterval({
152+
start: range.from,
153+
end: range.to,
154+
})
155+
.filter((date) => selectedWeekdaySet.has(date.getDay()))
156+
.map((date) => format(date, "yyyy-MM-dd"));
157+
}
158+
106159
export function CreateEventForm({
107160
timezones,
108161
timeOptions,
@@ -148,14 +201,9 @@ export function CreateEventForm({
148201
const [dayEndMinutes, setDayEndMinutes] = useState(
149202
defaultCreateEventDefaults.dayEndMinutes,
150203
);
151-
152-
const selectedDates =
153-
dateRange?.from && dateRange?.to
154-
? eachDayOfInterval({
155-
start: dateRange.from,
156-
end: dateRange.to,
157-
}).map((date) => format(date, "yyyy-MM-dd"))
158-
: [];
204+
const [selectedWeekdays, setSelectedWeekdays] = useState<number[]>(
205+
defaultSelectedWeekdays,
206+
);
159207

160208
const compactDateLabel = locale === "de" ? "d. MMM yyyy" : "MMM d, yyyy";
161209
const selectedRangeLabel = getRangeLabel(dateRange, compactDateLabel, messages, dateFnsLocale);
@@ -167,6 +215,7 @@ export function CreateEventForm({
167215
);
168216
const selectedRangeDays = getRangeDays(dateRange);
169217
const draftRangeDays = getRangeDays(draftDateRange);
218+
const selectedFilteredRangeDays = getFilteredRangeDays(dateRange, selectedWeekdays);
170219
const timezoneOptions = useMemo(
171220
() =>
172221
buildTimezoneOptions(
@@ -255,6 +304,17 @@ export function CreateEventForm({
255304
setIsRangePickerOpen(false);
256305
}
257306

307+
function toggleWeekday(value: number) {
308+
setSelectedWeekdays((current) => {
309+
if (current.includes(value)) {
310+
return current.filter((weekday) => weekday !== value);
311+
}
312+
313+
return sortWeekdays([...current, value]);
314+
});
315+
clearErrors("weekdays", "dates");
316+
}
317+
258318
function onSubmit(event: React.FormEvent<HTMLFormElement>) {
259319
event.preventDefault();
260320
setErrorMessage(null);
@@ -267,6 +327,16 @@ export function CreateEventForm({
267327
return;
268328
}
269329

330+
const selectedDates = expandRangeToDateKeys(dateRange, selectedWeekdays);
331+
332+
if (selectedDates.length === 0) {
333+
setFieldErrors({
334+
weekdays: messages.validation.eventCreate.weekdayRequired,
335+
});
336+
focusField("weekdays");
337+
return;
338+
}
339+
270340
const parsed = createEventCreateSchema(messages).safeParse({
271341
title,
272342
notificationEmail: notificationsConfigured ? notificationEmail : undefined,
@@ -522,6 +592,66 @@ export function CreateEventForm({
522592
</p>
523593
) : null}
524594
</div>
595+
596+
<fieldset
597+
id={eventFieldIds.weekdays}
598+
tabIndex={-1}
599+
aria-describedby={
600+
fieldErrors.weekdays
601+
? "weekday-filter-description weekday-filter-error"
602+
: "weekday-filter-description"
603+
}
604+
aria-invalid={fieldErrors.weekdays ? true : undefined}
605+
className="space-y-2 rounded-md focus:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 md:col-span-2"
606+
>
607+
<legend className="sr-only">{messages.createEvent.weekdays.label}</legend>
608+
<div className="flex flex-wrap items-center justify-between gap-2">
609+
<span className="text-sm font-medium">{messages.createEvent.weekdays.label}</span>
610+
<Badge variant="secondary" className="rounded-full px-2.5">
611+
{plural(messages.createEvent.range.days, selectedFilteredRangeDays)}
612+
</Badge>
613+
</div>
614+
<p id="weekday-filter-description" className="text-sm text-muted-foreground">
615+
{messages.createEvent.weekdays.description}
616+
</p>
617+
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4 lg:grid-cols-7">
618+
{weekdayOptions.map((weekday) => {
619+
const isSelected = selectedWeekdays.includes(weekday.value);
620+
const weekdayMessage =
621+
messages.createEvent.weekdays.options[weekday.messageKey];
622+
623+
return (
624+
<label
625+
key={weekday.value}
626+
className={cn(
627+
"flex min-h-12 cursor-pointer items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm transition-colors",
628+
isSelected
629+
? "border-primary bg-primary/10 text-foreground"
630+
: "border-border bg-background text-muted-foreground hover:bg-muted/50",
631+
)}
632+
>
633+
<input
634+
type="checkbox"
635+
className="sr-only"
636+
aria-label={weekdayMessage.label}
637+
checked={isSelected}
638+
onChange={() => toggleWeekday(weekday.value)}
639+
/>
640+
<span className="min-w-0" aria-hidden="true">
641+
<span className="block font-medium">{weekdayMessage.shortLabel}</span>
642+
<span className="block truncate text-xs">{weekdayMessage.label}</span>
643+
</span>
644+
{isSelected ? <CheckIcon className="size-4 shrink-0" aria-hidden="true" /> : null}
645+
</label>
646+
);
647+
})}
648+
</div>
649+
{fieldErrors.weekdays ? (
650+
<p id="weekday-filter-error" className="text-sm text-destructive">
651+
{fieldErrors.weekdays}
652+
</p>
653+
) : null}
654+
</fieldset>
525655
</div>
526656

527657
<Separator />
@@ -709,7 +839,7 @@ export function CreateEventForm({
709839
</div>
710840
<div className="flex items-center justify-between gap-4">
711841
<dt className="text-muted-foreground">{messages.createEvent.previewFields.daysShown}</dt>
712-
<dd className="font-medium">{selectedRangeDays}</dd>
842+
<dd className="font-medium">{selectedFilteredRangeDays}</dd>
713843
</div>
714844
<div className="flex items-center justify-between gap-4">
715845
<dt className="text-muted-foreground">{messages.createEvent.previewFields.dailyWindow}</dt>

0 commit comments

Comments
 (0)