@@ -5,6 +5,7 @@ import type { DateRange } from "react-day-picker";
55import {
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
7376const 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+
75100function 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+
106159export 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