22
33import { useRef } from 'react'
44import { format } from 'date-fns'
5- import { ChipDatePicker , ChipModalField , ChipModalSeparator , Switch } from '@/components/emcn'
5+ import { Chip , ChipDatePicker , ChipModalField , ChipModalSeparator , Switch } from '@/components/emcn'
66import type {
7+ MonthlyMode ,
78 Recurrence ,
89 RecurrenceFrequency ,
910} from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/recurrence'
@@ -14,26 +15,63 @@ const DEFAULT_END_AFTER_COUNT = 10
1415/** Cadence a task falls back to when the user first flips on recurrence. */
1516const DEFAULT_RECURRING_FREQUENCY = 'daily'
1617
18+ /** Sunday-first weekday order with single-letter labels and full names for a11y. */
19+ const WEEKDAYS = [
20+ { value : 0 , short : 'S' , name : 'Sunday' } ,
21+ { value : 1 , short : 'M' , name : 'Monday' } ,
22+ { value : 2 , short : 'T' , name : 'Tuesday' } ,
23+ { value : 3 , short : 'W' , name : 'Wednesday' } ,
24+ { value : 4 , short : 'T' , name : 'Thursday' } ,
25+ { value : 5 , short : 'F' , name : 'Friday' } ,
26+ { value : 6 , short : 'S' , name : 'Saturday' } ,
27+ ] as const
28+
29+ /** Ordinal words for the 1st–5th weekday-of-month, matching a calendar app's labels. */
30+ const ORDINALS = [ 'first' , 'second' , 'third' , 'fourth' , 'fifth' ] as const
31+
1732/** The frequency presets the dropdown authors, keyed by a synthetic option value. */
18- type FrequencyOption = 'daily' | 'weekly' | 'weekdays' | 'monthly' | 'custom'
33+ type FrequencyOption = 'daily' | 'weekly' | 'weekdays' | 'monthly' | 'yearly' | ' custom'
1934
2035function isWeekdayPreset ( weekdays : number [ ] ) : boolean {
2136 return (
2237 weekdays . length === WEEKDAY_PRESET . length && WEEKDAY_PRESET . every ( ( d ) => weekdays . includes ( d ) )
2338 )
2439}
2540
26- /** Collapses a recurring recurrence into the single dropdown value that represents it. */
41+ /**
42+ * Collapses a recurring recurrence into the single dropdown value that
43+ * represents it. `once` maps to the default cadence as an exhaustiveness
44+ * fallback: callers gate on `isRecurring`, so it never reaches here at runtime,
45+ * but the dropdown can't represent it — mapping it keeps the return type
46+ * `FrequencyOption` without a cast.
47+ */
2748function frequencyOptionFor ( recurrence : Recurrence ) : FrequencyOption {
2849 if ( recurrence . frequency === 'weekly' )
2950 return isWeekdayPreset ( recurrence . weekdays ) ? 'weekdays' : 'weekly'
30- // Exhaustiveness fallback: callers gate on `isRecurring`, so `once` never
31- // reaches here at runtime, but the dropdown can't represent it — mapping it to
32- // a recurring default keeps the return type `FrequencyOption` without a cast.
51+ if ( recurrence . frequency === 'monthly' ) return 'monthly'
52+ if ( recurrence . frequency === 'yearly' ) return 'yearly'
53+ if ( recurrence . frequency === 'custom' ) return 'custom'
3354 if ( recurrence . frequency === 'once' ) return DEFAULT_RECURRING_FREQUENCY
3455 return recurrence . frequency
3556}
3657
58+ /**
59+ * The monthly sub-options, derived from the launch date the same way a calendar
60+ * app offers them: repeat on the day number, on the ordinal weekday of the
61+ * month (e.g. the third Tuesday), or on the last weekday of the month. All three
62+ * are always offered so the picked mode is always representable, even when the
63+ * launch date is changed afterward via the footer date picker.
64+ */
65+ function monthlyModeOptions ( launch : Date ) : Array < { value : MonthlyMode ; label : string } > {
66+ const weekdayName = format ( launch , 'EEEE' )
67+ const ordinal = ORDINALS [ Math . ceil ( launch . getDate ( ) / 7 ) - 1 ]
68+ return [
69+ { value : 'day-of-month' , label : `On day ${ format ( launch , 'd' ) } ` } ,
70+ { value : 'nth-weekday' , label : `On the ${ ordinal } ${ weekdayName } ` } ,
71+ { value : 'last-weekday' , label : `On the last ${ weekdayName } ` } ,
72+ ]
73+ }
74+
3775interface RecurrenceSectionProps {
3876 recurrence : Recurrence
3977 onChange : ( recurrence : Recurrence ) => void
@@ -44,8 +82,9 @@ interface RecurrenceSectionProps {
4482/**
4583 * The repeat + end controls for a scheduled task, rendered as a body section
4684 * below the prompt: a "Recurring" {@link Switch} that toggles a one-time launch
47- * into a repeat, and — once on — the frequency preset and how it ends (never, on
48- * a date, or after N runs).
85+ * into a repeat, and — once on — the frequency preset, its cadence detail (the
86+ * weekly day toggles or the monthly anchor), and how it ends (never, on a date,
87+ * or after N runs).
4988 *
5089 * Composed as a sibling between the prompt body and footer; it owns its own
5190 * leading separator and mirrors {@link ChipModalBody}'s spacing
@@ -66,12 +105,14 @@ export function RecurrenceSection({ recurrence, onChange, launchDate }: Recurren
66105
67106 const launch = new Date ( `${ launchDate } T00:00` )
68107 const isRecurring = recurrence . frequency !== 'once'
108+ const selectedWeekdays = recurrence . weekdays . length > 0 ? recurrence . weekdays : [ launch . getDay ( ) ]
69109
70110 const frequencyOptions = [
71111 { value : 'daily' , label : 'Daily' } ,
72- { value : 'weekly' , label : ` Weekly on ${ format ( launch , 'EEE' ) } ` } ,
112+ { value : 'weekly' , label : ' Weekly' } ,
73113 { value : 'weekdays' , label : 'Weekdays' } ,
74- { value : 'monthly' , label : `Monthly on the ${ format ( launch , 'do' ) } ` } ,
114+ { value : 'monthly' , label : 'Monthly' } ,
115+ { value : 'yearly' , label : `Yearly on ${ format ( launch , 'MMM d' ) } ` } ,
75116 ...( recurrence . frequency === 'custom' ? [ { value : 'custom' , label : 'Custom' } ] : [ ] ) ,
76117 ]
77118
@@ -109,13 +150,32 @@ export function RecurrenceSection({ recurrence, onChange, launchDate }: Recurren
109150 } )
110151 return
111152 case 'monthly' :
112- onChange ( { ...recurrence , frequency : 'monthly' , weekdays : [ ] , cron : undefined } )
153+ onChange ( {
154+ ...recurrence ,
155+ frequency : 'monthly' ,
156+ weekdays : [ ] ,
157+ monthlyMode : 'day-of-month' ,
158+ cron : undefined ,
159+ } )
160+ return
161+ case 'yearly' :
162+ onChange ( { ...recurrence , frequency : 'yearly' , weekdays : [ ] , cron : undefined } )
113163 return
114164 case 'custom' :
115165 onChange ( { ...recurrence , frequency : 'custom' } )
116166 }
117167 }
118168
169+ /** Toggles a weekday on or off, never letting the last selected day be cleared. */
170+ const handleWeekdayToggle = ( day : number ) => {
171+ const isSelected = selectedWeekdays . includes ( day )
172+ if ( isSelected && selectedWeekdays . length === 1 ) return
173+ const weekdays = isSelected
174+ ? selectedWeekdays . filter ( ( d ) => d !== day )
175+ : [ ...selectedWeekdays , day ] . sort ( ( a , b ) => a - b )
176+ onChange ( { ...recurrence , weekdays } )
177+ }
178+
119179 const handleEndChange = ( value : string ) => {
120180 if ( value === 'never' ) onChange ( { ...recurrence , end : { type : 'never' } } )
121181 else if ( value === 'on' )
@@ -144,6 +204,39 @@ export function RecurrenceSection({ recurrence, onChange, launchDate }: Recurren
144204 onChange = { handleFrequencyChange }
145205 />
146206
207+ { recurrence . frequency === 'weekly' && (
208+ < ChipModalField type = 'custom' title = 'Repeat on' >
209+ < div className = 'flex gap-1' >
210+ { WEEKDAYS . map ( ( weekday ) => {
211+ const selected = selectedWeekdays . includes ( weekday . value )
212+ return (
213+ < Chip
214+ key = { weekday . value }
215+ active = { selected }
216+ flush
217+ className = 'min-w-0 flex-1 justify-center'
218+ aria-pressed = { selected }
219+ aria-label = { weekday . name }
220+ onClick = { ( ) => handleWeekdayToggle ( weekday . value ) }
221+ >
222+ { weekday . short }
223+ </ Chip >
224+ )
225+ } ) }
226+ </ div >
227+ </ ChipModalField >
228+ ) }
229+
230+ { recurrence . frequency === 'monthly' && (
231+ < ChipModalField
232+ type = 'dropdown'
233+ title = 'On'
234+ value = { recurrence . monthlyMode ?? 'day-of-month' }
235+ options = { monthlyModeOptions ( launch ) }
236+ onChange = { ( value ) => onChange ( { ...recurrence , monthlyMode : value as MonthlyMode } ) }
237+ />
238+ ) }
239+
147240 < ChipModalField
148241 type = 'dropdown'
149242 title = 'Ends'
0 commit comments