import type { Calendar, Event } from '@nylas-labs/cli-kit/v3' import { createFileRoute, useNavigate } from '@tanstack/react-router' import { Check, ChevronLeft, ChevronRight, Menu, Plus, Settings2 } from 'lucide-react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { AppRailLogo, AppRailMobileNav, AppRailNav } from '#app/components/AppRail' import { CommandPalette, useCommandPaletteShortcut } from '#app/components/CommandPalette' import { MobileTabBar } from '#app/components/MobileTabBar' import { CALENDAR_HEADER_GRID_CLASS, CALENDAR_SIDEBAR_WIDTH_CLASS, CHROME_ROW_CLASS, CHROME_ROW_SHELL_CLASS, } from '#app/config/layout' import { useUserPreferences } from '#app/preferences/user-preferences' import { CalendarManagerDialog } from '#features/calendar/components/CalendarManagerDialog' import { EventModal } from '#features/calendar/components/EventModal' import { addDays, allDayEventSegments, type CalView, calendarDateInTimeZone, calendarKeyAction, calendarSlotTime, calendarWallClockHour, eventsOnDay, eventTimes, filterEventsByCalendars, fmtAgendaTime, fmtTime, isCalendarDate, isCalView, isNewEventPreview, moveCalendarDay, shiftAnchor, startOfWeek, timedEventLayout, timedEventsOnDay, viewRange, ymd, } from '#features/calendar/lib/calendar' import { calendarTone, eventTone } from '#features/calendar/lib/calendar-ui-model' import { type CalendarRouteData, loadCalendarRouteData, useCalendarRouteData, } from '#features/calendar/state/calendar-state' import { PullToRefresh, RefreshButton } from '#shared/components/PullToRefresh' import { Sheet } from '#shared/components/Sheet' import { ScrollArea } from '#shared/components/ui/scroll-area' import { Tooltip, TooltipContent, TooltipTrigger } from '#shared/components/ui/tooltip' import { type EventTone, eventChipClass, eventColorClass } from '#shared/lib/color-tone' import type { Rect } from '#shared/lib/modal-position' import { cn } from '#shared/lib/utils' export const Route = createFileRoute('/calendar/$view')({ params: { parse: (params) => { if (!isCalView(params.view)) throw new Error(`Unknown view: ${params.view}`) return { view: params.view as CalView } }, }, validateSearch: (search): { date?: string } => (isCalendarDate(search.date) ? { date: search.date } : {}), loaderDeps: ({ search }) => ({ date: search.date }), loader: async ({ params, deps }) => loadCalendarRouteData(params.view, deps.date), component: CalendarViewRoutePage, }) export { loadCalendarRouteData } function CalendarViewRoutePage() { const { view } = Route.useParams() const { date } = Route.useSearch() const initialData = Route.useLoaderData() const calendarQuery = useCalendarRouteData(view, date, initialData) return ( calendarQuery.refetch({ throwOnError: true }).then(() => undefined)} /> ) } export function CalendarRouteScreen({ view, data, onRefresh, }: { view: CalView data: CalendarRouteData onRefresh?: () => Promise }) { const { events, calendar, calendars, info, anchorIso } = data const navigate = useNavigate() const [editing, setEditing] = useState(null) const [newStart, setNewStart] = useState(null) const [newStartIsSlot, setNewStartIsSlot] = useState(false) const [composerAnchor, setComposerAnchor] = useState(null) const [eventPreview, setEventPreview] = useState(null) const [hiddenCalendarIds, setHiddenCalendarIds] = useState>(new Set()) const [sidebarOpen, setSidebarOpen] = useState(false) const [paletteOpen, setPaletteOpen] = useState(false) const [managingCalendars, setManagingCalendars] = useState(false) const [preferences] = useUserPreferences() const primaryTimezone = preferences.primaryTimezone const secondaryTimezone = preferences.secondaryTimezone const today = useMemo(() => calendarDateInTimeZone(new Date(), primaryTimezone), [primaryTimezone]) const openPalette = useCallback(() => setPaletteOpen(true), []) const closePalette = useCallback(() => setPaletteOpen(false), []) useCommandPaletteShortcut(openPalette) const currentView = view const currentAnchorIso = anchorIso const anchor = useMemo(() => new Date(`${currentAnchorIso}T00:00:00`), [currentAnchorIso]) const visibleEvents = useMemo( () => filterEventsByCalendars(eventPreview ? [...events, eventPreview] : events, hiddenCalendarIds), [events, eventPreview, hiddenCalendarIds], ) const calendarNameById = useMemo( () => new Map(calendars.map((cal) => [cal.id, cal.name || 'Calendar'])), [calendars], ) const calendarById = useMemo(() => new Map(calendars.map((cal) => [cal.id, cal])), [calendars]) const agenda = useMemo( () => timedEventsOnDay(visibleEvents, today, primaryTimezone) .filter((event) => !isNewEventPreview(event)) .sort((a, b) => { const aTimes = eventTimes(a) const bTimes = eventTimes(b) /* v8 ignore next -- timedEventsOnDay only returns events with parsed times -- @preserve */ if (!aTimes || !bTimes) return 0 return aTimes.start.getTime() - bTimes.start.getTime() }) .slice(0, 5), [today, visibleEvents, primaryTimezone], ) const toggleCalendar = useCallback((calendarId: string) => { setHiddenCalendarIds((current) => { const next = new Set(current) if (next.has(calendarId)) next.delete(calendarId) else next.add(calendarId) return next }) }, []) const go = useCallback( (nextView: CalView, nextAnchor: Date) => { navigate({ to: '/calendar/$view', params: { view: nextView }, search: { date: ymd(nextAnchor) } }) }, [navigate], ) useEffect(() => { function onKeyDown(event: KeyboardEvent) { const target = event.target as HTMLElement | null const isTyping = target?.tagName === 'INPUT' || target?.tagName === 'TEXTAREA' || target?.isContentEditable if (isTyping || event.repeat || event.metaKey || event.ctrlKey || event.altKey) return if (target?.closest?.('[role="dialog"], button, a, select, [role="grid"]')) return const action = calendarKeyAction(event.key) if (!action) return event.preventDefault() if (action.kind === 'view') go(action.view, anchor) else if (action.kind === 'shift') go(currentView, shiftAnchor(currentView, anchor, action.direction)) else if (action.kind === 'today') go(currentView, calendarDateInTimeZone(new Date(), primaryTimezone)) else { setNewStart(null) setNewStartIsSlot(false) setComposerAnchor(null) setEditing('new') } } window.addEventListener('keydown', onKeyDown) return () => window.removeEventListener('keydown', onKeyDown) }, [anchor, currentView, go, primaryTimezone]) const title = currentView === 'month' ? anchor.toLocaleDateString(undefined, { month: 'long', year: 'numeric' }) : currentView === 'week' ? formatWeekTitle(anchor) : anchor.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' }) return (
{currentView === 'month' ? ( go('day', d)} onPickEvent={setEditing} timeZone={primaryTimezone} /> ) : ( { setNewStart(calendarSlotTime(date, hour, primaryTimezone)) setNewStartIsSlot(true) setComposerAnchor(rect) setEditing('new') }} /> )}
{editing ? ( { setEventPreview(null) setEditing(null) }} /> ) : null} setSidebarOpen(false)} title="Navigation" hideAt="lg"> setSidebarOpen(false)} showDestinations={false} />
{onRefresh ? (
Refresh calendar
) : null} { go(currentView === 'month' ? 'day' : currentView, date) setSidebarOpen(false) }} onToggleCalendar={toggleCalendar} onPickEvent={(event) => { setEditing(event) setSidebarOpen(false) }} onManageCalendars={() => setManagingCalendars(true)} />
{managingCalendars ? ( setManagingCalendars(false)} onDeleted={(calendarId) => { setHiddenCalendarIds((current) => { const next = new Set(current) next.delete(calendarId) return next }) }} /> ) : null}
) } function CalendarSidebarPanel({ anchor, calendars, calendarById, hiddenCalendarIds, agenda, timeZone, onPickDate, onToggleCalendar, onPickEvent, onManageCalendars, }: { anchor: Date calendars: Calendar[] calendarById: Map hiddenCalendarIds: Set agenda: Event[] timeZone: string onPickDate: (date: Date) => void onToggleCalendar: (calendarId: string) => void onPickEvent: (event: Event) => void onManageCalendars: () => void }) { return (

My calendars

{calendars.map((cal, index) => { const hidden = hiddenCalendarIds.has(cal.id) const tone = calendarTone(cal, index) return ( ) })}

Up next today

{agenda.length === 0 ? (

Nothing left today.

) : ( agenda.slice(0, 4).map((event, index) => { const times = eventTimes(event) /* v8 ignore next -- visibleEvents only contains runtime-validated events -- @preserve */ if (!times) return null return ( ) }) )}
) } function eventBarClass(tone: EventTone): string { return eventColorClass(tone, 'bg') } function eventDotClass(tone: EventTone): string { return eventColorClass(tone, 'bg') } function formatWeekTitle(anchor: Date): string { const start = startOfWeek(anchor) const end = addDays(start, 6) const sameMonth = start.getMonth() === end.getMonth() const sameYear = start.getFullYear() === end.getFullYear() if (sameMonth && sameYear) { return `${start.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} – ${end.getDate()}, ${end.getFullYear()}` } if (sameYear) { return `${start.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} – ${end.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}` } return `${start.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })} – ${end.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}` } /* v8 ignore start -- grid movement is unit-tested in moveCalendarDay; pointer rendering is covered separately -- @preserve */ function MiniCalendar({ refDate, onPick }: { refDate: Date; onPick: (date: Date) => void }) { const [cursor, setCursor] = useState(() => new Date(refDate.getFullYear(), refDate.getMonth(), 1)) const [activeDay, setActiveDay] = useState(() => new Date(refDate)) useEffect(() => setActiveDay(new Date(refDate)), [refDate]) const { start, end } = viewRange('month', cursor) const days: Date[] = [] for (let day = new Date(start); day < end; day = addDays(day, 1)) days.push(new Date(day)) const todayIso = ymd(new Date()) const refIso = ymd(refDate) return (
{cursor.toLocaleDateString(undefined, { month: 'long', year: 'numeric' })}
{/* biome-ignore lint/a11y/useSemanticElements: The date picker uses ARIA grid keyboard navigation with roving tab stops. */}
{[ ['sun', 'S'], ['mon', 'M'], ['tue', 'T'], ['wed', 'W'], ['thu', 'T'], ['fri', 'F'], ['sat', 'S'], ].map(([key, label]) => ( {label} ))} {days.map((day) => { const inMonth = day.getMonth() === cursor.getMonth() const iso = ymd(day) return ( ) })}
) } function MonthGrid({ anchor, events, calendarById, timeZone, onPickDay, onPickEvent, onRefresh, }: { anchor: Date events: Event[] calendarById: Map timeZone: string onPickDay: (d: Date) => void onPickEvent: (e: Event) => void onRefresh?: () => Promise }) { const { start, end } = viewRange('month', anchor) const days: Date[] = [] for (let d = new Date(start); d < end; d = addDays(d, 1)) days.push(new Date(d)) const weeks = Array.from({ length: 6 }, (_, index) => days.slice(index * 7, index * 7 + 7)) const visibleDayIds = new Set(days.map(ymd)) const todayIso = ymd(calendarDateInTimeZone(new Date(), timeZone)) const [activeDay, setActiveDay] = useState(() => new Date(anchor)) useEffect(() => setActiveDay(new Date(anchor)), [anchor]) const monthGrid = ( /* biome-ignore lint/a11y/noNoninteractiveElementToInteractiveRole: The native table structure provides the required row and cell ownership for this interactive ARIA grid. */ {['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((label) => ( ))} {weeks.map((week) => ( {week.map((day) => { const inMonth = day.getMonth() === anchor.getMonth() const dayEvents = eventsOnDay(events, day, timeZone) const iso = ymd(day) return ( ) })} ))}
{label}
onPickDay(day)} onFocus={() => setActiveDay(day)} onKeyDown={(event) => { if (event.target !== event.currentTarget) return if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() onPickDay(day) return } const next = moveCalendarDay(day, event.key) if (!next) return event.preventDefault() if (!visibleDayIds.has(ymd(next))) return setActiveDay(next) requestAnimationFrame(() => document .querySelector(`[data-month-calendar-day="${ymd(next)}"]`) ?.focus(), ) }} // biome-ignore lint/a11y/noNoninteractiveElementToInteractiveRole: This focusable table cell is an interactive day in the ARIA grid. role="gridcell" aria-label={day.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric', })} aria-selected={ymd(day) === ymd(activeDay)} tabIndex={ymd(day) === ymd(activeDay) ? 0 : -1} data-month-calendar-day={iso} className={cn( 'group relative flex min-h-0 cursor-pointer flex-col gap-1 border-r border-b border-border p-1.5 transition-colors hover:bg-muted/40', !inMonth && 'bg-muted/30', )} >
{day.getDate()}
{dayEvents.slice(0, 3).map((event, index) => { const times = eventTimes(event) /* v8 ignore next -- eventsOnDay excludes records without parsed times -- @preserve */ if (!times) return null const tone = eventTone(event, index, calendarById.get(event.calendar_id)) const allDay = times.allDay const preview = isNewEventPreview(event) return ( ) })} {dayEvents.length > 3 ? ( +{dayEvents.length - 3} more ) : null}
) return onRefresh ? ( {monthGrid} ) : ( monthGrid ) } function TimeGrid({ days, start, events, calendarById, timeZone, secondaryTimezone, onPickEvent, onPickSlot, onRefresh, }: { days: number start: Date events: Event[] calendarById: Map timeZone: string secondaryTimezone: string onPickEvent: (e: Event) => void onPickSlot: (date: Date, hour: number, rect: Rect) => void onRefresh?: () => Promise }) { const HOUR_PX = 52 // Render the full day so selections made in the event composer always // remain visible after the calendar refreshes. const START_HOUR = 0 // Midnight belongs at the top of each calendar day; 24:00 remains only as // the end boundary for layouts that run through the end of the day. const END_HOUR = 24 const LAST_SLOT_HOUR = END_HOUR - 1 const GRID_END_HOUR = END_HOUR const HOURS = Array.from({ length: END_HOUR - START_HOUR }, (_, i) => START_HOUR + i) const columns: Date[] = Array.from({ length: days }, (_, i) => addDays(start, i)) const todayIso = ymd(calendarDateInTimeZone(new Date(), timeZone)) const scrollRef = useRef(null) const [nowOffset, setNowOffset] = useState(null) const [activeSlot, setActiveSlot] = useState({ day: 0, hour: START_HOUR }) const allDaySegments = allDayEventSegments(events, columns) const allDayRowCount = Math.max(1, ...allDaySegments.map((segment) => segment.row + 1)) const hasAllDay = allDaySegments.length > 0 const dayGridTemplateColumns = days === 1 ? '3.5rem minmax(0, 1fr)' : '3.5rem repeat(7, minmax(0, 1fr))' useEffect(() => { function updateNowOffset() { const current = new Date() const hour = calendarWallClockHour(current, timeZone) setNowOffset((hour - START_HOUR) * HOUR_PX) } updateNowOffset() const id = setInterval(updateNowOffset, 60_000) return () => clearInterval(id) }, [timeZone]) useEffect(() => { if (scrollRef.current) scrollRef.current.scrollTop = Math.max(0, (8 - START_HOUR) * HOUR_PX - 12) }, []) function moveSlot(dayIndex: number, hour: number, key: string) { let nextDay = dayIndex let nextHour = hour if (key === 'ArrowLeft') nextDay = Math.max(0, dayIndex - 1) else if (key === 'ArrowRight') nextDay = Math.min(days - 1, dayIndex + 1) else if (key === 'ArrowUp') nextHour = Math.max(START_HOUR, hour - 1) else if (key === 'ArrowDown') nextHour = Math.min(LAST_SLOT_HOUR, hour + 1) else if (key === 'Home') nextHour = START_HOUR else if (key === 'End') nextHour = LAST_SLOT_HOUR else return setActiveSlot({ day: nextDay, hour: nextHour }) requestAnimationFrame(() => document.querySelector(`[data-calendar-slot="${nextDay}-${nextHour}"]`)?.focus(), ) } const timeGrid = (
1 ? 'max-sm:overflow-x-auto' : undefined} className="isolate min-h-0 flex-1" >
1 && 'max-sm:min-w-[54rem]')} data-testid="calendar-time-grid-header" >
{timezoneShortLabel(timeZone)} {secondaryTimezone ? ( {timezoneShortLabel(secondaryTimezone)} ) : null}
{columns.map((day, dayIndex) => (
{day.toLocaleDateString(undefined, { weekday: 'short' })} {day.getDate()}
))}
{hasAllDay ? (
All day
{columns.map((day, dayIndex) => ( ) : null}
1 && 'max-sm:min-w-[54rem]')} data-testid="calendar-time-grid-body" >
{HOURS.map((hour) => (
{hour === START_HOUR ? '' : fmtHour(hour)} {secondaryTimezone && hour !== START_HOUR ? ( {fmtTime(calendarSlotTime(columns[0] ?? start, hour, timeZone), secondaryTimezone)} ) : null}
))}
{columns.map((day, dayIndex) => { const dayEvents = eventsOnDay(events, day, timeZone).filter( (event) => eventTimes(event)?.allDay === false, ) const isToday = ymd(day) === todayIso return (
{HOURS.map((hour) => ( ) })}
) })}
) return onRefresh ? ( {timeGrid} ) : ( timeGrid ) } function ContinuousDayColumnRules({ days, gridTemplateColumns, }: { days: number gridTemplateColumns: string }) { if (days <= 1) return null const ruleGridColumns = Array.from({ length: days - 1 }, (_, offset) => offset + 3) return (