1- import { ChevronDown , CircleAlert , Clock3 , Loader2 } from "lucide-react" ;
2- import { useLayoutEffect , useMemo , useRef , useState } from "react" ;
1+ import { CircleAlert , Loader2 , X } from "lucide-react" ;
2+ import { useId , useLayoutEffect , useMemo , useRef , useState } from "react" ;
3+ import type { ReactNode } from "react" ;
34
45import { Button } from "@/components/ui/button" ;
56import { formControlFocusClassName } from "@/components/ui/form-control" ;
7+ import { Popover , PopoverContent , PopoverTrigger } from "@/components/ui/popover" ;
68import type { SessionAutomationJob } from "@/lib/types" ;
79import { cn } from "@/lib/utils" ;
810
@@ -24,17 +26,16 @@ type CalendarCopy = {
2426 running : string ;
2527 failed : string ;
2628 more : ( count : number ) => string ;
29+ close : string ;
2730 noEntries : string ;
28- outsideMonth : string ;
29- paused : string ;
30- attention : string ;
3131 completed : string ;
3232} ;
3333
3434interface AutomationCalendarProps {
3535 jobs : SessionAutomationJob [ ] ;
3636 locale : string ;
3737 copy : CalendarCopy ;
38+ filters ?: ReactNode ;
3839 onInspect : ( job : SessionAutomationJob , trigger : HTMLElement ) => void ;
3940}
4041
@@ -173,7 +174,72 @@ function CalendarEntryRow({ entry, locale, copy, onInspect, compact = false }: {
173174 ) ;
174175}
175176
176- export function AutomationCalendar ( { jobs, locale, copy, onInspect } : AutomationCalendarProps ) {
177+ function CalendarOverflowEntries ( { entries, dayLabel, locale, copy, onInspect } : {
178+ entries : CalendarEntry [ ] ;
179+ dayLabel : string ;
180+ locale : string ;
181+ copy : CalendarCopy ;
182+ onInspect : AutomationCalendarProps [ "onInspect" ] ;
183+ } ) {
184+ const [ open , setOpen ] = useState ( false ) ;
185+ const titleId = useId ( ) ;
186+ const triggerRef = useRef < HTMLButtonElement | null > ( null ) ;
187+ const pendingSelection = useRef < SessionAutomationJob | null > ( null ) ;
188+
189+ return (
190+ < Popover open = { open } onOpenChange = { setOpen } >
191+ < PopoverTrigger asChild >
192+ < button
193+ ref = { triggerRef }
194+ type = "button"
195+ className = { cn (
196+ "min-h-6 w-full rounded-compact pe-2 ps-[1.375rem] py-1 text-left text-[10px] text-muted-foreground transition-colors duration-150 hover:bg-foreground/[0.055] hover:text-foreground motion-reduce:transition-none" ,
197+ formControlFocusClassName ,
198+ ) }
199+ >
200+ { copy . more ( entries . length - 3 ) }
201+ </ button >
202+ </ PopoverTrigger >
203+ < PopoverContent
204+ align = "start"
205+ side = "right"
206+ collisionPadding = { 16 }
207+ aria-labelledby = { titleId }
208+ className = "w-80 max-w-[calc(100vw-2rem)] overscroll-contain p-2"
209+ onCloseAutoFocus = { ( event ) => {
210+ const job = pendingSelection . current ;
211+ pendingSelection . current = null ;
212+ if ( ! job || ! triggerRef . current ?. isConnected ) return ;
213+ // Hand off after the day popover exits, retaining a mounted return target.
214+ event . preventDefault ( ) ;
215+ onInspect ( job , triggerRef . current ) ;
216+ } }
217+ >
218+ < div className = "mb-1 flex items-center gap-2 px-2 py-1" >
219+ < h3 id = { titleId } className = "min-w-0 flex-1 text-[13px] font-semibold" > { dayLabel } </ h3 >
220+ < Button type = "button" variant = "ghost" size = "icon" className = "h-8 w-8 shrink-0" aria-label = { copy . close } onClick = { ( ) => setOpen ( false ) } >
221+ < X className = "h-4 w-4" aria-hidden />
222+ </ Button >
223+ </ div >
224+ { entries . map ( ( entry ) => (
225+ < CalendarEntryRow
226+ key = { entry . id }
227+ entry = { entry }
228+ locale = { locale }
229+ copy = { copy }
230+ onInspect = { ( job ) => {
231+ pendingSelection . current = job ;
232+ setOpen ( false ) ;
233+ } }
234+ compact
235+ />
236+ ) ) }
237+ </ PopoverContent >
238+ </ Popover >
239+ ) ;
240+ }
241+
242+ export function AutomationCalendar ( { jobs, locale, copy, filters, onInspect } : AutomationCalendarProps ) {
177243 const [ month , setMonth ] = useState ( ( ) => startOfMonth ( new Date ( ) ) ) ;
178244 const calendarRef = useRef < HTMLElement | null > ( null ) ;
179245 const [ wideCalendar , setWideCalendar ] = useState ( true ) ;
@@ -190,16 +256,12 @@ export function AutomationCalendar({ jobs, locale, copy, onInspect }: Automation
190256 const key = dateKey ( entry . startMs ) ;
191257 entriesByDay . set ( key , [ ...( entriesByDay . get ( key ) ?? [ ] ) , entry ] ) ;
192258 } ) ;
193- const visibleJobIds = new Set ( visibleEntries . map ( ( entry ) => entry . job . id ) ) ;
194- const outsideJobs = jobs . filter ( ( job ) => ! visibleJobIds . has ( job . id ) ) ;
195259 const monthLabel = new Intl . DateTimeFormat ( locale , { month : "long" , year : "numeric" } ) . format ( month ) ;
196260 const weekdayFormatter = new Intl . DateTimeFormat ( locale , { weekday : "short" } ) ;
197261 const dayFormatter = new Intl . DateTimeFormat ( locale , { day : "numeric" } ) ;
198262 const agendaDateFormatter = new Intl . DateTimeFormat ( locale , { month : "short" , day : "numeric" , weekday : "short" } ) ;
199263 const weekdayLabels = Array . from ( { length : 7 } , ( _ , index ) => weekdayFormatter . format ( addDays ( new Date ( 2026 , 0 , 5 ) , index ) ) ) ;
200264
201- const inspectOutsideJob = ( job : SessionAutomationJob , target : HTMLElement ) => onInspect ( job , target ) ;
202-
203265 useLayoutEffect ( ( ) => {
204266 const calendar = calendarRef . current ;
205267 if ( ! calendar ) return ;
@@ -214,52 +276,50 @@ export function AutomationCalendar({ jobs, locale, copy, onInspect }: Automation
214276 } , [ ] ) ;
215277
216278 return (
217- < section ref = { calendarRef } className = "automation-calendar overflow-hidden rounded-panel border border-border/70 bg-[hsl(var(--settings-surface))]" >
218- < div className = "flex min-h-14 flex-wrap items-center gap-x-4 gap-y-2 px-4 py-2.5 sm:px-6" >
219- < h2 className = "min-w-0 flex-1 text-[15px] font-semibold text-foreground" > { monthLabel } </ h2 >
220- < div className = "flex items-center gap-1" >
221- < Button
222- type = "button"
223- variant = "outline"
224- size = "sm"
225- className = "h-8 rounded-control px-3 text-[12px]"
226- onClick = { ( ) => setMonth ( startOfMonth ( new Date ( ) ) ) }
227- >
228- { copy . today }
229- </ Button >
230- < Button
231- type = "button"
232- variant = "ghost"
233- size = "icon"
234- className = "h-8 w-8 text-muted-foreground"
235- aria-label = { copy . previousMonth }
236- onClick = { ( ) => setMonth ( ( value ) => addMonths ( value , - 1 ) ) }
237- >
238- < span className = "text-lg leading-none" aria-hidden > ‹</ span >
239- </ Button >
240- < Button
241- type = "button"
242- variant = "ghost"
243- size = "icon"
244- className = "h-8 w-8 text-muted-foreground"
245- aria-label = { copy . nextMonth }
246- onClick = { ( ) => setMonth ( ( value ) => addMonths ( value , 1 ) ) }
247- >
248- < span className = "text-lg leading-none" aria-hidden > ›</ span >
249- </ Button >
279+ < section ref = { calendarRef } className = "automation-calendar overflow-hidden rounded-panel bg-[hsl(var(--settings-surface))]" >
280+ < div className = "automation-calendar-header bg-foreground/[0.025]" >
281+ < div className = "automation-calendar-toolbar" >
282+ < h2 className = "min-w-0 text-[15px] font-semibold text-foreground" > { monthLabel } </ h2 >
283+ { filters ? < div className = "automation-calendar-filters min-w-0" > { filters } </ div > : null }
284+ < div className = "automation-calendar-navigation flex items-center gap-1" >
285+ < Button
286+ type = "button"
287+ variant = "outline"
288+ size = "sm"
289+ className = "h-8 rounded-control px-3 text-[12px]"
290+ onClick = { ( ) => setMonth ( startOfMonth ( new Date ( ) ) ) }
291+ >
292+ { copy . today }
293+ </ Button >
294+ < Button
295+ type = "button"
296+ variant = "ghost"
297+ size = "icon"
298+ className = "h-8 w-8 text-muted-foreground"
299+ aria-label = { copy . previousMonth }
300+ onClick = { ( ) => setMonth ( ( value ) => addMonths ( value , - 1 ) ) }
301+ >
302+ < span className = "text-lg leading-none" aria-hidden > ‹</ span >
303+ </ Button >
304+ < Button
305+ type = "button"
306+ variant = "ghost"
307+ size = "icon"
308+ className = "h-8 w-8 text-muted-foreground"
309+ aria-label = { copy . nextMonth }
310+ onClick = { ( ) => setMonth ( ( value ) => addMonths ( value , 1 ) ) }
311+ >
312+ < span className = "text-lg leading-none" aria-hidden > ›</ span >
313+ </ Button >
314+ </ div >
250315 </ div >
251- </ div >
252-
253- { ! jobs . length ? (
254- < div className = "flex min-h-48 items-center justify-center px-6 text-center text-[13px] text-muted-foreground" >
255- { copy . noEntries }
256- </ div >
257- ) : null }
258316
259- { jobs . length > 0 && wideCalendar ? < div className = "automation-calendar-grid" aria-label = { monthLabel } >
260- < div className = "automation-calendar-weekdays" aria-hidden >
317+ { wideCalendar ? < div className = "automation-calendar-weekdays" aria-hidden >
261318 { weekdayLabels . map ( ( label ) => < div key = { label } > { label } </ div > ) }
262- </ div >
319+ </ div > : null }
320+ </ div >
321+
322+ { wideCalendar ? < div className = "automation-calendar-grid" aria-label = { monthLabel } >
263323 < div className = "automation-calendar-month" >
264324 { days . map ( ( day ) => {
265325 const entries = entriesByDay . get ( dateKey ( day ) ) ?? [ ] ;
@@ -295,25 +355,13 @@ export function AutomationCalendar({ jobs, locale, copy, onInspect }: Automation
295355 />
296356 ) ) }
297357 { entries . length > 3 ? (
298- < details className = "group/more" >
299- < summary className = { cn (
300- "cursor-pointer list-none rounded-control px-2 py-1 text-[10px] text-muted-foreground settings-hover [&::-webkit-details-marker]:hidden" ,
301- formControlFocusClassName ,
302- ) } >
303- { copy . more ( entries . length - 3 ) }
304- </ summary >
305- < div className = "mt-0.5 space-y-0.5" >
306- { entries . slice ( 3 ) . map ( ( entry ) => (
307- < CalendarEntryRow
308- key = { entry . id }
309- entry = { entry }
310- locale = { locale }
311- copy = { copy }
312- onInspect = { onInspect }
313- />
314- ) ) }
315- </ div >
316- </ details >
358+ < CalendarOverflowEntries
359+ entries = { entries }
360+ dayLabel = { dayLabel }
361+ locale = { locale }
362+ copy = { copy }
363+ onInspect = { onInspect }
364+ />
317365 ) : null }
318366 </ div >
319367 </ div >
@@ -322,7 +370,7 @@ export function AutomationCalendar({ jobs, locale, copy, onInspect }: Automation
322370 </ div >
323371 </ div > : null }
324372
325- { jobs . length > 0 && ! wideCalendar ? < div className = "automation-calendar-agenda px-3 py-2 sm:px-4" >
373+ { ! wideCalendar ? < div className = "automation-calendar-agenda px-3 py-2 sm:px-4" >
326374 { visibleEntries . length ? (
327375 < ol >
328376 { days . filter ( ( day ) => entriesByDay . has ( dateKey ( day ) ) ) . map ( ( day ) => (
@@ -363,47 +411,6 @@ export function AutomationCalendar({ jobs, locale, copy, onInspect }: Automation
363411 </ div >
364412 ) : null }
365413
366- { outsideJobs . length ? (
367- < details
368- key = { `${ month . getTime ( ) } :${ visibleEntries . length ? "mixed" : "outside-only" } ` }
369- className = "group"
370- open = { ! visibleEntries . length || undefined }
371- >
372- < summary className = { cn ( "automation-meta-row min-h-11 cursor-pointer list-none items-center text-[12px] text-muted-foreground settings-hover [&::-webkit-details-marker]:hidden" , formControlFocusClassName ) } >
373- < Clock3 className = "h-3.5 w-3.5 place-self-center" aria-hidden />
374- < span className = "flex min-w-0 items-center gap-2" >
375- < span className = "truncate" > { copy . outsideMonth } </ span >
376- < span className = "tabular-nums text-muted-foreground/65" > { outsideJobs . length } </ span >
377- </ span >
378- < ChevronDown className = "h-4 w-4 transition-transform group-open:rotate-180 motion-reduce:transition-none" aria-hidden />
379- </ summary >
380- < ul className = "px-4 pb-2 sm:px-6" >
381- { outsideJobs . map ( ( job ) => (
382- < li key = { job . id } >
383- < button
384- type = "button"
385- className = { cn ( "grid min-h-10 w-full grid-cols-[1rem_minmax(0,1fr)_auto] items-center gap-x-2 rounded-control text-left text-[12px] settings-hover" , formControlFocusClassName ) }
386- aria-haspopup = "dialog"
387- onClick = { ( event ) => inspectOutsideJob ( job , event . currentTarget ) }
388- >
389- < span className = "h-4 w-4" aria-hidden />
390- < span className = "truncate font-medium text-foreground" > { job . name || job . id } </ span >
391- < span className = "flex shrink-0 flex-wrap justify-end gap-x-2 gap-y-0.5 text-[11px] text-muted-foreground" >
392- { ( job . state . last_status === "error" || job . state . last_error )
393- ? < span > { copy . attention } </ span > : null }
394- { ! job . enabled ? < span > { copy . paused } </ span > : null }
395- { job . enabled && job . delete_after_run && job . state . last_status === "ok"
396- ? < span > { copy . completed } </ span > : null }
397- { job . enabled && ! ( job . delete_after_run && job . state . last_status === "ok" )
398- && job . state . last_status !== "error" && ! job . state . last_error
399- ? < span > { copy . outsideMonth } </ span > : null }
400- </ span >
401- </ button >
402- </ li >
403- ) ) }
404- </ ul >
405- </ details >
406- ) : null }
407414 </ section >
408415 ) ;
409416}
0 commit comments