chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,60 @@
'use client'
import { chipContentGap, chipPrimaryFillTokens, cn } from '@sim/emcn'
import { format } from 'date-fns'
import type {
CalendarEvent,
ScheduledTask,
} from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events'
interface CalendarEventChipProps {
event: CalendarEvent
onSelect: (task: ScheduledTask) => void
/** Right-click — open the task's context menu at the cursor. */
onContextMenu: (task: ScheduledTask, e: React.MouseEvent) => void
/** Layout/sizing only (`w-full`, `min-w-0 flex-1`); chrome lives here. */
className?: string
}
/**
* Compact task pill rendered inside a month day cell or a time-grid slot — the
* one leaf shared by both grids. Every task renders identically regardless of
* status — plaintext start time + title, no icons or status colors; the
* details modal carries the state. The pill is the grid's real `<button>` (its
* parent cells are plain clickable `<div>`s), so tasks are the tab-reachable
* elements; clicks stop propagating so the cell underneath doesn't also open
* the create modal. A paused task (`task.disabled`) renders dimmed — the one
* status the pill signals visually, since a paused task can sit on the calendar
* indefinitely without running.
*/
export function CalendarEventChip({
event,
onSelect,
onContextMenu,
className,
}: CalendarEventChipProps) {
return (
<button
type='button'
onClick={(e) => {
e.stopPropagation()
onSelect(event.task)
}}
onContextMenu={(e) => {
e.preventDefault()
e.stopPropagation()
onContextMenu(event.task, e)
}}
className={cn(
'flex min-w-0 cursor-pointer items-center truncate rounded-md px-1.5 py-0.5 text-left text-caption outline-none transition-colors',
chipContentGap,
chipPrimaryFillTokens,
'hover-hover:bg-[var(--text-body)] dark:hover-hover:bg-[var(--text-secondary)]',
event.task.disabled && 'opacity-45',
className
)}
>
<span className='flex-shrink-0'>{format(event.start, 'h:mm a')}</span>
<span className='min-w-0 truncate'>{event.title}</span>
</button>
)
}
@@ -0,0 +1 @@
export { CalendarEventChip } from './calendar-event-chip'
@@ -0,0 +1,84 @@
'use client'
import {
Check,
Chip,
ChipDatePicker,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@sim/emcn'
import { format, parseISO } from 'date-fns'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import type { CalendarScope } from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/calendar-grid'
const SCOPE_OPTIONS: { value: CalendarScope; label: string }[] = [
{ value: 'day', label: 'Day' },
{ value: 'week', label: 'Week' },
{ value: 'month', label: 'Month' },
]
interface CalendarToolbarProps {
scope: CalendarScope
anchor: Date
label: string
onPrev: () => void
onNext: () => void
onToday: () => void
onSelectDate: (date: Date) => void
onScopeChange: (scope: CalendarScope) => void
}
/**
* Calendar ribbon: a "Today" jump and the period-label date picker on the left;
* the prev/next chevrons and the scope picker on the right. The controls are
* bare chips — the period label is a ghost `ChipDatePicker` that jumps the view
* to any picked date — and the scope picker is a `DropdownMenu`, matching the
* Filter/Sort menus on the resource options bar.
*/
export function CalendarToolbar({
scope,
anchor,
label,
onPrev,
onNext,
onToday,
onSelectDate,
onScopeChange,
}: CalendarToolbarProps) {
const scopeLabel = SCOPE_OPTIONS.find((option) => option.value === scope)?.label ?? 'Week'
return (
<div className='flex items-center justify-between border-[var(--border)] border-b px-4 py-2.5'>
<div className='flex items-center'>
<Chip onClick={onToday}>Today</Chip>
<ChipDatePicker
variant='ghost'
label={label}
value={format(anchor, 'yyyy-MM-dd')}
onChange={(value) => onSelectDate(parseISO(value))}
/>
</div>
<div className='flex items-center'>
<Chip leftIcon={ChevronLeft} aria-label='Previous' onClick={onPrev} />
<Chip leftIcon={ChevronRight} aria-label='Next' onClick={onNext} />
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Chip>{scopeLabel}</Chip>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' alignOffset={6}>
{SCOPE_OPTIONS.map((option) => (
<DropdownMenuItem key={option.value} onSelect={() => onScopeChange(option.value)}>
{option.label}
{option.value === scope && (
<Check className='ml-auto size-[12px] text-[var(--text-tertiary)]' />
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
)
}
@@ -0,0 +1 @@
export { CalendarToolbar } from './calendar-toolbar'
@@ -0,0 +1,4 @@
export { CalendarEventChip } from './calendar-event-chip'
export { CalendarToolbar } from './calendar-toolbar'
export { MonthGrid } from './month-grid'
export { TimeGrid } from './time-grid'
@@ -0,0 +1 @@
export { MonthGrid } from './month-grid'
@@ -0,0 +1,168 @@
'use client'
import { chipPrimaryFillTokens, cn } from '@sim/emcn'
import { format } from 'date-fns'
import { CalendarEventChip } from '@/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/calendar-event-chip'
import {
type CalendarDayCell,
type MonthGrid as MonthGridData,
WEEKDAY_LABELS,
} from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/calendar-grid'
import {
type CalendarEvent,
dayKey,
type ScheduledTask,
} from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events'
/**
* Lines of task content a month day cell shows before collapsing the rest
* behind a "N more" overflow line. Capping at a fixed line count keeps every
* cell's height contribution bounded no matter how many tasks pile onto a day.
*/
const MAX_DAY_EVENT_LINES = 3
interface MonthGridProps {
grid: MonthGridData
onSelectDay: (date: Date) => void
onSelectTask: (task: ScheduledTask) => void
/** A task pill was right-clicked — open its context menu at the cursor. */
onTaskContextMenu: (task: ScheduledTask, e: React.MouseEvent) => void
/** Drill into the day scope, where overflowing tasks have room to render. */
onShowDay: (date: Date) => void
eventsByDay?: Map<string, CalendarEvent[]>
}
/**
* One day in the month grid. Clicking empty space opens the create modal; the
* cell is a plain clickable `<div>` so the task pills inside can be real
* `<button>`s without nesting interactive elements. Tasks stack vertically up
* to {@link MAX_DAY_EVENT_LINES} lines — beyond that the last line becomes a
* plaintext "N more" that jumps to the day view.
*
* The day number carries a 1px optical inset (`ml-px`) on top of the shared
* `p-1.5` cell padding: digit glyphs and the cap-height weekday labels above
* carry different side bearings, so box-flush alignment reads visibly off at
* this size. 1px is the tuned value — 0 reads short of the label, 2px reads
* past it.
*/
function DayCell({
cell,
events,
colIndex,
onSelect,
onSelectTask,
onTaskContextMenu,
onShowDay,
}: {
cell: CalendarDayCell
events: CalendarEvent[]
colIndex: number
onSelect: (date: Date) => void
onSelectTask: (task: ScheduledTask) => void
onTaskContextMenu: (task: ScheduledTask, e: React.MouseEvent) => void
onShowDay: (date: Date) => void
}) {
const visible =
events.length > MAX_DAY_EVENT_LINES ? events.slice(0, MAX_DAY_EVENT_LINES - 1) : events
const hiddenCount = events.length - visible.length
return (
<div
onClick={() => onSelect(cell.date)}
className={cn(
'flex min-h-0 min-w-0 cursor-pointer flex-col items-start gap-1 overflow-hidden border-[var(--border)] border-r border-b p-1.5 transition-colors hover-hover:bg-[var(--surface-active)]',
colIndex === 0 && 'pl-6',
colIndex === 6 && 'pr-6'
)}
>
<span
className={cn(
'ml-px flex h-[26px] flex-shrink-0 items-center rounded-lg text-caption',
cell.isToday
? cn('w-[26px] justify-center', chipPrimaryFillTokens)
: cell.isCurrentMonth
? 'text-[var(--text-body)]'
: 'text-[var(--text-muted)]'
)}
>
{format(cell.date, 'd')}
</span>
<div className='flex w-full min-w-0 flex-col gap-0.5'>
{visible.map((event) => (
<CalendarEventChip
key={event.id}
event={event}
onSelect={onSelectTask}
onContextMenu={onTaskContextMenu}
/>
))}
{hiddenCount > 0 && (
<button
type='button'
onClick={(e) => {
e.stopPropagation()
onShowDay(cell.date)
}}
className='cursor-pointer px-1.5 py-0.5 text-left text-[var(--text-muted)] text-micro outline-none transition-colors hover-hover:text-[var(--text-body)]'
>
{hiddenCount} more
</button>
)}
</div>
</div>
)
}
/**
* Month scope: a sticky weekday header over a 7-column grid of day cells that
* fills the body height. All seven tracks are equal, so the border-to-border
* column rhythm is even; the edge cells span clear to the panel edges (the page
* gutter stays hoverable and clickable) and inset their own content via
* `pl-6`/`pr-6`. Events flow in via `eventsByDay` — the single injection point
* the container fills.
*/
export function MonthGrid({
grid,
onSelectDay,
onSelectTask,
onTaskContextMenu,
onShowDay,
eventsByDay,
}: MonthGridProps) {
return (
<div className='flex h-full min-h-0 flex-col'>
<div className='sticky top-0 z-10 grid grid-cols-7 border-[var(--border)] border-b bg-[var(--bg)]'>
{WEEKDAY_LABELS.map((label, index) => (
<div
key={label}
className={cn(
'p-1.5 text-[var(--text-muted)] text-caption',
index === 0 && 'pl-6',
index === WEEKDAY_LABELS.length - 1 && 'pr-6'
)}
>
{label}
</div>
))}
</div>
<div
className='grid min-h-0 flex-1 grid-cols-7'
style={{ gridTemplateRows: `repeat(${grid.weeks.length}, minmax(0, 1fr))` }}
>
{grid.weeks.map((week) =>
week.map((cell, colIndex) => (
<DayCell
key={cell.date.toISOString()}
cell={cell}
colIndex={colIndex}
events={eventsByDay?.get(dayKey(cell.date)) ?? []}
onSelect={onSelectDay}
onSelectTask={onSelectTask}
onTaskContextMenu={onTaskContextMenu}
onShowDay={onShowDay}
/>
))
)}
</div>
</div>
)
}
@@ -0,0 +1 @@
export { TimeGrid } from './time-grid'
@@ -0,0 +1,235 @@
'use client'
import { useEffect, useState } from 'react'
import { chipPrimaryFillTokens, cn } from '@sim/emcn'
import { format } from 'date-fns'
import { zonedClockDate } from '@/lib/core/utils/timezone'
import { CalendarEventChip } from '@/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/calendar-event-chip'
import {
type CalendarDayCell,
EVENT_CHIP_HEIGHT,
formatHourLabel,
formatSlotTime,
layoutColumn,
TIME_SLOT_HEIGHT,
timeToOffset,
} from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/calendar-grid'
import {
type CalendarEvent,
dayKey,
type ScheduledTask,
} from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events'
const GUTTER_WIDTH = 56
/** Re-render cadence for the current-time indicator. */
const TICK_MS = 60_000
interface TimeGridProps {
/** One column per day: 7 for week scope, 1 for day scope. */
days: CalendarDayCell[]
hours: number[]
/** The viewer's effective timezone — positions the now-line. */
timezone: string
onSelectSlot: (date: Date, time: string) => void
onSelectTask: (task: ScheduledTask) => void
/** A task pill was right-clicked — open its context menu at the cursor. */
onTaskContextMenu: (task: ScheduledTask, e: React.MouseEvent) => void
eventsByDay?: Map<string, CalendarEvent[]>
}
/**
* Live now-line drawn over today's column — a chip-primary dot at the left edge
* and a hairline across the column, positioned by {@link timeToOffset}. Renders
* nothing until mounted (keeps SSR output stable, avoiding a hydration mismatch
* on the time-dependent offset), then ticks once a minute so the line advances.
* Positioned in `timezone` so it tracks the same zone the day columns render in.
* The parent column is `relative`; this is `absolute`.
*/
function CurrentTimeIndicator({ timezone }: { timezone: string }) {
const [now, setNow] = useState<Date | null>(null)
useEffect(() => {
setNow(new Date())
const interval = setInterval(() => setNow(new Date()), TICK_MS)
return () => clearInterval(interval)
}, [])
if (!now) return null
return (
<div
style={{ top: timeToOffset(zonedClockDate(now, timezone)) }}
className='pointer-events-none absolute inset-x-0 z-20'
>
<div className='-translate-x-1/2 -translate-y-1/2 absolute top-0 left-0 size-[10px] rounded-full bg-[var(--text-primary)] dark:bg-white' />
<div className='-translate-y-1/2 absolute inset-x-0 top-0 h-[2px] bg-[var(--text-primary)] dark:bg-white' />
</div>
)
}
/**
* One hour cell in a day column: a click target that opens the create modal
* seeded to this hour, plus the hour's gridlines. Tasks are not rendered here —
* they live in the day's {@link DayEvents} overlay so each sits at its exact
* minute rather than snapping to the top of the hour.
*/
function HourCell({
date,
hour,
isLastColumn,
onSelect,
}: {
date: Date
hour: number
isLastColumn: boolean
onSelect: (date: Date, time: string) => void
}) {
return (
<div
onClick={() => onSelect(date, formatSlotTime(hour))}
style={{ height: TIME_SLOT_HEIGHT }}
className={cn(
'cursor-pointer border-[var(--border)] border-r border-b transition-colors hover-hover:bg-[var(--surface-active)]',
isLastColumn && 'pr-6'
)}
/>
)
}
/**
* A day column's task pills, each absolutely positioned at its exact start time
* via {@link timeToOffset}. The layer is non-interactive so empty space falls
* through to the hour cells beneath (click-to-create); the pills re-enable
* pointer events. The layer clips to the day's bounds so a late-night pill never
* spills past the final hour row. Coincident tasks overlap by design.
*/
function DayEvents({
events,
isLastColumn,
onSelectTask,
onTaskContextMenu,
}: {
events: CalendarEvent[]
isLastColumn: boolean
onSelectTask: (task: ScheduledTask) => void
onTaskContextMenu: (task: ScheduledTask, e: React.MouseEvent) => void
}) {
const placed = layoutColumn(events, EVENT_CHIP_HEIGHT)
return (
<div
className={cn(
'pointer-events-none absolute inset-y-0 left-0.5 z-10 overflow-hidden',
isLastColumn ? 'right-6' : 'right-0.5'
)}
>
{placed.map(({ item: event, topPx, lane, lanes }) => (
<div
key={event.id}
style={{
top: topPx,
height: EVENT_CHIP_HEIGHT,
left: `${(lane / lanes) * 100}%`,
width: `${(1 / lanes) * 100}%`,
}}
className='pointer-events-auto absolute pr-0.5'
>
<CalendarEventChip
event={event}
onSelect={onSelectTask}
onContextMenu={onTaskContextMenu}
className='h-full w-full'
/>
</div>
))}
</div>
)
}
/**
* Shared time-based grid for the week (7 columns) and day (1 column) scopes: a
* sticky day header, a fixed hour gutter, and a stack of hour slots per day.
* Column widths come from a CSS grid template shared by the header and body so
* they stay aligned. The sticky header paints chrome on the day cells only —
* its gutter spacer is transparent and border-free, so the hour labels scroll
* clear to the top of the viewport. Today's column is `relative` and hosts the
* {@link CurrentTimeIndicator}. Events flow in via `eventsByDay` — the single
* injection point the container fills.
*/
export function TimeGrid({
days,
hours,
timezone,
onSelectSlot,
onSelectTask,
onTaskContextMenu,
eventsByDay,
}: TimeGridProps) {
const columnsStyle = {
gridTemplateColumns: `${GUTTER_WIDTH}px repeat(${days.length}, minmax(0, 1fr))`,
}
return (
<div className='flex h-full min-h-0 flex-col'>
<div data-time-grid-header style={columnsStyle} className='sticky top-0 z-20 grid'>
<div className='border-[var(--border)] border-r' />
{days.map((day, dayIndex) => (
<div
key={day.date.toISOString()}
className={cn(
'flex items-center justify-center gap-1.5 border-[var(--border)] border-r border-b bg-[var(--bg)] py-2',
dayIndex === days.length - 1 && 'pr-6'
)}
>
<span className='text-[var(--text-muted)] text-caption'>{format(day.date, 'EEE')}</span>
<span
className={cn(
'flex size-[26px] items-center justify-center rounded-lg text-caption',
day.isToday ? chipPrimaryFillTokens : 'text-[var(--text-body)]'
)}
>
{format(day.date, 'd')}
</span>
</div>
))}
</div>
<div style={columnsStyle} className='grid'>
<div className='flex flex-col'>
{hours.map((hour) => (
<div
key={hour}
style={{ height: TIME_SLOT_HEIGHT }}
className='relative border-[var(--border)] border-r'
>
<span className='-translate-y-1/2 absolute top-0 right-1.5 text-[var(--text-muted)] text-micro'>
{formatHourLabel(hour)}
</span>
</div>
))}
</div>
{days.map((day, dayIndex) => (
<div key={day.date.toISOString()} className='relative flex flex-col'>
{day.isToday && <CurrentTimeIndicator timezone={timezone} />}
{hours.map((hour) => (
<HourCell
key={hour}
date={day.date}
hour={hour}
isLastColumn={dayIndex === days.length - 1}
onSelect={onSelectSlot}
/>
))}
<DayEvents
events={eventsByDay?.get(dayKey(day.date)) ?? []}
isLastColumn={dayIndex === days.length - 1}
onSelectTask={onSelectTask}
onTaskContextMenu={onTaskContextMenu}
/>
</div>
))}
</div>
</div>
)
}
@@ -0,0 +1 @@
export { ScheduleCalendar } from './schedule-calendar'
@@ -0,0 +1,141 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { zonedClockDate } from '@/lib/core/utils/timezone'
import {
CalendarToolbar,
MonthGrid,
TimeGrid,
} from '@/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components'
import {
buildCalendarGrid,
type CalendarScope,
formatScopeLabel,
timeToOffset,
} from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/calendar-grid'
import type {
CalendarEvent,
ScheduledTask,
} from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events'
interface ScheduleCalendarProps {
scope: CalendarScope
anchor: Date
today: Date
/** The viewer's effective timezone — positions the now-line and centering. */
timezone: string
onScopeChange: (scope: CalendarScope) => void
onPrev: () => void
onNext: () => void
onToday: () => void
onSelectDate: (date: Date) => void
onSelectSlot: (date: Date, time?: string) => void
/** A task pill was clicked — open its details modal. */
onSelectTask: (task: ScheduledTask) => void
/** A task pill was right-clicked — open its context menu at the cursor. */
onTaskContextMenu: (task: ScheduledTask, e: React.MouseEvent) => void
/** A month cell's overflow line was clicked — jump to that day's view. */
onShowDay: (date: Date) => void
/** Day-bucketed events feeding both the month grid and the time grid. */
eventsByDay?: Map<string, CalendarEvent[]>
}
/**
* Calendar body for the scheduled-tasks page. Owns the scroll region and view
* dispatch: it renders the toolbar, derives the grid from the page's
* `useCalendar` state, and switches between the month grid and the shared time
* grid on the grid discriminant.
*
* Scroll behavior: entering week/day scope, and "Today" presses (signaled via an
* internal `scrollSignal`), center the current time in the viewport; month scope
* resets to the top. Plain prev/next navigation never re-centers. Today presses
* scroll smoothly as an orientation cue; mount and scope switches position
* instantly (animating initial placement would read as a glitch). Centering is
* computed from the time-grid header height plus {@link timeToOffset} rather than
* the now-line element, so it works even on first paint before the line mounts.
*
* Event injection is the single integration point — `eventsByDay` is threaded
* straight into both grids, which forward it to their cells.
*/
export function ScheduleCalendar({
scope,
anchor,
today,
timezone,
onScopeChange,
onPrev,
onNext,
onToday,
onSelectDate,
onSelectSlot,
onSelectTask,
onTaskContextMenu,
onShowDay,
eventsByDay,
}: ScheduleCalendarProps) {
const scrollRef = useRef<HTMLDivElement>(null)
const lastScrollSignalRef = useRef(0)
const [scrollSignal, setScrollSignal] = useState(0)
const grid = useMemo(() => buildCalendarGrid(scope, anchor, today), [scope, anchor, today])
const label = useMemo(() => formatScopeLabel(scope, anchor), [scope, anchor])
const handleToday = useCallback(() => {
onToday()
setScrollSignal((signal) => signal + 1)
}, [onToday])
useEffect(() => {
const region = scrollRef.current
if (!region) return
const behavior: ScrollBehavior =
scrollSignal !== lastScrollSignalRef.current ? 'smooth' : 'auto'
lastScrollSignalRef.current = scrollSignal
if (scope === 'month') {
region.scrollTo({ top: 0, behavior })
return
}
const header = region.querySelector('[data-time-grid-header]')
const headerHeight = header ? header.getBoundingClientRect().height : 0
const target =
headerHeight + timeToOffset(zonedClockDate(new Date(), timezone)) - region.clientHeight / 2
region.scrollTo({ top: Math.max(0, target), behavior })
}, [scope, scrollSignal, timezone])
return (
<div className='relative flex min-h-0 flex-1 flex-col overflow-hidden'>
<CalendarToolbar
scope={scope}
anchor={anchor}
label={label}
onPrev={onPrev}
onNext={onNext}
onToday={handleToday}
onSelectDate={onSelectDate}
onScopeChange={onScopeChange}
/>
<div ref={scrollRef} className='min-h-0 flex-1 overflow-auto overscroll-none'>
{grid.kind === 'month' ? (
<MonthGrid
grid={grid}
onSelectDay={(date) => onSelectSlot(date)}
onSelectTask={onSelectTask}
onTaskContextMenu={onTaskContextMenu}
onShowDay={onShowDay}
eventsByDay={eventsByDay}
/>
) : (
<TimeGrid
days={grid.kind === 'week' ? grid.days : [grid.day]}
hours={grid.hours}
timezone={timezone}
onSelectSlot={(date, time) => onSelectSlot(date, time)}
onSelectTask={onSelectTask}
onTaskContextMenu={onTaskContextMenu}
eventsByDay={eventsByDay}
/>
)}
</div>
</div>
)
}
@@ -0,0 +1 @@
export { ScheduleListContextMenu } from './schedule-list-context-menu'
@@ -0,0 +1,53 @@
'use client'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@sim/emcn'
import { Plus } from '@sim/emcn/icons'
interface ScheduleListContextMenuProps {
isOpen: boolean
position: { x: number; y: number }
onClose: () => void
onCreateSchedule?: () => void
disableCreate?: boolean
}
export function ScheduleListContextMenu({
isOpen,
position,
onClose,
onCreateSchedule,
disableCreate = false,
}: ScheduleListContextMenuProps) {
return (
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
<DropdownMenuTrigger asChild>
<div
style={{
position: 'fixed',
left: `${position.x}px`,
top: `${position.y}px`,
width: '1px',
height: '1px',
pointerEvents: 'none',
}}
tabIndex={-1}
aria-hidden
/>
</DropdownMenuTrigger>
<DropdownMenuContent
align='start'
side='bottom'
sideOffset={4}
onCloseAutoFocus={(e) => e.preventDefault()}
onContextMenu={(e) => e.preventDefault()}
>
{onCreateSchedule && (
<DropdownMenuItem disabled={disableCreate} onSelect={onCreateSchedule}>
<Plus />
New scheduled task
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1 @@
export { TaskContextMenu } from './task-context-menu'
@@ -0,0 +1,110 @@
'use client'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@sim/emcn'
import { Duplicate as DuplicateIcon, Pause, Pencil, Play, Trash } from '@sim/emcn/icons'
import type { ScheduledTask } from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events'
interface TaskContextMenuProps {
isOpen: boolean
position: { x: number; y: number }
onClose: () => void
/** The right-clicked task; its status decides which actions render. */
task: ScheduledTask | null
onEdit: () => void
/** Opens a new-task modal pre-filled from this task. */
onDuplicate: () => void
/** Pauses an active recurring task — suspends its future runs. */
onPause: () => void
/** Resumes a paused recurring task. */
onResume: () => void
onDelete: () => void
}
/**
* Right-click menu for a calendar task pill. Upcoming (`pending`) tasks can be
* edited or deleted, and recurring ones paused or resumed; any task can be
* duplicated into a new one. Finished tasks open their read-only record on
* click, so the menu only offers Duplicate.
*/
export function TaskContextMenu({
isOpen,
position,
onClose,
task,
onEdit,
onDuplicate,
onPause,
onResume,
onDelete,
}: TaskContextMenuProps) {
const isUpcoming = task?.status === 'pending'
/** Pause/Resume applies to recurring tasks only — one-time tasks carry no cadence. */
const canPauseResume = isUpcoming && task?.recurring === true
return (
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
<DropdownMenuTrigger asChild>
<div
style={{
position: 'fixed',
left: `${position.x}px`,
top: `${position.y}px`,
width: '1px',
height: '1px',
pointerEvents: 'none',
}}
tabIndex={-1}
aria-hidden
/>
</DropdownMenuTrigger>
<DropdownMenuContent
align='start'
side='bottom'
sideOffset={4}
onCloseAutoFocus={(e) => e.preventDefault()}
onContextMenu={(e) => e.preventDefault()}
>
{isUpcoming ? (
<>
<DropdownMenuItem onSelect={onEdit}>
<Pencil />
Edit
</DropdownMenuItem>
{canPauseResume &&
(task?.disabled ? (
<DropdownMenuItem onSelect={onResume}>
<Play />
Resume
</DropdownMenuItem>
) : (
<DropdownMenuItem onSelect={onPause}>
<Pause />
Pause
</DropdownMenuItem>
))}
<DropdownMenuItem onSelect={onDuplicate}>
<DuplicateIcon />
Duplicate
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onDelete}>
<Trash />
Delete
</DropdownMenuItem>
</>
) : (
<DropdownMenuItem onSelect={onDuplicate}>
<DuplicateIcon />
Duplicate
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1 @@
export { TaskDeleteDialog } from './task-delete-dialog'
@@ -0,0 +1,98 @@
'use client'
import {
ChipConfirmModal,
ChipModal,
ChipModalBody,
ChipModalFooter,
ChipModalHeader,
} from '@sim/emcn'
import { Calendar } from '@sim/emcn/icons'
import type { ScheduledTask } from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events'
interface TaskDeleteDialogProps {
/** The task targeted for deletion, or `null` to keep the dialog closed. */
task: ScheduledTask | null
onClose: () => void
/** Delete just the targeted occurrence of a recurring task. */
onDeleteOccurrence: (task: ScheduledTask) => void
/** Delete a one-time task, or the entire recurring series. */
onDeleteSeries: (task: ScheduledTask) => void
}
/**
* Deletion confirmation for a scheduled task. A one-time task takes a single
* confirm; a recurring task offers the calendar-app choice between deleting
* this occurrence and deleting the whole series.
*/
export function TaskDeleteDialog({
task,
onClose,
onDeleteOccurrence,
onDeleteSeries,
}: TaskDeleteDialogProps) {
if (task && !task.recurring) {
return (
<ChipConfirmModal
open
onOpenChange={(open) => {
if (!open) onClose()
}}
title='Delete scheduled task'
text='This task will be removed from the calendar and will not run.'
confirm={{
label: 'Delete',
onClick: () => {
onDeleteSeries(task)
onClose()
},
}}
/>
)
}
return (
<ChipModal
open={task !== null}
onOpenChange={(open) => {
if (!open) onClose()
}}
size='sm'
srTitle='Delete recurring task'
>
{task && (
<>
<ChipModalHeader icon={Calendar} onClose={onClose}>
Delete recurring task
</ChipModalHeader>
<ChipModalBody>
<p className='px-2 text-[var(--text-body)] text-sm'>
This is a recurring task. Delete only this occurrence, or the entire series?
</p>
</ChipModalBody>
<ChipModalFooter
onCancel={onClose}
secondaryActions={[
{
label: 'This task',
variant: 'destructive',
onClick: () => {
onDeleteOccurrence(task)
onClose()
},
},
]}
primaryAction={{
label: 'All tasks',
variant: 'destructive',
onClick: () => {
onDeleteSeries(task)
onClose()
},
}}
/>
</>
)}
</ChipModal>
)
}
@@ -0,0 +1 @@
export { TaskDetailsModal } from './task-details-modal'
@@ -0,0 +1,106 @@
'use client'
import {
ChipModal,
ChipModalBody,
ChipModalField,
ChipModalFooter,
ChipModalHeader,
chipFieldSurfaceClass,
cn,
} from '@sim/emcn'
import { Calendar } from '@sim/emcn/icons'
import { format } from 'date-fns'
import { useParams } from 'next/navigation'
import {
PromptEditor,
usePromptEditor,
} from '@/app/workspace/[workspaceId]/home/components/user-input/components'
import type {
ScheduledTask,
ScheduledTaskStatus,
} from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events'
/**
* Plaintext copy per task state: the status label and the verb that titles the
* run-time field — the tense carries the state ("Ran" is done, "Failed" errored).
* No icons, no status colors, by design. Total over the status union for type
* safety, though `pending` tasks open the edit `TaskModal` instead.
*/
const STATUS_COPY: Record<ScheduledTaskStatus, { label: string; timeTitle: string }> = {
pending: { label: 'Pending', timeTitle: 'Runs' },
error: { label: 'Error', timeTitle: 'Failed' },
completed: { label: 'Completed', timeTitle: 'Ran' },
}
interface TaskDetailsModalProps {
/** The running or finished task to show. `null` keeps the modal closed. */
task: ScheduledTask | null
onClose: () => void
}
/**
* Read-only record modal for tasks that are running or already finished —
* pending tasks open the edit `TaskModal` instead. Three plaintext fields:
* Status and the run time as copy fields, the prompt as a view-only chip editor.
*/
export function TaskDetailsModal({ task, onClose }: TaskDetailsModalProps) {
return (
<ChipModal
open={task !== null}
onOpenChange={(open) => {
if (!open) onClose()
}}
size='md'
srTitle='Scheduled task'
>
{/* Key by the occurrence id so switching tasks while the modal stays open
remounts the content — the editor seeds prompt + contexts on mount, so
without a fresh mount it would keep showing the first task's prompt. */}
{task && <TaskDetailsContent key={task.id} task={task} onClose={onClose} />}
</ChipModal>
)
}
/**
* Inner content, mounted only while a task is shown (the Radix portal unmounts
* closed content). Holding the read-only editor here keeps its mention-data
* queries from firing on page load and re-seeds from the task on each open.
*/
function TaskDetailsContent({ task, onClose }: { task: ScheduledTask; onClose: () => void }) {
const { workspaceId } = useParams<{ workspaceId: string }>()
/**
* Seed the stored resource mentions (files, tables, knowledge) as the editor's
* initial contexts — these can't be recovered from the prompt text alone. The
* mount chipify pass then merges integration `@`-mentions and `/`-skills on top
* (they DO chipify from text), so the overlay renders the full set. Seeding is
* deliberate over a post-mount `setContexts`, which would clobber the
* auto-registered integration/skill contexts.
*/
const editor = usePromptEditor({
workspaceId,
initialValue: task.prompt,
initialContexts: task.contexts,
})
return (
<>
<ChipModalHeader icon={Calendar} onClose={onClose}>
Scheduled task
</ChipModalHeader>
<ChipModalBody>
<ChipModalField type='copy' title='Status' value={STATUS_COPY[task.status].label} />
<ChipModalField
type='copy'
title={STATUS_COPY[task.status].timeTitle}
value={format(task.runAt, "EEEE, MMMM d, yyyy 'at' h:mm a")}
/>
<ChipModalField type='custom' title='Prompt'>
<div className={cn(chipFieldSurfaceClass, 'max-h-[200px] overflow-y-auto px-1 py-0.5')}>
<PromptEditor editor={editor} readOnly aria-label='Prompt' />
</div>
</ChipModalField>
</ChipModalBody>
<ChipModalFooter onCancel={onClose} primaryAction={{ label: 'Done', onClick: onClose }} />
</>
)
}
@@ -0,0 +1 @@
export { type TaskDraft, type TaskEditSeed, TaskModal, type TaskPrefill } from './task-modal'
@@ -0,0 +1,300 @@
'use client'
import { useRef } from 'react'
import {
CalendarDayCell,
ChipDatePicker,
ChipModalField,
ChipModalSeparator,
Switch,
} from '@sim/emcn'
import { format } from 'date-fns'
import type {
MonthlyMode,
Recurrence,
RecurrenceFrequency,
} from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/recurrence'
const WEEKDAY_PRESET = [1, 2, 3, 4, 5]
/** Seed count when the user first chooses "ends after N runs". */
const DEFAULT_END_AFTER_COUNT = 10
/** Cadence a task falls back to when the user first flips on recurrence. */
const DEFAULT_RECURRING_FREQUENCY = 'daily'
/** Sunday-first weekday order with single-letter labels and full names for a11y. */
const WEEKDAYS = [
{ value: 0, short: 'S', name: 'Sunday' },
{ value: 1, short: 'M', name: 'Monday' },
{ value: 2, short: 'T', name: 'Tuesday' },
{ value: 3, short: 'W', name: 'Wednesday' },
{ value: 4, short: 'T', name: 'Thursday' },
{ value: 5, short: 'F', name: 'Friday' },
{ value: 6, short: 'S', name: 'Saturday' },
] as const
/** Ordinal words for the 1st5th weekday-of-month, matching a calendar app's labels. */
const ORDINALS = ['first', 'second', 'third', 'fourth', 'fifth'] as const
/** The frequency presets the dropdown authors, keyed by a synthetic option value. */
type FrequencyOption = 'daily' | 'weekly' | 'weekdays' | 'monthly' | 'yearly' | 'custom'
function isWeekdayPreset(weekdays: number[]): boolean {
return (
weekdays.length === WEEKDAY_PRESET.length && WEEKDAY_PRESET.every((d) => weekdays.includes(d))
)
}
/**
* Collapses a recurring recurrence into the single dropdown value that
* represents it. `once` maps to the default cadence as an exhaustiveness
* fallback: callers gate on `isRecurring`, so it never reaches here at runtime,
* but the dropdown can't represent it — mapping it keeps the return type
* `FrequencyOption` without a cast.
*/
function frequencyOptionFor(recurrence: Recurrence): FrequencyOption {
if (recurrence.frequency === 'weekly')
return isWeekdayPreset(recurrence.weekdays) ? 'weekdays' : 'weekly'
if (recurrence.frequency === 'monthly') return 'monthly'
if (recurrence.frequency === 'yearly') return 'yearly'
if (recurrence.frequency === 'custom') return 'custom'
if (recurrence.frequency === 'once') return DEFAULT_RECURRING_FREQUENCY
return recurrence.frequency
}
/**
* The monthly sub-options, derived from the launch date the same way a calendar
* app offers them: repeat on the day number, on the ordinal weekday of the
* month (e.g. the third Tuesday), or on the last weekday of the month.
*
* The ordinal anchor is offered only for the 1st4th occurrence: a 5th
* occurrence is always the month's last weekday, so — like a calendar app — it
* is folded into the "last weekday" option rather than offering a "fifth" that
* would silently skip months without a 5th occurrence.
*/
function monthlyModeOptions(launch: Date): Array<{ value: MonthlyMode; label: string }> {
const weekdayName = format(launch, 'EEEE')
const ordinal = Math.ceil(launch.getDate() / 7)
const options: Array<{ value: MonthlyMode; label: string }> = [
{ value: 'day-of-month', label: `On day ${format(launch, 'd')}` },
]
if (ordinal <= 4)
options.push({ value: 'nth-weekday', label: `On the ${ORDINALS[ordinal - 1]} ${weekdayName}` })
options.push({ value: 'last-weekday', label: `On the last ${weekdayName}` })
return options
}
interface RecurrenceSectionProps {
recurrence: Recurrence
onChange: (recurrence: Recurrence) => void
/** The launch day, so weekly/monthly labels name the weekday and day-of-month. */
launchDate: string
}
/**
* The repeat + end controls for a scheduled task, rendered as a body section
* below the prompt: a "Recurring" {@link Switch} that toggles a one-time launch
* into a repeat, and — once on — the frequency preset, its cadence detail (the
* weekly day toggles or the monthly anchor), and how it ends (never, on a date,
* or after N runs).
*
* Composed as a sibling between the prompt body and footer; it owns its own
* leading separator and mirrors {@link ChipModalBody}'s spacing
* (`gap-4 px-2 pt-4 pb-4.5`) so every {@link ChipModalField} lands at the same
* effective `px-4` as the modal header/footer — no changes to the `ChipModal`
* primitives.
*/
export function RecurrenceSection({ recurrence, onChange, launchDate }: RecurrenceSectionProps) {
/**
* The cadence to reinstate when recurrence is toggled back on. Toggling off
* collapses `frequency` to `once`, dropping which preset was active, so the
* last recurring cadence is cached here and restored — a paused "Weekly on
* Mon" returns as weekly, not silently reset to daily. Written during render
* (an idempotent cache), so it is current before the toggle handler reads it.
*/
const lastRecurringFrequency = useRef<RecurrenceFrequency>(DEFAULT_RECURRING_FREQUENCY)
if (recurrence.frequency !== 'once') lastRecurringFrequency.current = recurrence.frequency
const launch = new Date(`${launchDate}T00:00`)
const isRecurring = recurrence.frequency !== 'once'
const selectedWeekdays = recurrence.weekdays.length > 0 ? recurrence.weekdays : [launch.getDay()]
const monthlyOptions = monthlyModeOptions(launch)
const monthlyMode = recurrence.monthlyMode ?? 'day-of-month'
// If the launch date drifted to a 5th occurrence, the nth anchor is no longer
// offered; fall back to "last weekday", which is exactly what it compiles to.
const monthlyValue = monthlyOptions.some((option) => option.value === monthlyMode)
? monthlyMode
: 'last-weekday'
const frequencyOptions = [
{ value: 'daily', label: 'Daily' },
{ value: 'weekly', label: 'Weekly' },
{ value: 'weekdays', label: 'Weekdays' },
{ value: 'monthly', label: 'Monthly' },
{ value: 'yearly', label: `Yearly on ${format(launch, 'MMM d')}` },
...(recurrence.frequency === 'custom' ? [{ value: 'custom', label: 'Custom' }] : []),
]
/**
* Flips the one-time launch into a repeat and back. Toggling off keeps the
* recurrence shape (weekdays, end, and a passed-through `custom` cron) on the
* object and only collapses `frequency` to `once`; toggling back on reinstates
* the remembered cadence, so neither a weekly preset nor a conversationally
* authored custom cron is silently rewritten to daily.
*/
const handleRecurringToggle = (checked: boolean) => {
onChange({ ...recurrence, frequency: checked ? lastRecurringFrequency.current : 'once' })
}
const handleFrequencyChange = (value: string) => {
const option = value as FrequencyOption
switch (option) {
case 'daily':
onChange({ ...recurrence, frequency: 'daily', weekdays: [], cron: undefined })
return
case 'weekly':
onChange({
...recurrence,
frequency: 'weekly',
weekdays: [launch.getDay()],
cron: undefined,
})
return
case 'weekdays':
onChange({
...recurrence,
frequency: 'weekly',
weekdays: [...WEEKDAY_PRESET],
cron: undefined,
})
return
case 'monthly':
onChange({
...recurrence,
frequency: 'monthly',
weekdays: [],
monthlyMode: recurrence.monthlyMode ?? 'day-of-month',
cron: undefined,
})
return
case 'yearly':
onChange({ ...recurrence, frequency: 'yearly', weekdays: [], cron: undefined })
return
case 'custom':
onChange({ ...recurrence, frequency: 'custom' })
}
}
/** Toggles a weekday on or off, never letting the last selected day be cleared. */
const handleWeekdayToggle = (day: number) => {
const isSelected = selectedWeekdays.includes(day)
if (isSelected && selectedWeekdays.length === 1) return
const weekdays = isSelected
? selectedWeekdays.filter((d) => d !== day)
: [...selectedWeekdays, day].sort((a, b) => a - b)
onChange({ ...recurrence, weekdays })
}
const handleEndChange = (value: string) => {
if (value === 'never') onChange({ ...recurrence, end: { type: 'never' } })
else if (value === 'on')
onChange({ ...recurrence, end: { type: 'on', date: format(launch, 'yyyy-MM-dd') } })
else {
const count = recurrence.end.type === 'after' ? recurrence.end.count : DEFAULT_END_AFTER_COUNT
onChange({ ...recurrence, end: { type: 'after', count } })
}
}
return (
<div className='flex flex-col'>
<ChipModalSeparator />
<div className='flex flex-col gap-4 px-2 pt-4 pb-4.5'>
<ChipModalField type='custom' title='Recurring'>
<Switch checked={isRecurring} onCheckedChange={handleRecurringToggle} />
</ChipModalField>
{isRecurring && (
<>
<ChipModalField
type='dropdown'
title='Frequency'
value={frequencyOptionFor(recurrence)}
options={frequencyOptions}
onChange={handleFrequencyChange}
/>
{recurrence.frequency === 'weekly' && (
<ChipModalField type='custom' title='Repeat on'>
{/* A one-row extract of the calendar: seven equal day cells built
from the same {@link CalendarDayCell} the date picker uses, so
the weekday toggles read as a sibling of the calendar rather than
a separate segmented bar. */}
<div className='grid grid-cols-7 gap-1'>
{WEEKDAYS.map((weekday) => {
const selected = selectedWeekdays.includes(weekday.value)
return (
<CalendarDayCell
key={weekday.value}
selected={selected}
fullWidth
aria-pressed={selected}
aria-label={weekday.name}
onClick={() => handleWeekdayToggle(weekday.value)}
>
{weekday.short}
</CalendarDayCell>
)
})}
</div>
</ChipModalField>
)}
{recurrence.frequency === 'monthly' && (
<ChipModalField
type='dropdown'
title='On'
value={monthlyValue}
options={monthlyOptions}
onChange={(value) => onChange({ ...recurrence, monthlyMode: value as MonthlyMode })}
/>
)}
<ChipModalField
type='dropdown'
title='Ends'
value={recurrence.end.type}
options={[
{ value: 'never', label: 'No end' },
{ value: 'on', label: 'Ends on' },
{ value: 'after', label: 'Ends after' },
]}
onChange={handleEndChange}
/>
{recurrence.end.type === 'on' && (
<ChipModalField type='custom' title='End date'>
<ChipDatePicker
value={recurrence.end.date}
onChange={(date) => onChange({ ...recurrence, end: { type: 'on', date } })}
fullWidth
/>
</ChipModalField>
)}
{recurrence.end.type === 'after' && (
<ChipModalField
type='input'
title='Number of runs'
value={String(recurrence.end.count)}
onChange={(value) => {
const count = Math.max(1, Math.floor(Number(value) || 1))
onChange({ ...recurrence, end: { type: 'after', count } })
}}
/>
)}
</>
)}
</div>
</div>
)
}
@@ -0,0 +1,347 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import {
ChipDatePicker,
ChipModal,
ChipModalFooter,
type ChipModalFooterSlotAction,
ChipModalHeader,
ChipModalPromptBody,
ChipTimePicker,
} from '@sim/emcn'
import { Calendar } from '@sim/emcn/icons'
import { format } from 'date-fns'
import { useParams } from 'next/navigation'
import { wallClockNow, zonedWallClockToUtc } from '@/lib/core/utils/timezone'
import {
PromptEditor,
usePromptEditor,
} from '@/app/workspace/[workspaceId]/home/components/user-input/components'
import { RecurrenceSection } from '@/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/recurrence-section'
import type { CalendarSlot } from '@/app/workspace/[workspaceId]/scheduled-tasks/hooks/use-calendar'
import {
DEFAULT_RECURRENCE,
type Recurrence,
} from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/recurrence'
import { useTimezone } from '@/hooks/queries/general-settings'
import type { ChatContext } from '@/stores/panel'
const DEFAULT_TIME = '09:00'
const PAST_LAUNCH_MESSAGE = "You can't schedule a one-time task in the past"
/** Whether a one-time launch has already passed, evaluated in the task's `timezone`. */
function isLaunchInPast(launchDate: string, launchTime: string, timezone: string): boolean {
return zonedWallClockToUtc(`${launchDate}T${launchTime}`, timezone) < new Date()
}
/**
* The next top of the hour in `timezone`, as a `{ date, time }` wall-clock pair.
* The `Z` suffix keeps the step pure wall-clock arithmetic (adding an hour and
* rolling the date at 23:xx), not a timezone conversion.
*/
function nextTopOfHour(timezone: string): { date: string; time: string } {
const next = new Date(`${wallClockNow(timezone)}:00Z`)
next.setUTCMinutes(0, 0, 0)
next.setUTCHours(next.getUTCHours() + 1)
return { date: next.toISOString().slice(0, 10), time: next.toISOString().slice(11, 16) }
}
/**
* Seeds the launch date/time for a create. A slot with a specific hour uses it;
* a whole-day slot (month cell) and the no-slot header action both default to
* the next top of the hour in `timezone` when the target day is today — so the
* modal never opens with a past, already-disabled default — and to 9am on a
* future day.
*/
function defaultLaunch(
slot: CalendarSlot | null | undefined,
timezone: string
): { date: string; time: string } {
if (slot?.time) return { date: format(slot.date, 'yyyy-MM-dd'), time: slot.time }
const upcoming = nextTopOfHour(timezone)
if (slot?.date) {
const date = format(slot.date, 'yyyy-MM-dd')
const today = wallClockNow(timezone).slice(0, 10)
return date === today ? upcoming : { date, time: DEFAULT_TIME }
}
return upcoming
}
/** The data a task create or edit captures. */
export interface TaskDraft {
prompt: string
/** Resources the prompt `@`-mentions / skills it `/`-invokes, when any. */
contexts?: ChatContext[]
launchDate: string
launchTime: string
timezone: string
recurrence: Recurrence
}
/** Pre-filled fields shared by the edit and duplicate flows. */
export interface TaskPrefill {
prompt: string
/** Stored `@`-mention contexts, re-registered so they carry over. */
contexts?: ChatContext[]
launchDate: string
launchTime: string
/** The task's own zone; the modal seeds AND submits in it so unchanged times never drift. */
timezone: string
recurrence: Recurrence
}
/** Seeds the modal when editing an existing task, recovered from its schedule. */
export interface TaskEditSeed extends TaskPrefill {
scheduleId: string
}
interface TaskModalProps {
open: boolean
onOpenChange: (open: boolean) => void
/** Slot seeding a create — the clicked day/time, or `null` from the header action. */
slot?: CalendarSlot | null
/** Seed for an edit; when set the modal opens in edit mode (Save + Delete). */
edit?: TaskEditSeed | null
/** Pre-fill for a create (duplicate): opens in create mode with every field copied. */
prefill?: TaskPrefill | null
/**
* Receives the captured draft on submit (create and save alike). May return a
* promise — the modal awaits it, keeping itself open until the task persists
* and closing only on success, so a failed save never silently discards the draft.
*/
onSubmit: (draft: TaskDraft) => void | Promise<void>
/** Asks the parent to start the delete flow (which handles the recurring this/all choice). */
onRequestDelete?: () => void
}
/**
* The "schedule a task" modal, shared by create (blank, or pre-filled from a
* duplicate) and edit (seeded from a task's schedule). The body is the chat
* input's editor — so `@` mentions resources and `/` invokes skills exactly like
* talking to Sim — followed by the recurrence section; the footer carries the
* launch date/time and (edit only) Delete.
*/
export function TaskModal({
open,
onOpenChange,
slot,
edit,
prefill,
onSubmit,
onRequestDelete,
}: TaskModalProps) {
const [submitting, setSubmitting] = useState(false)
/**
* While a save is in flight, swallow every dismiss path — Cancel, header X,
* Escape, and overlay click all route through this one handler — so an
* in-progress create/edit can't be abandoned and lose its draft. `submitting`
* lives here (not in the unmounted-on-close content) so this guard can see it.
*
* The programmatic close on a *successful* submit is intentionally NOT blocked:
* `handleSubmit` runs in the pre-submit render where `submitting` was still
* false, so its `close()` resolves to that render's handler and passes through,
* while user dismisses fire from the current (submitting) render and are caught
* here. Keep `submitting` as render state — moving it to a ref or memoizing this
* handler with `submitting` in deps would make the success-close start blocking.
*/
const handleOpenChange = (next: boolean) => {
if (!next && submitting) return
onOpenChange(next)
}
return (
<ChipModal
open={open}
onOpenChange={handleOpenChange}
size='lg'
srTitle={edit ? 'Edit scheduled task' : 'New scheduled task'}
>
<TaskModalContent
onOpenChange={handleOpenChange}
slot={slot}
edit={edit}
prefill={prefill}
onSubmit={onSubmit}
onRequestDelete={onRequestDelete}
submitting={submitting}
setSubmitting={setSubmitting}
/>
</ChipModal>
)
}
interface TaskModalContentProps extends Omit<TaskModalProps, 'open'> {
/** Whether a save is in flight — owned by {@link TaskModal} so the dismiss guard can read it. */
submitting: boolean
setSubmitting: (submitting: boolean) => void
}
/**
* Inner content, mounted only while the dialog is open (the Radix portal
* unmounts closed content). Holding the editor here keeps its mention-data
* queries from firing on page load and re-seeds from `edit`/`slot` on each open.
*/
function TaskModalContent({
onOpenChange,
slot,
edit,
prefill,
onSubmit,
onRequestDelete,
submitting,
setSubmitting,
}: TaskModalContentProps) {
const { workspaceId } = useParams<{ workspaceId: string }>()
const source = edit ?? prefill
const accountTimezone = useTimezone()
const timezone = source?.timezone ?? accountTimezone
const editor = usePromptEditor({ workspaceId, initialValue: source?.prompt })
const setContexts = editor.setContexts
/**
* Re-registers a seeded task's stored `@`-mentions once on open: the editor
* seeds from `initialValue` text only, never its contexts. Runs once per open
* since the dialog's content remounts each time it opens.
*/
useEffect(() => {
if (source?.contexts && source.contexts.length > 0) setContexts(source.contexts)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const seedFromSource =
source &&
!(
source.recurrence.frequency === 'once' &&
isLaunchInPast(source.launchDate, source.launchTime, timezone)
)
const seed = seedFromSource
? { date: source.launchDate, time: source.launchTime }
: defaultLaunch(slot, timezone)
const [launchDate, setLaunchDate] = useState(seed.date)
const [launchTime, setLaunchTime] = useState(seed.time)
const [recurrence, setRecurrence] = useState<Recurrence>(
() => source?.recurrence ?? DEFAULT_RECURRENCE
)
const launchEditedRef = useRef(false)
/**
* Synchronous mirror of `submitting` that gates {@link handleSubmit}. The
* `submitting` state only reflects after a re-render, so two invocations in the
* same tick (Enter racing the click) could both pass a state-based guard; the
* ref flips immediately, so the second is rejected before it can fire a second
* mutation.
*/
const submittingRef = useRef(false)
/**
* Re-seed a blank create's default launch when the effective zone resolves
* after mount — `useTimezone()` starts on the browser fallback, so without
* this the next-top-of-the-hour default (and its past-launch guard) would be
* computed in the wrong zone and submitted in the resolved one. No-op once the
* user edits the fields, and skipped for slot/edit/duplicate seeds, whose
* launch is zone-stable (slot times are zone-independent; source seeds carry
* the task's own fixed zone).
*/
useEffect(() => {
if (launchEditedRef.current || source || slot) return
const next = defaultLaunch(null, timezone)
setLaunchDate(next.date)
setLaunchTime(next.time)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [timezone])
const editLaunchDate = (date: string) => {
launchEditedRef.current = true
setLaunchDate(date)
}
const editLaunchTime = (time: string) => {
launchEditedRef.current = true
setLaunchTime(time)
}
const close = () => onOpenChange(false)
const isOneTime = recurrence.frequency === 'once'
const isPastLaunch = isOneTime && isLaunchInPast(launchDate, launchTime, timezone)
const promptText = editor.value.trim()
/**
* Submits the draft and waits for it to persist. The synchronous
* {@link submittingRef} guard blocks a double-submit (Enter racing the click).
* The modal closes only when the save resolves; a rejection leaves it open so
* the draft survives — the mutation hook already surfaces the error via toast,
* so it is swallowed here rather than duplicated. Both the ref and the
* `submitting` state are always cleared, so the button can never stick disabled
* while the modal stays open.
*/
const handleSubmit = async () => {
if (!promptText || isPastLaunch || submittingRef.current) return
submittingRef.current = true
setSubmitting(true)
const persisted = await Promise.resolve(
onSubmit({
prompt: editor.getPlainValue().trim(),
contexts: editor.contexts.length > 0 ? editor.contexts : undefined,
launchDate,
launchTime,
timezone,
recurrence,
})
)
.then(() => true)
.catch(() => false)
submittingRef.current = false
setSubmitting(false)
if (persisted) close()
}
/**
* Footer secondary actions — the launch date/time pickers and (edit only)
* Delete. Delete is disabled while `submitting` because it bypasses the
* dismiss guard — it closes the modal via `closeTask`, not the guarded
* `onOpenChange` — so without the lock an in-flight edit and a delete could
* run against the same task at once. Recurrence lives in the body, not here.
*/
const secondaryActions: ChipModalFooterSlotAction[] = [
...(edit && onRequestDelete
? [
{
label: 'Delete',
variant: 'destructive' as const,
onClick: onRequestDelete,
disabled: submitting,
},
]
: []),
{ custom: <ChipDatePicker value={launchDate} onChange={editLaunchDate} flush /> },
{ custom: <ChipTimePicker value={launchTime} onChange={editLaunchTime} flush /> },
]
return (
<>
<ChipModalHeader icon={Calendar} onClose={close}>
{edit ? 'Edit scheduled task' : 'New scheduled task'}
</ChipModalHeader>
<ChipModalPromptBody>
<PromptEditor
editor={editor}
placeholder='Use @ and launch Sim to...'
autoFocus
onSubmit={handleSubmit}
/>
</ChipModalPromptBody>
<RecurrenceSection recurrence={recurrence} onChange={setRecurrence} launchDate={launchDate} />
<ChipModalFooter
onCancel={close}
cancelDisabled={submitting}
secondaryActions={secondaryActions}
primaryAction={{
label: submitting ? (edit ? 'Saving...' : 'Scheduling...') : edit ? 'Save' : 'Schedule',
onClick: handleSubmit,
disabled: !promptText || isPastLaunch || submitting,
disabledTooltip: isPastLaunch ? PAST_LAUNCH_MESSAGE : undefined,
}}
/>
</>
)
}
@@ -0,0 +1,15 @@
'use client'
import { type ErrorBoundaryProps, ErrorState } from '@/app/workspace/[workspaceId]/components'
export default function ScheduledTasksError({ error, reset }: ErrorBoundaryProps) {
return (
<ErrorState
error={error}
reset={reset}
title='Failed to load scheduled tasks'
description='Something went wrong while loading your scheduled tasks. Please try again.'
loggerName='ScheduledTasksError'
/>
)
}
@@ -0,0 +1,180 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { isSameDay } from 'date-fns'
import { useQueryStates } from 'nuqs'
import { zonedClockDate } from '@/lib/core/utils/timezone'
import {
calendarParsers,
calendarUrlKeys,
} from '@/app/workspace/[workspaceId]/scheduled-tasks/search-params'
import {
advanceAnchor,
type CalendarScope,
} from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/calendar-grid'
/** How often to check whether the calendar day has rolled over. */
const DAY_ROLLOVER_POLL_MS = 60_000
/** A clicked calendar position: a day, optionally narrowed to an hour slot. */
export interface CalendarSlot {
date: Date
/** `HH:mm` when a time slot was clicked; absent for a whole-day click. */
time?: string
}
export interface UseCalendarReturn {
scope: CalendarScope
/** The focused day; week/month ranges derive from it. */
anchor: Date
/** The current calendar day, shared by every view; refreshes at midnight. */
today: Date
selectedSlot: CalendarSlot | null
isCreateOpen: boolean
setScope: (scope: CalendarScope) => void
next: () => void
prev: () => void
goToday: () => void
/** Jumps the view to an arbitrary day, keeping the current scope. */
goToDate: (date: Date) => void
/** Jumps to a day AND switches to the day scope (month-cell overflow drill-in). */
openDay: (date: Date) => void
selectSlot: (date: Date, time?: string) => void
openCreate: () => void
closeCreate: () => void
}
/**
* Owns the calendar's view state. `scope` and `anchor` live in the URL (nuqs) so
* the current view is shareable and survives reload / back-forward; the create
* modal and selected slot stay local (ephemeral UI). Opens on the `week` scope.
* "Now" (the today highlight, the default anchor) is resolved in `timezone` — the
* viewer's effective zone — so the calendar's date frame matches the zone tasks
* are scheduled in. The `anchor` param is date-only and nullable: a clean URL
* means "today", derived per-timezone here, so navigating to today clears the
* param. `today` is polled so the today highlight and current-time column survive
* midnight without a remount; the poll only re-renders when the day actually
* changes (the interval is resilient to device sleep, unlike a one-shot timeout
* aimed at midnight).
*/
export function useCalendar(timezone: string): UseCalendarReturn {
const timezoneRef = useRef(timezone)
const [today, setToday] = useState<Date>(() => zonedClockDate(new Date(), timezone))
const [{ scope, anchor: anchorParam }, setCalendarState] = useQueryStates(
calendarParsers,
calendarUrlKeys
)
const [selectedSlot, setSelectedSlot] = useState<CalendarSlot | null>(null)
const [isCreateOpen, setIsCreateOpen] = useState(false)
const todayRef = useRef(today)
/** A clean URL (no `anchor` param) means "today", resolved in the effective zone. */
const anchor = anchorParam ?? today
const anchorRef = useRef(anchor)
useEffect(() => {
todayRef.current = today
}, [today])
useEffect(() => {
anchorRef.current = anchor
}, [anchor])
const setScope = useCallback(
(next: CalendarScope) => {
void setCalendarState({ scope: next })
},
[setCalendarState]
)
/**
* Set the focused day. Writing `today` (the default anchor) as `null` keeps the
* URL clean and preserves the "clean URL = today" invariant.
*/
const setAnchorDate = useCallback(
(date: Date) => {
void setCalendarState({ anchor: isSameDay(date, todayRef.current) ? null : date })
},
[setCalendarState]
)
/**
* Re-sync `today` to the effective zone's current day when `timezone` actually
* changes — e.g. when `useTimezone()` resolves from the browser fallback to the
* saved account zone after mount. When the URL holds an explicit anchor that
* was on the previous "today", drop it so the view follows to the new today;
* an in-progress navigation (anchor on another day) is preserved.
*/
useEffect(() => {
if (timezoneRef.current === timezone) return
timezoneRef.current = timezone
const now = zonedClockDate(new Date(), timezone)
if (anchorRef.current && isSameDay(anchorRef.current, todayRef.current)) {
void setCalendarState({ anchor: null })
}
setToday(now)
}, [timezone, setCalendarState])
useEffect(() => {
const id = setInterval(() => {
const now = zonedClockDate(new Date(), timezoneRef.current)
setToday((current) => (isSameDay(current, now) ? current : now))
}, DAY_ROLLOVER_POLL_MS)
return () => clearInterval(id)
}, [])
const next = useCallback(
() => setAnchorDate(advanceAnchor(anchorRef.current, scope, 1)),
[scope, setAnchorDate]
)
const prev = useCallback(
() => setAnchorDate(advanceAnchor(anchorRef.current, scope, -1)),
[scope, setAnchorDate]
)
const goToday = useCallback(
() => setAnchorDate(zonedClockDate(new Date(), timezoneRef.current)),
[setAnchorDate]
)
const goToDate = useCallback((date: Date) => setAnchorDate(date), [setAnchorDate])
const openDay = useCallback(
(date: Date) => {
void setCalendarState({
anchor: isSameDay(date, todayRef.current) ? null : date,
scope: 'day',
})
},
[setCalendarState]
)
const selectSlot = useCallback((date: Date, time?: string) => {
setSelectedSlot({ date, time })
setIsCreateOpen(true)
}, [])
const openCreate = useCallback(() => {
setSelectedSlot(null)
setIsCreateOpen(true)
}, [])
const closeCreate = useCallback(() => {
setIsCreateOpen(false)
setSelectedSlot(null)
}, [])
return {
scope,
anchor,
today,
selectedSlot,
isCreateOpen,
setScope,
next,
prev,
goToday,
goToDate,
openDay,
selectSlot,
openCreate,
closeCreate,
}
}
@@ -0,0 +1,235 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
import { truncate } from '@sim/utils/string'
import type { CreateScheduleBody, UpdateScheduleBody } from '@/lib/api/contracts/schedules'
import { zonedWallClock } from '@/lib/core/utils/timezone'
import type {
TaskDraft,
TaskEditSeed,
} from '@/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal'
import {
cronToRecurrence,
recurrenceToScheduleFields,
} from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/recurrence'
import {
bucketEventsByDay,
type CalendarEvent,
type ScheduledTask,
scheduleToTasks,
taskToCalendarEvent,
} from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events'
import {
useCreateSchedule,
useDeleteSchedule,
useDisableSchedule,
useExcludeOccurrence,
useResumeSchedule,
useUpdateSchedule,
useWorkspaceSchedules,
} from '@/hooks/queries/schedules'
/** Job title shown in audit logs / listings, derived from the prompt the user wrote. */
function titleFromPrompt(prompt: string): string {
return truncate(prompt.trim(), 80) || 'Scheduled task'
}
function draftToCreateBody(draft: TaskDraft, workspaceId: string): CreateScheduleBody {
const fields = recurrenceToScheduleFields(
draft.recurrence,
draft.launchDate,
draft.launchTime,
draft.timezone
)
return {
workspaceId,
title: titleFromPrompt(draft.prompt),
prompt: draft.prompt,
cronExpression: fields.cronExpression ?? undefined,
time: fields.time,
timezone: draft.timezone,
lifecycle: fields.lifecycle,
maxRuns: fields.maxRuns,
endsAt: fields.endsAt,
contexts: draft.contexts,
}
}
/** Edit always sends every recurrence field so clearing an end boundary or switching cadence sticks. */
function draftToUpdateBody(draft: TaskDraft): Omit<UpdateScheduleBody, 'action'> {
const fields = recurrenceToScheduleFields(
draft.recurrence,
draft.launchDate,
draft.launchTime,
draft.timezone
)
return {
title: titleFromPrompt(draft.prompt),
prompt: draft.prompt,
cronExpression: fields.cronExpression,
time: fields.time,
timezone: draft.timezone,
lifecycle: fields.lifecycle,
maxRuns: fields.maxRuns ?? null,
endsAt: fields.endsAt ?? null,
contexts: draft.contexts ?? [],
}
}
export interface UseScheduledTasksParams {
workspaceId: string
/** Inclusive window the current view renders; bounds recurrence expansion. */
rangeStart: Date
rangeEnd: Date
}
export interface UseScheduledTasksReturn {
isLoading: boolean
/** Day-bucketed events feeding both the month grid and the time grid. */
eventsByDay: Map<string, CalendarEvent[]>
/** The task occurrence whose modal is open, or `null` when none is. */
selectedTask: ScheduledTask | null
openTask: (task: ScheduledTask) => void
closeTask: () => void
/** Recovers the modal's edit seed (recurrence, launch) from a task's schedule. */
editSeedFor: (task: ScheduledTask) => TaskEditSeed | null
/** Resolves once the create persists; rejects on failure so the modal stays open. */
createTask: (draft: TaskDraft) => Promise<void>
/** Resolves once the edit persists; rejects on failure so the modal stays open. */
updateTask: (scheduleId: string, draft: TaskDraft) => Promise<void>
/** Deletes the whole task (one-time or the entire recurring series). */
deleteTask: (scheduleId: string) => void
/** Deletes a single occurrence of a recurring task. */
deleteOccurrence: (scheduleId: string, occurrence: Date) => void
/** Pauses a recurring task — suspends future runs until resumed. */
pauseTask: (scheduleId: string) => void
/** Resumes a paused recurring task, recomputing its next run from the cron. */
resumeTask: (scheduleId: string) => void
}
/**
* Bridges the calendar to the persisted job-schedule backend: reads the
* workspace's scheduled tasks, expands them into the occurrences visible in the
* current range, and exposes create/edit/delete mutations. UI-only selection
* state lives here; all task data flows through React Query.
*/
export function useScheduledTasks({
workspaceId,
rangeStart,
rangeEnd,
}: UseScheduledTasksParams): UseScheduledTasksReturn {
const { data: schedules = [], isLoading } = useWorkspaceSchedules(workspaceId)
const createSchedule = useCreateSchedule()
const updateSchedule = useUpdateSchedule()
const deleteSchedule = useDeleteSchedule()
const excludeOccurrence = useExcludeOccurrence()
const disableSchedule = useDisableSchedule()
const resumeSchedule = useResumeSchedule()
const [selectedTask, setSelectedTask] = useState<ScheduledTask | null>(null)
const events = useMemo(() => {
const now = new Date()
return schedules
.filter((schedule) => schedule.sourceType === 'job')
.flatMap((schedule) => scheduleToTasks(schedule, rangeStart, rangeEnd, now))
.map(taskToCalendarEvent)
.sort((a, b) => a.start.getTime() - b.start.getTime())
}, [schedules, rangeStart, rangeEnd])
const eventsByDay = useMemo(() => bucketEventsByDay(events), [events])
const openTask = useCallback((task: ScheduledTask) => setSelectedTask(task), [])
const closeTask = useCallback(() => setSelectedTask(null), [])
const editSeedFor = useCallback(
(task: ScheduledTask): TaskEditSeed | null => {
const schedule = schedules.find((row) => row.id === task.scheduleId)
if (!schedule) return null
const { recurrence, launchTime } = cronToRecurrence({
cronExpression: schedule.cronExpression,
maxRuns: schedule.maxRuns,
endsAt: schedule.endsAt,
anchor: task.runAt,
timezone: schedule.timezone,
})
return {
scheduleId: schedule.id,
prompt: schedule.prompt ?? '',
contexts: task.contexts,
launchDate: zonedWallClock(task.runAt, schedule.timezone).slice(0, 10),
launchTime,
timezone: schedule.timezone,
recurrence,
}
},
[schedules]
)
const createTask = useCallback(
async (draft: TaskDraft) => {
await createSchedule.mutateAsync(draftToCreateBody(draft, workspaceId))
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[workspaceId]
)
const updateTask = useCallback(
async (scheduleId: string, draft: TaskDraft) => {
await updateSchedule.mutateAsync({ scheduleId, workspaceId, ...draftToUpdateBody(draft) })
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[workspaceId]
)
const deleteTask = useCallback(
(scheduleId: string) => {
deleteSchedule.mutate({ scheduleId, workspaceId })
setSelectedTask((current) => (current?.scheduleId === scheduleId ? null : current))
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[workspaceId]
)
const deleteOccurrence = useCallback(
(scheduleId: string, occurrence: Date) => {
excludeOccurrence.mutate({ scheduleId, workspaceId, occurrence: occurrence.toISOString() })
setSelectedTask((current) => (current?.scheduleId === scheduleId ? null : current))
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[workspaceId]
)
const pauseTask = useCallback(
(scheduleId: string) => {
disableSchedule.mutate({ scheduleId, workspaceId })
setSelectedTask((current) => (current?.scheduleId === scheduleId ? null : current))
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[workspaceId]
)
const resumeTask = useCallback(
(scheduleId: string) => {
resumeSchedule.mutate({ scheduleId, workspaceId })
setSelectedTask((current) => (current?.scheduleId === scheduleId ? null : current))
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[workspaceId]
)
return {
isLoading,
eventsByDay,
selectedTask,
openTask,
closeTask,
editSeedFor,
createTask,
updateTask,
deleteTask,
deleteOccurrence,
pauseTask,
resumeTask,
}
}
@@ -0,0 +1,18 @@
'use client'
import { Calendar, Plus } from '@sim/emcn/icons'
import {
type ChromeActionSpec,
ResourceChromeFallback,
} from '@/app/workspace/[workspaceId]/components'
const ACTIONS: ChromeActionSpec[] = [{ text: 'New scheduled task', icon: Plus, variant: 'primary' }]
/**
* Route-segment fallback: the page renders a calendar, not a table, so this
* paints only the header chrome (no table columns / search / sort / filter).
* The empty calendar then mounts and its tasks load in.
*/
export default function ScheduledTasksLoading() {
return <ResourceChromeFallback icon={Calendar} title='Scheduled Tasks' actions={ACTIONS} />
}
@@ -0,0 +1,22 @@
import { Suspense } from 'react'
import type { Metadata } from 'next'
import ScheduledTasksLoading from '@/app/workspace/[workspaceId]/scheduled-tasks/loading'
import { ScheduledTasks } from './scheduled-tasks'
export const metadata: Metadata = {
title: 'Scheduled Tasks',
}
/**
* Scheduled-tasks page entry. `ScheduledTasks` reads the calendar's `scope` /
* `anchor` URL query params via nuqs (which uses `useSearchParams` internally),
* so it must sit under a Suspense boundary. The fallback renders the real chrome
* so a suspend never shows a blank frame.
*/
export default function ScheduledTasksPage() {
return (
<Suspense fallback={<ScheduledTasksLoading />}>
<ScheduledTasks />
</Suspense>
)
}
@@ -0,0 +1,231 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
import { Calendar, Plus } from '@sim/emcn/icons'
import { useParams } from 'next/navigation'
import type { ResourceAction } from '@/app/workspace/[workspaceId]/components'
import { Resource } from '@/app/workspace/[workspaceId]/components'
import { ScheduleCalendar } from '@/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar'
import { ScheduleListContextMenu } from '@/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-list-context-menu'
import { TaskContextMenu } from '@/app/workspace/[workspaceId]/scheduled-tasks/components/task-context-menu'
import { TaskDeleteDialog } from '@/app/workspace/[workspaceId]/scheduled-tasks/components/task-delete-dialog'
import { TaskDetailsModal } from '@/app/workspace/[workspaceId]/scheduled-tasks/components/task-details-modal'
import {
TaskModal,
type TaskPrefill,
} from '@/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal'
import { useCalendar } from '@/app/workspace/[workspaceId]/scheduled-tasks/hooks/use-calendar'
import { useScheduledTasks } from '@/app/workspace/[workspaceId]/scheduled-tasks/hooks/use-scheduled-tasks'
import { visibleRange } from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/calendar-grid'
import type { ScheduledTask } from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { useTimezone } from '@/hooks/queries/general-settings'
export function ScheduledTasks() {
const { workspaceId } = useParams<{ workspaceId: string }>()
const timezone = useTimezone()
const calendar = useCalendar(timezone)
const range = useMemo(
() => visibleRange(calendar.scope, calendar.anchor),
[calendar.scope, calendar.anchor]
)
const tasks = useScheduledTasks({ workspaceId, rangeStart: range.start, rangeEnd: range.end })
/** Pending tasks open the editable TaskModal; running/finished open the record. */
const editTask = tasks.selectedTask?.status === 'pending' ? tasks.selectedTask : null
const recordTask = tasks.selectedTask?.status !== 'pending' ? tasks.selectedTask : null
const editSeed = editTask ? tasks.editSeedFor(editTask) : null
const {
isOpen: isListContextMenuOpen,
position: listContextMenuPosition,
handleContextMenu: handleListContextMenu,
closeMenu: closeListContextMenu,
} = useContextMenu()
const {
isOpen: isTaskContextMenuOpen,
position: taskContextMenuPosition,
handleContextMenu: handleTaskCtxMenu,
closeMenu: closeTaskContextMenu,
} = useContextMenu()
/** The right-clicked task — drives the context menu items. */
const [contextTask, setContextTask] = useState<ScheduledTask | null>(null)
/** The task targeted for deletion — drives the (recurring-aware) delete dialog. */
const [deletingTask, setDeletingTask] = useState<ScheduledTask | null>(null)
/** Pre-fill for a duplicate — opens the create modal seeded from an existing task. */
const [duplicatePrefill, setDuplicatePrefill] = useState<TaskPrefill | null>(null)
/** Starts a blank create. The three modal sources are mutually exclusive, so it closes the others. */
const handleOpenCreate = useCallback(() => {
setDuplicatePrefill(null)
tasks.closeTask()
calendar.openCreate()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [calendar.openCreate])
/** Starts a slot-seeded create, closing any other open modal. */
const handleSelectSlot = useCallback(
(date: Date, time?: string) => {
setDuplicatePrefill(null)
tasks.closeTask()
calendar.selectSlot(date, time)
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[calendar.selectSlot]
)
/** Opens a task's edit/record modal, closing any create/duplicate flow. */
const handleOpenTask = useCallback(
(task: ScheduledTask) => {
setDuplicatePrefill(null)
calendar.closeCreate()
tasks.openTask(task)
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[calendar.closeCreate]
)
const handleDuplicate = useCallback(() => {
if (!contextTask) return
const seed = tasks.editSeedFor(contextTask)
if (!seed) return
const { scheduleId: _scheduleId, ...prefill } = seed
calendar.closeCreate()
tasks.closeTask()
setDuplicatePrefill(prefill)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [contextTask, calendar.closeCreate])
const handleTaskContextMenu = useCallback(
(task: ScheduledTask, e: React.MouseEvent) => {
closeListContextMenu()
setContextTask(task)
handleTaskCtxMenu(e)
},
[closeListContextMenu, handleTaskCtxMenu]
)
/** Opens the right-clicked task's modal (edit for pending, record otherwise). */
const openContextTask = useCallback(() => {
if (contextTask) handleOpenTask(contextTask)
}, [contextTask, handleOpenTask])
const handlePauseContextTask = useCallback(() => {
if (contextTask) tasks.pauseTask(contextTask.scheduleId)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [contextTask])
const handleResumeContextTask = useCallback(() => {
if (contextTask) tasks.resumeTask(contextTask.scheduleId)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [contextTask])
const handleContentContextMenu = useCallback(
(e: React.MouseEvent) => {
const target = e.target as HTMLElement
if (
target.closest('[data-resource-row]') ||
target.closest('button, input, a, [role="button"]')
) {
return
}
handleListContextMenu(e)
},
[handleListContextMenu]
)
const headerActions: ResourceAction[] = useMemo(
() => [
{
text: 'New scheduled task',
icon: Plus,
onSelect: handleOpenCreate,
variant: 'primary',
},
],
[handleOpenCreate]
)
return (
<>
<Resource onContextMenu={handleContentContextMenu}>
<Resource.Header icon={Calendar} title='Scheduled Tasks' actions={headerActions} />
<ScheduleCalendar
scope={calendar.scope}
anchor={calendar.anchor}
today={calendar.today}
timezone={timezone}
onScopeChange={calendar.setScope}
onPrev={calendar.prev}
onNext={calendar.next}
onToday={calendar.goToday}
onSelectDate={calendar.goToDate}
onSelectSlot={handleSelectSlot}
onSelectTask={handleOpenTask}
onTaskContextMenu={handleTaskContextMenu}
onShowDay={calendar.openDay}
eventsByDay={tasks.eventsByDay}
/>
</Resource>
<ScheduleListContextMenu
isOpen={isListContextMenuOpen}
position={listContextMenuPosition}
onClose={closeListContextMenu}
onCreateSchedule={handleOpenCreate}
/>
<TaskContextMenu
isOpen={isTaskContextMenuOpen}
position={taskContextMenuPosition}
onClose={closeTaskContextMenu}
task={contextTask}
onEdit={openContextTask}
onDuplicate={handleDuplicate}
onPause={handlePauseContextTask}
onResume={handleResumeContextTask}
onDelete={() => setDeletingTask(contextTask)}
/>
<TaskDeleteDialog
task={deletingTask}
onClose={() => setDeletingTask(null)}
onDeleteOccurrence={(task) => tasks.deleteOccurrence(task.scheduleId, task.runAt)}
onDeleteSeries={(task) => tasks.deleteTask(task.scheduleId)}
/>
<TaskModal
open={calendar.isCreateOpen || duplicatePrefill !== null}
onOpenChange={(open) => {
if (!open) {
calendar.closeCreate()
setDuplicatePrefill(null)
}
}}
slot={duplicatePrefill ? null : calendar.selectedSlot}
prefill={duplicatePrefill}
onSubmit={tasks.createTask}
/>
<TaskModal
open={editTask !== null && editSeed !== null}
onOpenChange={(open) => {
if (!open) tasks.closeTask()
}}
edit={editSeed}
onSubmit={(draft) => {
if (editTask) return tasks.updateTask(editTask.scheduleId, draft)
}}
onRequestDelete={() => {
setDeletingTask(editTask)
tasks.closeTask()
}}
/>
<TaskDetailsModal task={recordTask} onClose={tasks.closeTask} />
</>
)
}
@@ -0,0 +1,61 @@
import { createParser, parseAsStringLiteral } from 'nuqs/server'
const CALENDAR_SCOPES = ['day', 'week', 'month'] as const
/** Default calendar granularity; matches the prior `useState` initial scope. */
export const DEFAULT_CALENDAR_SCOPE = 'week'
const pad2 = (n: number) => String(n).padStart(2, '0')
/**
* Local-time date-only parser (`yyyy-MM-dd`).
*
* Unlike nuqs's built-in `parseAsIsoDate` — which serializes via `toISOString()`
* and parses to **UTC** midnight — this reads and writes the date using the
* browser's **local** calendar fields. The calendar's `anchor` is a local-time
* `Date` (`zonedClockDate`) and all the grid math (`date-fns`) is local, so a
* UTC-based parser shifts the day by ±1 in any non-UTC timezone on
* reload/deep-link/back-forward. This local parser round-trips losslessly against
* that local-time math.
*/
const parseAsLocalDate = createParser<Date>({
parse(value) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value)
if (!match) return null
const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]))
return Number.isNaN(date.getTime()) ? null : date
},
serialize(value) {
return `${value.getFullYear()}-${pad2(value.getMonth() + 1)}-${pad2(value.getDate())}`
},
eq(a, b) {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
)
},
})
/**
* Co-located, typed URL query-param definitions for the scheduled-tasks calendar.
*
* - `scope` is the calendar granularity (`day` / `week` / `month`).
* - `anchor` is the focused day, stored date-only (`yyyy-MM-dd`) via the
* local-time {@link parseAsLocalDate} so it matches the calendar's local-time
* date math (no timezone day-shift). It is intentionally **nullable** (no
* `.withDefault`): the default anchor is "today", which is dynamic and resolved
* per-timezone in the hook (`anchor = param ?? zonedClockDate(now, tz)`). A
* clean URL therefore means "today", and navigating back to today clears the
* param.
*/
export const calendarParsers = {
scope: parseAsStringLiteral(CALENDAR_SCOPES).withDefault(DEFAULT_CALENDAR_SCOPE),
anchor: parseAsLocalDate,
} as const
/** Calendar view-state: clean URLs, no back-stack churn. */
export const calendarUrlKeys = {
history: 'replace',
clearOnDefault: true,
} as const
@@ -0,0 +1,149 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
advanceAnchor,
buildCalendarGrid,
EVENT_CHIP_HEIGHT,
formatHourLabel,
formatScopeLabel,
formatSlotTime,
HOURS,
layoutColumn,
TIME_SLOT_HEIGHT,
timeToOffset,
visibleRange,
WEEKDAY_LABELS,
} from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/calendar-grid'
// June 10, 2026 is a Wednesday. June 1, 2026 is a Monday.
const ANCHOR = new Date(2026, 5, 10)
const TODAY = new Date(2026, 5, 10)
describe('buildCalendarGrid', () => {
it('builds a Sunday-first month grid with spillover days', () => {
const grid = buildCalendarGrid('month', ANCHOR, TODAY)
if (grid.kind !== 'month') throw new Error('expected month grid')
// May 31 (Sun) → Jul 4 (Sat) = 5 full weeks.
expect(grid.weeks).toHaveLength(5)
expect(grid.weeks.every((week) => week.length === 7)).toBe(true)
const first = grid.weeks[0][0]
expect(first.date).toEqual(new Date(2026, 4, 31))
expect(first.isCurrentMonth).toBe(false)
const flat = grid.weeks.flat()
const tenth = flat.find((cell) => cell.date.getDate() === 10 && cell.isCurrentMonth)
expect(tenth?.isToday).toBe(true)
expect(flat.filter((cell) => cell.isToday)).toHaveLength(1)
})
it('builds a 7-day week grid starting Sunday with 24 hours', () => {
const grid = buildCalendarGrid('week', ANCHOR, TODAY)
if (grid.kind !== 'week') throw new Error('expected week grid')
expect(grid.days).toHaveLength(7)
expect(grid.days[0].date).toEqual(new Date(2026, 5, 7)) // Sunday
expect(grid.hours).toEqual(HOURS)
})
it('builds a single-day grid for the anchor', () => {
const grid = buildCalendarGrid('day', ANCHOR, TODAY)
if (grid.kind !== 'day') throw new Error('expected day grid')
expect(grid.day.date).toEqual(ANCHOR)
expect(grid.day.isToday).toBe(true)
expect(grid.hours).toHaveLength(24)
})
})
describe('visibleRange', () => {
it('pads the rendered span by a day each side to cover timezone offset slop', () => {
// Week of Jun 713, 2026 (SunSat); padded to Jun 6 → Jun 14.
const { start, end } = visibleRange('week', ANCHOR)
expect(start).toEqual(new Date(2026, 5, 6))
expect(end.getDate()).toBe(14)
})
it('pads the single-day span to the neighboring days', () => {
const { start, end } = visibleRange('day', ANCHOR)
expect(start.getDate()).toBe(9)
expect(end.getDate()).toBe(11)
})
})
describe('advanceAnchor', () => {
it('advances by the unit of the scope', () => {
expect(advanceAnchor(ANCHOR, 'month', 1)).toEqual(new Date(2026, 6, 10))
expect(advanceAnchor(ANCHOR, 'week', 1)).toEqual(new Date(2026, 5, 17))
expect(advanceAnchor(ANCHOR, 'day', -1)).toEqual(new Date(2026, 5, 9))
})
})
describe('formatScopeLabel', () => {
it('formats per scope', () => {
expect(formatScopeLabel('month', ANCHOR)).toBe('June 2026')
expect(formatScopeLabel('week', ANCHOR)).toBe('Jun 7 13, 2026')
expect(formatScopeLabel('day', ANCHOR)).toBe('June 10, 2026')
})
})
describe('hour helpers', () => {
it('formats 24h slot times and 12h gutter labels', () => {
expect(formatSlotTime(7)).toBe('07:00')
expect(formatSlotTime(0)).toBe('00:00')
expect(formatHourLabel(0)).toBe('12 AM')
expect(formatHourLabel(13)).toBe('1 PM')
})
it('rotates weekday labels to Sunday-first', () => {
expect(WEEKDAY_LABELS[0]).toBe('Sun')
expect(WEEKDAY_LABELS).toHaveLength(7)
})
})
describe('timeToOffset', () => {
it('maps a moment in the day to a pixel offset from the slots top', () => {
expect(timeToOffset(new Date(2026, 5, 10, 0, 0))).toBe(0)
expect(timeToOffset(new Date(2026, 5, 10, 1, 0))).toBe(TIME_SLOT_HEIGHT)
expect(timeToOffset(new Date(2026, 5, 10, 6, 30))).toBe(6.5 * TIME_SLOT_HEIGHT)
expect(timeToOffset(new Date(2026, 5, 10, 23, 0))).toBe(23 * TIME_SLOT_HEIGHT)
})
})
describe('layoutColumn', () => {
const at = (h: number, m: number) => ({ start: new Date(2026, 5, 15, h, m) })
it('keeps non-overlapping events full width in a single lane', () => {
const placed = layoutColumn([at(9, 0), at(11, 0)], EVENT_CHIP_HEIGHT)
expect(placed.map((p) => ({ lane: p.lane, lanes: p.lanes }))).toEqual([
{ lane: 0, lanes: 1 },
{ lane: 0, lanes: 1 },
])
})
it('splits events within one pill-height of each other into side-by-side lanes', () => {
const placed = layoutColumn([at(9, 0), at(9, 10)], EVENT_CHIP_HEIGHT)
expect(placed.map((p) => ({ lane: p.lane, lanes: p.lanes }))).toEqual([
{ lane: 0, lanes: 2 },
{ lane: 1, lanes: 2 },
])
})
it('reuses a freed lane after the overlap clears and resets the cluster', () => {
const placed = layoutColumn([at(9, 0), at(9, 10), at(12, 0)], EVENT_CHIP_HEIGHT)
expect(placed.map((p) => ({ lane: p.lane, lanes: p.lanes }))).toEqual([
{ lane: 0, lanes: 2 },
{ lane: 1, lanes: 2 },
{ lane: 0, lanes: 1 },
])
})
it('sorts by start time before assigning lanes', () => {
const placed = layoutColumn([at(9, 10), at(9, 0)], EVENT_CHIP_HEIGHT)
expect(placed[0].item).toEqual(at(9, 0))
expect(placed[1].item).toEqual(at(9, 10))
})
})
@@ -0,0 +1,237 @@
import {
addDays,
addMonths,
addWeeks,
eachDayOfInterval,
endOfDay,
endOfMonth,
endOfWeek,
format,
isSameDay,
isSameMonth,
startOfDay,
startOfMonth,
startOfWeek,
} from 'date-fns'
/** The granularity the calendar is currently rendering. */
export type CalendarScope = 'day' | 'week' | 'month'
/** A single day rendered in any view (month cell, week/day column header). */
export interface CalendarDayCell {
date: Date
isToday: boolean
/** `false` for leading/trailing spillover days outside the focused month. */
isCurrentMonth: boolean
}
export interface MonthGrid {
kind: 'month'
/** Calendar rows (46) of 7 day cells each, including spillover days. */
weeks: CalendarDayCell[][]
}
export interface WeekGrid {
kind: 'week'
days: CalendarDayCell[]
hours: number[]
}
export interface DayGrid {
kind: 'day'
day: CalendarDayCell
hours: number[]
}
export type CalendarGrid = MonthGrid | WeekGrid | DayGrid
/** Sunday-first, matching the emcn `Calendar` picker. */
export const WEEK_STARTS_ON = 0 as const
/** Hours of the day rendered as rows in the week/day time grid. */
export const HOURS: number[] = Array.from({ length: 24 }, (_, hour) => hour)
/** Fixed pixel height of one hour row in the time grid. */
export const TIME_SLOT_HEIGHT = 48
/** Rendered height of a task pill in the time grid, used for overlap detection. */
export const EVENT_CHIP_HEIGHT = 22
const BASE_WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as const
/** Weekday header labels rotated to honor {@link WEEK_STARTS_ON}. */
export const WEEKDAY_LABELS: string[] = [
...BASE_WEEKDAYS.slice(WEEK_STARTS_ON),
...BASE_WEEKDAYS.slice(0, WEEK_STARTS_ON),
]
function toCell(date: Date, today: Date, anchor?: Date): CalendarDayCell {
return {
date,
isToday: isSameDay(date, today),
isCurrentMonth: anchor ? isSameMonth(date, anchor) : true,
}
}
/** Move the anchor date forward (`delta > 0`) or back by one unit of `scope`. */
export function advanceAnchor(anchor: Date, scope: CalendarScope, delta: number): Date {
switch (scope) {
case 'month':
return addMonths(anchor, delta)
case 'week':
return addWeeks(anchor, delta)
case 'day':
return addDays(anchor, delta)
}
}
function buildMonthGrid(anchor: Date, today: Date): MonthGrid {
const start = startOfWeek(startOfMonth(anchor), { weekStartsOn: WEEK_STARTS_ON })
const end = endOfWeek(endOfMonth(anchor), { weekStartsOn: WEEK_STARTS_ON })
const days = eachDayOfInterval({ start, end })
const weeks: CalendarDayCell[][] = []
for (let i = 0; i < days.length; i += 7) {
weeks.push(days.slice(i, i + 7).map((date) => toCell(date, today, anchor)))
}
return { kind: 'month', weeks }
}
function buildWeekGrid(anchor: Date, today: Date): WeekGrid {
const start = startOfWeek(anchor, { weekStartsOn: WEEK_STARTS_ON })
const end = endOfWeek(anchor, { weekStartsOn: WEEK_STARTS_ON })
const days = eachDayOfInterval({ start, end }).map((date) => toCell(date, today))
return { kind: 'week', days, hours: HOURS }
}
function buildDayGrid(anchor: Date, today: Date): DayGrid {
return { kind: 'day', day: toCell(anchor, today), hours: HOURS }
}
/**
* Pure, React-free derivation of the renderable grid for a given scope and
* anchor. `today` is passed in (never read from the clock here) so the result
* is fully deterministic and unit-testable.
*/
export function buildCalendarGrid(scope: CalendarScope, anchor: Date, today: Date): CalendarGrid {
switch (scope) {
case 'month':
return buildMonthGrid(anchor, today)
case 'week':
return buildWeekGrid(anchor, today)
case 'day':
return buildDayGrid(anchor, today)
}
}
/** The day span the current view renders (month spans its 46 spillover weeks). */
function visibleSpan(scope: CalendarScope, anchor: Date): { start: Date; end: Date } {
switch (scope) {
case 'month':
return {
start: startOfWeek(startOfMonth(anchor), { weekStartsOn: WEEK_STARTS_ON }),
end: endOfWeek(endOfMonth(anchor), { weekStartsOn: WEEK_STARTS_ON }),
}
case 'week':
return {
start: startOfWeek(anchor, { weekStartsOn: WEEK_STARTS_ON }),
end: endOfWeek(anchor, { weekStartsOn: WEEK_STARTS_ON }),
}
case 'day':
return { start: startOfDay(anchor), end: endOfDay(anchor) }
}
}
/**
* The instant window that bounds recurrence expansion for the current view,
* padded one day past the rendered span on each side. The grid frame is in the
* viewer's zone, but each occurrence is positioned in its task's own zone, so an
* instant up to a full UTC offset (≤14h) outside the rendered span can still
* fall on a visible day. The pad guarantees those boundary occurrences are
* expanded; `bucketEventsByDay` then places each on its zoned day, so any that
* land off-screen sit in an unrendered bucket and never show.
*/
export function visibleRange(scope: CalendarScope, anchor: Date): { start: Date; end: Date } {
const span = visibleSpan(scope, anchor)
return { start: addDays(span.start, -1), end: addDays(span.end, 1) }
}
/** Toolbar period label, e.g. `June 2026`, `Jun 7 13, 2026`, `June 10, 2026`. */
export function formatScopeLabel(scope: CalendarScope, anchor: Date): string {
if (scope === 'month') return format(anchor, 'MMMM yyyy')
if (scope === 'day') return format(anchor, 'MMMM d, yyyy')
const start = startOfWeek(anchor, { weekStartsOn: WEEK_STARTS_ON })
const end = endOfWeek(anchor, { weekStartsOn: WEEK_STARTS_ON })
if (isSameMonth(start, end)) return `${format(start, 'MMM d')} ${format(end, 'd, yyyy')}`
return `${format(start, 'MMM d')} ${format(end, 'MMM d, yyyy')}`
}
/** Display label for an hour-of-day gutter row, e.g. `7 AM`, `12 PM`. */
export function formatHourLabel(hour: number): string {
return format(new Date(2000, 0, 1, hour), 'h a')
}
/**
* Vertical pixel offset of a moment within the day, measured from the top of the
* time grid's slot stack (the `00:00` row). Positions the current-time
* indicator. Pure and clock-free — `date` is passed in so callers control "now".
*/
export function timeToOffset(date: Date): number {
return (date.getHours() + date.getMinutes() / 60) * TIME_SLOT_HEIGHT
}
/** Wire-format time string for an hour slot, e.g. `07:00`. */
export function formatSlotTime(hour: number): string {
return `${hour.toString().padStart(2, '0')}:00`
}
/** A time-grid item placed at its minute, with its column slot within an overlap cluster. */
export interface PlacedEvent<T> {
item: T
/** Pixel offset from the top of the day column. */
topPx: number
/** 0-based column index within the overlap cluster. */
lane: number
/** Total columns the overlap cluster spans (1 when nothing overlaps). */
lanes: number
}
/**
* Google-Calendar-style lane assignment for events sharing a day column. Items
* whose pill rectangles (`[topPx, topPx + chipHeight]`) intersect form a cluster
* and split the width into side-by-side lanes; non-overlapping items keep the
* full width. Pure: positions come from {@link timeToOffset}.
*/
export function layoutColumn<T extends { start: Date }>(
items: T[],
chipHeight: number
): PlacedEvent<T>[] {
const sorted = [...items].sort((a, b) => a.start.getTime() - b.start.getTime())
const placed: PlacedEvent<T>[] = []
let cluster: PlacedEvent<T>[] = []
let laneBottoms: number[] = []
const closeCluster = () => {
for (const entry of cluster) entry.lanes = laneBottoms.length
cluster = []
laneBottoms = []
}
for (const item of sorted) {
const topPx = timeToOffset(item.start)
if (laneBottoms.length > 0 && laneBottoms.every((bottom) => topPx >= bottom)) {
closeCluster()
}
let lane = laneBottoms.findIndex((bottom) => topPx >= bottom)
if (lane === -1) {
lane = laneBottoms.length
laneBottoms.push(topPx + chipHeight)
} else {
laneBottoms[lane] = topPx + chipHeight
}
const entry: PlacedEvent<T> = { item, topPx, lane, lanes: 1 }
cluster.push(entry)
placed.push(entry)
}
closeCluster()
return placed
}
@@ -0,0 +1,337 @@
import { describe, expect, it } from 'vitest'
import {
cronToRecurrence,
expandOccurrences,
type Recurrence,
recurrenceToCron,
recurrenceToScheduleFields,
} from './recurrence'
const once: Recurrence = { frequency: 'once', weekdays: [], end: { type: 'never' } }
describe('recurrenceToCron', () => {
it('returns null for a one-time task', () => {
expect(recurrenceToCron(once, '2026-06-15', '09:30')).toBeNull()
})
it('builds a daily expression at the launch time', () => {
expect(
recurrenceToCron(
{ frequency: 'daily', weekdays: [], end: { type: 'never' } },
'2026-06-15',
'09:30'
)
).toBe('30 9 * * *')
})
it('builds a weekly expression from the selected weekdays, sorted and deduped', () => {
expect(
recurrenceToCron(
{ frequency: 'weekly', weekdays: [3, 1, 1], end: { type: 'never' } },
'2026-06-15',
'08:00'
)
).toBe('0 8 * * 1,3')
})
it('builds a monthly expression from the launch day-of-month', () => {
expect(
recurrenceToCron(
{ frequency: 'monthly', weekdays: [], end: { type: 'never' } },
'2026-06-15',
'07:05'
)
).toBe('5 7 15 * *')
})
it('builds a monthly nth-weekday expression (2026-06-15 is the third Monday)', () => {
expect(
recurrenceToCron(
{ frequency: 'monthly', weekdays: [], monthlyMode: 'nth-weekday', end: { type: 'never' } },
'2026-06-15',
'09:30'
)
).toBe('30 9 * * 1#3')
})
it('builds a monthly last-weekday expression', () => {
expect(
recurrenceToCron(
{ frequency: 'monthly', weekdays: [], monthlyMode: 'last-weekday', end: { type: 'never' } },
'2026-06-29',
'09:30'
)
).toBe('30 9 * * 1#L')
})
it('clamps a 5th-occurrence nth-weekday to last-weekday so no month is skipped', () => {
// 2026-06-29 is the fifth Monday of June; `#5` would skip months without one.
expect(
recurrenceToCron(
{ frequency: 'monthly', weekdays: [], monthlyMode: 'nth-weekday', end: { type: 'never' } },
'2026-06-29',
'09:30'
)
).toBe('30 9 * * 1#L')
})
it('builds a yearly expression from the launch month and day', () => {
expect(
recurrenceToCron(
{ frequency: 'yearly', weekdays: [], end: { type: 'never' } },
'2026-06-15',
'09:30'
)
).toBe('30 9 15 6 *')
})
it('preserves a custom expression verbatim', () => {
expect(
recurrenceToCron(
{ frequency: 'custom', weekdays: [], end: { type: 'never' }, cron: '*/5 * * * *' },
'2026-06-15',
'09:00'
)
).toBe('*/5 * * * *')
})
})
describe('recurrenceToScheduleFields', () => {
it('resolves a one-time launch to the UTC instant of that wall-clock in the zone', () => {
const fields = recurrenceToScheduleFields(once, '2026-06-15', '09:00', 'America/New_York')
expect(fields.cronExpression).toBeNull()
expect(fields.time).toBe('2026-06-15T13:00:00.000Z')
expect(fields.lifecycle).toBe('persistent')
})
it('maps "ends after N" to maxRuns with an until_complete lifecycle', () => {
const fields = recurrenceToScheduleFields(
{ frequency: 'daily', weekdays: [], end: { type: 'after', count: 5 } },
'2026-06-15',
'09:00',
'UTC'
)
expect(fields.cronExpression).toBe('0 9 * * *')
expect(fields.maxRuns).toBe(5)
expect(fields.lifecycle).toBe('until_complete')
expect(fields.endsAt).toBeUndefined()
})
it('maps "ends on date" to an end-of-day boundary in the zone', () => {
const fields = recurrenceToScheduleFields(
{ frequency: 'daily', weekdays: [], end: { type: 'on', date: '2026-07-01' } },
'2026-06-15',
'09:00',
'UTC'
)
expect(fields.endsAt).toBe('2026-07-01T23:59:59.000Z')
expect(fields.maxRuns).toBeUndefined()
expect(fields.lifecycle).toBe('persistent')
})
})
describe('cronToRecurrence', () => {
const anchor = new Date('2026-06-15T09:00:00Z')
it('recovers a one-time task from a null cron', () => {
const { recurrence } = cronToRecurrence({
cronExpression: null,
maxRuns: null,
endsAt: null,
anchor,
timezone: 'UTC',
})
expect(recurrence.frequency).toBe('once')
})
it('recovers daily, weekly, and monthly cadences', () => {
expect(
cronToRecurrence({
cronExpression: '30 9 * * *',
maxRuns: null,
endsAt: null,
anchor,
timezone: 'UTC',
}).recurrence.frequency
).toBe('daily')
const weekly = cronToRecurrence({
cronExpression: '0 8 * * 1,3',
maxRuns: null,
endsAt: null,
anchor,
timezone: 'UTC',
}).recurrence
expect(weekly.frequency).toBe('weekly')
expect(weekly.weekdays).toEqual([1, 3])
const monthly = cronToRecurrence({
cronExpression: '5 7 15 * *',
maxRuns: null,
endsAt: null,
anchor,
timezone: 'UTC',
}).recurrence
expect(monthly.frequency).toBe('monthly')
expect(monthly.monthlyMode).toBe('day-of-month')
})
it('recovers monthly nth-weekday, monthly last-weekday, and yearly cadences', () => {
const nthWeekday = cronToRecurrence({
cronExpression: '30 9 * * 1#3',
maxRuns: null,
endsAt: null,
anchor,
timezone: 'UTC',
}).recurrence
expect(nthWeekday.frequency).toBe('monthly')
expect(nthWeekday.monthlyMode).toBe('nth-weekday')
const lastWeekday = cronToRecurrence({
cronExpression: '30 9 * * 1#L',
maxRuns: null,
endsAt: null,
anchor,
timezone: 'UTC',
}).recurrence
expect(lastWeekday.frequency).toBe('monthly')
expect(lastWeekday.monthlyMode).toBe('last-weekday')
expect(
cronToRecurrence({
cronExpression: '30 9 15 6 *',
maxRuns: null,
endsAt: null,
anchor,
timezone: 'UTC',
}).recurrence.frequency
).toBe('yearly')
})
it("accepts croner's alternate Sunday digit (7) for monthly weekday anchors", () => {
const nth = cronToRecurrence({
cronExpression: '30 9 * * 7#3',
maxRuns: null,
endsAt: null,
anchor,
timezone: 'UTC',
}).recurrence
expect(nth.frequency).toBe('monthly')
expect(nth.monthlyMode).toBe('nth-weekday')
const last = cronToRecurrence({
cronExpression: '30 9 * * 7#L',
maxRuns: null,
endsAt: null,
anchor,
timezone: 'UTC',
}).recurrence
expect(last.frequency).toBe('monthly')
expect(last.monthlyMode).toBe('last-weekday')
})
it('leaves a 5th-occurrence (#5) cron as custom so its month-skipping is preserved', () => {
const { recurrence } = cronToRecurrence({
cronExpression: '30 9 * * 1#5',
maxRuns: null,
endsAt: null,
anchor,
timezone: 'UTC',
})
expect(recurrence.frequency).toBe('custom')
expect(recurrence.cron).toBe('30 9 * * 1#5')
})
it('falls back to custom for an expression it did not author', () => {
const { recurrence } = cronToRecurrence({
cronExpression: '*/5 * * * *',
maxRuns: null,
endsAt: null,
anchor,
timezone: 'UTC',
})
expect(recurrence.frequency).toBe('custom')
expect(recurrence.cron).toBe('*/5 * * * *')
})
it('recovers the end boundary from maxRuns and endsAt', () => {
expect(
cronToRecurrence({
cronExpression: '0 9 * * *',
maxRuns: 5,
endsAt: null,
anchor,
timezone: 'UTC',
}).recurrence.end
).toEqual({ type: 'after', count: 5 })
expect(
cronToRecurrence({
cronExpression: '0 9 * * *',
maxRuns: null,
endsAt: '2026-07-01T23:59:59Z',
anchor,
timezone: 'UTC',
}).recurrence.end
).toEqual({ type: 'on', date: '2026-07-01' })
})
})
describe('expandOccurrences', () => {
const base = {
cronExpression: '0 12 * * *',
timezone: 'UTC',
rangeStart: new Date('2026-06-01T00:00:00Z'),
rangeEnd: new Date('2026-06-03T23:59:59Z'),
from: new Date('2026-05-31T00:00:00Z'),
}
it('materializes every upcoming occurrence inside the range', () => {
const occurrences = expandOccurrences(base)
expect(occurrences.map((d) => d.toISOString())).toEqual([
'2026-06-01T12:00:00.000Z',
'2026-06-02T12:00:00.000Z',
'2026-06-03T12:00:00.000Z',
])
})
it('skips excluded occurrences', () => {
const occurrences = expandOccurrences({ ...base, excludedDates: ['2026-06-02T12:00:00.000Z'] })
expect(occurrences.map((d) => d.toISOString())).toEqual([
'2026-06-01T12:00:00.000Z',
'2026-06-03T12:00:00.000Z',
])
})
it('stops at the recurrence end boundary', () => {
const occurrences = expandOccurrences({ ...base, endsAt: new Date('2026-06-02T12:00:00Z') })
expect(occurrences.map((d) => d.toISOString())).toEqual([
'2026-06-01T12:00:00.000Z',
'2026-06-02T12:00:00.000Z',
])
})
it('omits occurrences that already passed relative to `from`', () => {
const occurrences = expandOccurrences({ ...base, from: new Date('2026-06-02T13:00:00Z') })
expect(occurrences.map((d) => d.toISOString())).toEqual(['2026-06-03T12:00:00.000Z'])
})
it('materializes a monthly nth-weekday cron (third Monday of each month)', () => {
const occurrences = expandOccurrences({
cronExpression: '30 9 * * 1#3',
timezone: 'UTC',
rangeStart: new Date('2026-06-01T00:00:00Z'),
rangeEnd: new Date('2026-08-31T23:59:59Z'),
from: new Date('2026-05-31T00:00:00Z'),
})
expect(occurrences.map((d) => d.toISOString())).toEqual([
'2026-06-15T09:30:00.000Z',
'2026-07-20T09:30:00.000Z',
'2026-08-17T09:30:00.000Z',
])
})
it('returns nothing for an invalid expression instead of throwing', () => {
expect(expandOccurrences({ ...base, cronExpression: 'not-a-cron' })).toEqual([])
})
})
@@ -0,0 +1,269 @@
import { Cron } from 'croner'
import { zonedWallClock, zonedWallClockToUtc } from '@/lib/core/utils/timezone'
/**
* Recurrence cadence the modal exposes. `once` is a one-time launch; `custom`
* preserves a cron expression the UI did not author (e.g. a task created
* conversationally) so editing never silently rewrites it.
*/
export type RecurrenceFrequency = 'once' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom'
/**
* How a monthly recurrence anchors within the month, mirroring a calendar app's
* monthly sub-options. `day-of-month` repeats on the launch date's day number
* (e.g. the 15th); `nth-weekday` on the same ordinal weekday (e.g. the third
* Tuesday, croner `2#3`); `last-weekday` on the final weekday of that kind (e.g.
* the last Tuesday, croner `2#L`). The weekday and ordinal are read from the
* launch date at cron-build time — on edit the launch date is an actual
* occurrence, so the mode round-trips to the same day.
*/
export type MonthlyMode = 'day-of-month' | 'nth-weekday' | 'last-weekday'
/** When a recurrence stops, mirroring the three calendar-app end options. */
export type RecurrenceEnd =
| { type: 'never' }
| { type: 'on'; date: string }
| { type: 'after'; count: number }
export interface Recurrence {
frequency: RecurrenceFrequency
/** Weekly only: weekdays 0 (Sun) 6 (Sat). Empty falls back to the launch day's weekday. */
weekdays: number[]
/** Monthly only: how it anchors within the month. Defaults to `day-of-month`. */
monthlyMode?: MonthlyMode
end: RecurrenceEnd
/** `custom` only: the raw cron expression, passed through unchanged on save. */
cron?: string
}
export const DEFAULT_RECURRENCE: Recurrence = {
frequency: 'once',
weekdays: [],
end: { type: 'never' },
}
/** Upper bound on occurrences materialized for one schedule in a single view. */
const MAX_OCCURRENCES_PER_VIEW = 500
/**
* Builds the cron expression for a recurrence, evaluated in the schedule's
* timezone against the launch day/time. Returns `null` for a one-time task and
* the preserved expression for a `custom` recurrence. The weekday/day-of-month
* are read from the launch date as a zone-independent calendar date (UTC parse),
* so the cron targets the right day regardless of the device zone.
*/
export function recurrenceToCron(
recurrence: Recurrence,
launchDate: string,
launchTime: string
): string | null {
if (recurrence.frequency === 'once') return null
if (recurrence.frequency === 'custom') return recurrence.cron ?? null
const [hour, minute] = launchTime.split(':').map(Number)
const launchDay = new Date(`${launchDate}T00:00:00Z`)
switch (recurrence.frequency) {
case 'daily':
return `${minute} ${hour} * * *`
case 'weekly': {
const days = recurrence.weekdays.length > 0 ? recurrence.weekdays : [launchDay.getUTCDay()]
return `${minute} ${hour} * * ${[...new Set(days)].sort((a, b) => a - b).join(',')}`
}
case 'monthly': {
const weekday = launchDay.getUTCDay()
switch (recurrence.monthlyMode ?? 'day-of-month') {
case 'nth-weekday': {
// A 5th occurrence is always the last weekday of the month; emit `#L`
// rather than `#5` so no month without a 5th occurrence is silently
// skipped (the picker only offers nth for the 1st4th, but the launch
// date can still drift to a 5th via the footer date picker).
const nth = Math.ceil(launchDay.getUTCDate() / 7)
return nth >= 5
? `${minute} ${hour} * * ${weekday}#L`
: `${minute} ${hour} * * ${weekday}#${nth}`
}
case 'last-weekday':
return `${minute} ${hour} * * ${weekday}#L`
default:
return `${minute} ${hour} ${launchDay.getUTCDate()} * *`
}
}
case 'yearly':
return `${minute} ${hour} ${launchDay.getUTCDate()} ${launchDay.getUTCMonth() + 1} *`
}
}
export interface ScheduleFields {
cronExpression: string | null
time?: string
maxRuns?: number
endsAt?: string
lifecycle: 'persistent' | 'until_complete'
}
/**
* Translates a recurrence + launch into the wire fields the schedules API
* accepts: a one-time `time`, or a `cronExpression` with an optional end
* boundary (`maxRuns` for "after N", `endsAt` for "on date"). The launch
* date/time and end date are wall-clock in `timezone`, so they resolve to UTC
* instants in that zone — matching how the recurring cron is evaluated.
*/
export function recurrenceToScheduleFields(
recurrence: Recurrence,
launchDate: string,
launchTime: string,
timezone: string
): ScheduleFields {
const cronExpression = recurrenceToCron(recurrence, launchDate, launchTime)
if (!cronExpression) {
return {
cronExpression: null,
time: zonedWallClockToUtc(`${launchDate}T${launchTime}`, timezone).toISOString(),
lifecycle: 'persistent',
}
}
const { end } = recurrence
return {
cronExpression,
maxRuns: end.type === 'after' ? end.count : undefined,
endsAt:
end.type === 'on'
? zonedWallClockToUtc(`${end.date}T23:59:59`, timezone).toISOString()
: undefined,
lifecycle: end.type === 'after' ? 'until_complete' : 'persistent',
}
}
const CRON_FIELD_COUNT = 5
/**
* Recovers the modal's recurrence + launch fields from a stored schedule so
* editing reflects what is persisted, read back in the schedule's `timezone`.
* A recurring task's launch clock comes from its cron; a one-time task's comes
* from the stored instant (`anchor`). A cron the UI did not author maps to
* `custom` and round-trips untouched.
*/
export function cronToRecurrence(params: {
cronExpression: string | null
maxRuns: number | null
endsAt: string | null
anchor: Date
timezone: string
}): { recurrence: Recurrence; launchTime: string } {
const { cronExpression, maxRuns, endsAt, anchor, timezone } = params
const end: RecurrenceEnd = endsAt
? { type: 'on', date: zonedWallClock(new Date(endsAt), timezone).slice(0, 10) }
: maxRuns
? { type: 'after', count: maxRuns }
: { type: 'never' }
const anchorTime = zonedWallClock(anchor, timezone).slice(11, 16)
if (!cronExpression) {
return {
recurrence: { frequency: 'once', weekdays: [], end },
launchTime: anchorTime,
}
}
const parts = cronExpression.trim().split(/\s+/)
if (parts.length !== CRON_FIELD_COUNT) {
return {
recurrence: { frequency: 'custom', weekdays: [], end, cron: cronExpression },
launchTime: anchorTime,
}
}
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts
const launchTime = `${hour.padStart(2, '0')}:${minute.padStart(2, '0')}`
const isNumeric = (value: string) => /^\d+$/.test(value)
const numbersAreValid = isNumeric(minute) && isNumeric(hour)
if (numbersAreValid && month === '*') {
if (dayOfMonth === '*' && dayOfWeek === '*') {
return { recurrence: { frequency: 'daily', weekdays: [], end }, launchTime }
}
if (dayOfMonth === '*' && /^[0-6](,[0-6])*$/.test(dayOfWeek)) {
const weekdays = dayOfWeek.split(',').map(Number)
return { recurrence: { frequency: 'weekly', weekdays, end }, launchTime }
}
// Accept croner's alternate Sunday digit (`7`) so externally-authored
// `7#…` crons round-trip; the picker canonicalizes them to `0#…` on save.
// A 5th occurrence (`#5`) is intentionally NOT matched — it falls through to
// `custom` so its month-skipping behavior is preserved verbatim rather than
// silently rewritten to `#L`.
if (dayOfMonth === '*' && /^[0-7]#[1-4]$/.test(dayOfWeek)) {
return {
recurrence: { frequency: 'monthly', weekdays: [], monthlyMode: 'nth-weekday', end },
launchTime,
}
}
if (dayOfMonth === '*' && /^[0-7]#L$/.test(dayOfWeek)) {
return {
recurrence: { frequency: 'monthly', weekdays: [], monthlyMode: 'last-weekday', end },
launchTime,
}
}
if (isNumeric(dayOfMonth) && dayOfWeek === '*') {
return {
recurrence: { frequency: 'monthly', weekdays: [], monthlyMode: 'day-of-month', end },
launchTime,
}
}
}
if (numbersAreValid && isNumeric(dayOfMonth) && isNumeric(month) && dayOfWeek === '*') {
return { recurrence: { frequency: 'yearly', weekdays: [], end }, launchTime }
}
return {
recurrence: { frequency: 'custom', weekdays: [], end, cron: cronExpression },
launchTime,
}
}
/**
* Materializes a recurring schedule's run instants inside `[rangeStart, rangeEnd]`
* that are still upcoming (after `from`), skipping individually deleted
* occurrences and stopping at the recurrence end. Pure given its inputs.
*
* The lower bound is inclusive: croner's `nextRun(date)` returns the first
* occurrence strictly after `date`, so the search starts one millisecond before
* the bound to admit an occurrence landing exactly on it.
*/
export function expandOccurrences(params: {
cronExpression: string
timezone: string
rangeStart: Date
rangeEnd: Date
from: Date
excludedDates?: string[] | null
endsAt?: Date | null
}): Date[] {
const { cronExpression, timezone, rangeStart, rangeEnd, from, excludedDates, endsAt } = params
let cron: Cron
try {
cron = new Cron(cronExpression, timezone ? { timezone } : undefined)
} catch {
return []
}
const excluded = new Set(
(excludedDates ?? []).map((iso) => new Date(iso).getTime()).filter((ms) => !Number.isNaN(ms))
)
const lowerBound = rangeStart.getTime() > from.getTime() ? rangeStart : from
const occurrences: Date[] = []
let cursor = new Date(lowerBound.getTime() - 1)
for (let i = 0; i < MAX_OCCURRENCES_PER_VIEW; i++) {
const next = cron.nextRun(cursor)
if (!next || next.getTime() > rangeEnd.getTime()) break
if (endsAt && next.getTime() > endsAt.getTime()) break
if (!excluded.has(next.getTime())) occurrences.push(next)
cursor = next
}
return occurrences
}
@@ -0,0 +1,220 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import type { WorkspaceScheduleRow } from '@/lib/api/contracts/schedules'
import {
bucketEventsByDay,
dayKey,
type ScheduledTask,
scheduleToTasks,
taskToCalendarEvent,
} from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events'
function makeTask(overrides: Partial<ScheduledTask>): ScheduledTask {
return {
id: 't1',
scheduleId: 's1',
prompt: 'Summarize yesterday',
runAt: new Date('2026-06-10T14:30:00.000Z'),
timezone: 'UTC',
status: 'pending',
recurring: false,
disabled: false,
...overrides,
}
}
const RANGE_START = new Date('2026-06-08T00:00:00.000Z')
const RANGE_END = new Date('2026-06-14T23:59:59.999Z')
const NOW = new Date('2026-06-10T00:00:00.000Z')
function makeRow(overrides: Partial<WorkspaceScheduleRow>): WorkspaceScheduleRow {
return {
id: 's1',
sourceType: 'job',
prompt: 'Summarize yesterday',
timezone: 'UTC',
status: 'active',
cronExpression: null,
nextRunAt: null,
lastRanAt: null,
lastFailedAt: null,
excludedDates: null,
endsAt: null,
contexts: null,
...overrides,
} as WorkspaceScheduleRow
}
describe('taskToCalendarEvent', () => {
it('positions the event at its wall-clock time in the task timezone and keeps the task', () => {
const task = makeTask({ id: 'abc', runAt: new Date('2026-06-10T14:30:00.000Z') })
const event = taskToCalendarEvent(task)
expect(event.id).toBe('abc')
// start is a device-local layout coordinate carrying the UTC wall clock (14:30).
expect(event.start.getFullYear()).toBe(2026)
expect(event.start.getMonth()).toBe(5)
expect(event.start.getDate()).toBe(10)
expect(event.start.getHours()).toBe(14)
expect(event.start.getMinutes()).toBe(30)
expect(event.title).toBe('Summarize yesterday')
expect(event.task).toBe(task)
})
it('shifts the position to the task timezone, not the run instant', () => {
const task = makeTask({
runAt: new Date('2026-06-10T14:30:00.000Z'),
timezone: 'America/New_York',
})
const event = taskToCalendarEvent(task)
// 14:30 UTC is 10:30 in New York (EDT, UTC-4) on this date.
expect(event.start.getHours()).toBe(10)
expect(event.start.getMinutes()).toBe(30)
expect(event.start.getDate()).toBe(10)
})
it('truncates long prompts and falls back to a default title', () => {
const longPrompt = 'x'.repeat(120)
const truncated = taskToCalendarEvent(makeTask({ prompt: longPrompt }))
expect(truncated.title.length).toBeLessThan(longPrompt.length)
const fallback = taskToCalendarEvent(makeTask({ prompt: ' ' }))
expect(fallback.title).toBe('Scheduled task')
})
it('derives the same event shape for every status', () => {
const statuses = ['pending', 'error', 'completed'] as const
const titles = statuses.map((status) => taskToCalendarEvent(makeTask({ status })).title)
expect(new Set(titles).size).toBe(1)
})
})
describe('scheduleToTasks', () => {
it('renders an active one-time task as a single pending occurrence at its next run', () => {
const tasks = scheduleToTasks(
makeRow({ nextRunAt: '2026-06-11T09:00:00.000Z' }),
RANGE_START,
RANGE_END,
NOW
)
expect(tasks).toHaveLength(1)
expect(tasks[0]).toMatchObject({ scheduleId: 's1', status: 'pending', recurring: false })
expect(tasks[0].runAt.toISOString()).toBe('2026-06-11T09:00:00.000Z')
})
it('renders a completed one-time task at its last run', () => {
const tasks = scheduleToTasks(
makeRow({ status: 'completed', lastRanAt: '2026-06-09T09:00:00.000Z' }),
RANGE_START,
RANGE_END,
NOW
)
expect(tasks).toHaveLength(1)
expect(tasks[0]).toMatchObject({
status: 'completed',
runAt: new Date('2026-06-09T09:00:00.000Z'),
})
})
it('marks the last run as error when the latest failure is at or after the last success', () => {
const tasks = scheduleToTasks(
makeRow({
status: 'completed',
lastRanAt: '2026-06-09T09:00:00.000Z',
lastFailedAt: '2026-06-09T09:00:00.000Z',
}),
RANGE_START,
RANGE_END,
NOW
)
expect(tasks[0].status).toBe('error')
})
it('omits a one-time task whose run falls outside the visible range', () => {
const tasks = scheduleToTasks(
makeRow({ nextRunAt: '2026-07-01T09:00:00.000Z' }),
RANGE_START,
RANGE_END,
NOW
)
expect(tasks).toHaveLength(0)
})
it('expands a recurring task into upcoming occurrences plus a last-run marker', () => {
const tasks = scheduleToTasks(
makeRow({
cronExpression: '0 12 * * *',
nextRunAt: '2026-06-10T12:00:00.000Z',
lastRanAt: '2026-06-09T12:00:00.000Z',
}),
RANGE_START,
RANGE_END,
NOW
)
const pending = tasks.filter((t) => t.status === 'pending')
const completed = tasks.filter((t) => t.status === 'completed')
expect(pending.length).toBe(5) // Jun 1014 noon (NOW is Jun 10 00:00)
expect(pending.every((t) => t.recurring)).toBe(true)
expect(completed).toHaveLength(1)
expect(completed[0].runAt.toISOString()).toBe('2026-06-09T12:00:00.000Z')
})
it('skips individually-deleted occurrences of a recurring task', () => {
const tasks = scheduleToTasks(
makeRow({
cronExpression: '0 12 * * *',
excludedDates: ['2026-06-12T12:00:00.000Z'],
}),
RANGE_START,
RANGE_END,
NOW
)
const runs = tasks.filter((t) => t.status === 'pending').map((t) => t.runAt.toISOString())
expect(runs).not.toContain('2026-06-12T12:00:00.000Z')
})
it('flags active recurring occurrences as not disabled', () => {
const tasks = scheduleToTasks(
makeRow({ cronExpression: '0 12 * * *' }),
RANGE_START,
RANGE_END,
NOW
)
const pending = tasks.filter((t) => t.status === 'pending')
expect(pending.length).toBeGreaterThan(0)
expect(pending.every((t) => !t.disabled)).toBe(true)
})
it('expands a paused recurring schedule as disabled occurrences so it stays resumable', () => {
const tasks = scheduleToTasks(
makeRow({ status: 'disabled', cronExpression: '0 12 * * *' }),
RANGE_START,
RANGE_END,
NOW
)
const pending = tasks.filter((t) => t.status === 'pending')
expect(pending.length).toBe(5) // Jun 1014 noon (NOW is Jun 10 00:00)
expect(pending.every((t) => t.disabled)).toBe(true)
})
it('produces nothing for a paused one-time task with no run history', () => {
const tasks = scheduleToTasks(makeRow({ status: 'disabled' }), RANGE_START, RANGE_END, NOW)
expect(tasks).toHaveLength(0)
})
})
describe('bucketEventsByDay', () => {
it('groups events by calendar day', () => {
const events = [
taskToCalendarEvent(makeTask({ id: 'a', runAt: new Date('2026-06-10T14:30:00.000Z') })),
taskToCalendarEvent(makeTask({ id: 'b', runAt: new Date('2026-06-10T14:45:00.000Z') })),
taskToCalendarEvent(makeTask({ id: 'c', runAt: new Date('2026-06-11T09:00:00.000Z') })),
]
const byDay = bucketEventsByDay(events)
expect(byDay.size).toBe(2)
expect(byDay.get(dayKey(events[0].start))).toHaveLength(2)
expect(byDay.get(dayKey(events[2].start))).toHaveLength(1)
})
})
@@ -0,0 +1,181 @@
import { truncate } from '@sim/utils/string'
import { format } from 'date-fns'
import type { WorkspaceScheduleRow } from '@/lib/api/contracts/schedules'
import { zonedClockDate } from '@/lib/core/utils/timezone'
import { expandOccurrences } from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/recurrence'
import type { ChatContext } from '@/stores/panel'
/**
* Lifecycle of a scheduled task occurrence: `pending` has not run yet, and
* `error`/`completed` are terminal outcomes of a past run.
*/
export type ScheduledTaskStatus = 'pending' | 'error' | 'completed'
/**
* One occurrence of a scheduled task as the calendar renders it. A recurring
* schedule expands into many of these; one-time tasks produce a single one.
*/
export interface ScheduledTask {
/** Occurrence-unique key for rendering — not the backend identifier. */
id: string
/** The persisted schedule id, used to edit or delete the task. */
scheduleId: string
/** The instruction Sim runs. Doubles as the calendar title. */
prompt: string
/** Resources the prompt `@`-mentions / skills it `/`-invokes, when any. */
contexts?: ChatContext[]
/** When this occurrence runs (`pending`) or ran (`completed`/`error`). */
runAt: Date
/** IANA timezone the launch time was captured in. */
timezone: string
status: ScheduledTaskStatus
/** Whether the task repeats — drives edit seeding and the delete dialog. */
recurring: boolean
/**
* Whether the parent schedule is paused. A paused recurring task still shows
* its upcoming occurrences (rendered dimmed) so it can be found and resumed;
* it will not run until resumed. Always `false` for past runs and one-time tasks.
*/
disabled: boolean
}
/**
* A scheduled task positioned on the calendar. Derived from a
* {@link ScheduledTask} via {@link taskToCalendarEvent}; keeps the full `task`
* for the click-through details modal.
*/
export interface CalendarEvent {
id: string
/**
* The occurrence's wall-clock position in the task's own timezone, as a
* device-local {@link zonedClockDate} — a layout coordinate, not the real
* instant. Keeps the calendar showing each task at the local time it was
* scheduled for, matching the modal. The true instant lives in `task.runAt`.
*/
start: Date
title: string
task: ScheduledTask
}
/** Bucket key for a day cell (`yyyy-MM-dd`). */
export function dayKey(date: Date): string {
return format(date, 'yyyy-MM-dd')
}
/** The most recent terminal run of a schedule, or `null` if it has never run. */
function lastRunMarker(
row: WorkspaceScheduleRow
): { at: Date; status: ScheduledTaskStatus } | null {
const ranAt = row.lastRanAt ? new Date(row.lastRanAt) : null
const failedAt = row.lastFailedAt ? new Date(row.lastFailedAt) : null
if (failedAt && (!ranAt || failedAt.getTime() >= ranAt.getTime())) {
return { at: failedAt, status: 'error' }
}
if (ranAt) return { at: ranAt, status: 'completed' }
return null
}
function withinRange(date: Date, rangeStart: Date, rangeEnd: Date): boolean {
return date.getTime() >= rangeStart.getTime() && date.getTime() <= rangeEnd.getTime()
}
/**
* Maps a persisted job schedule into the occurrences visible in `[rangeStart,
* rangeEnd]`: upcoming runs (`pending`, expanded from the recurrence) plus the
* schedule's most recent terminal run. `now` separates upcoming from past. A
* paused (`disabled`) recurring schedule still expands its upcoming occurrences
* — flagged `disabled` so the calendar can render them dimmed and offer Resume —
* since the cadence is intact and only suspended. `completed` schedules expand
* no future runs.
*/
export function scheduleToTasks(
row: WorkspaceScheduleRow,
rangeStart: Date,
rangeEnd: Date,
now: Date
): ScheduledTask[] {
const recurring = Boolean(row.cronExpression)
const paused = row.status === 'disabled'
// double-cast-allowed: contexts persist as open kind/label objects; the calendar consumes them as ChatContext
const contexts = (row.contexts ?? undefined) as unknown as ChatContext[] | undefined
const base = {
scheduleId: row.id,
prompt: row.prompt ?? '',
contexts,
timezone: row.timezone,
recurring,
disabled: false,
}
const tasks: ScheduledTask[] = []
if (!recurring) {
if (row.status === 'active' && row.nextRunAt) {
const runAt = new Date(row.nextRunAt)
if (withinRange(runAt, rangeStart, rangeEnd)) {
tasks.push({ ...base, id: row.id, runAt, status: 'pending' })
}
} else {
const marker = lastRunMarker(row)
if (marker && withinRange(marker.at, rangeStart, rangeEnd)) {
tasks.push({ ...base, id: row.id, runAt: marker.at, status: marker.status })
}
}
return tasks
}
if ((row.status === 'active' || paused) && row.cronExpression) {
const occurrences = expandOccurrences({
cronExpression: row.cronExpression,
timezone: row.timezone,
rangeStart,
rangeEnd,
from: now,
excludedDates: row.excludedDates,
endsAt: row.endsAt ? new Date(row.endsAt) : null,
})
for (const runAt of occurrences) {
tasks.push({
...base,
id: `${row.id}:${runAt.toISOString()}`,
runAt,
status: 'pending',
disabled: paused,
})
}
}
const marker = lastRunMarker(row)
if (marker && withinRange(marker.at, rangeStart, rangeEnd)) {
tasks.push({ ...base, id: `${row.id}:last`, runAt: marker.at, status: marker.status })
}
return tasks
}
/**
* Adapts a task occurrence into a positioned calendar event, placing it at its
* wall-clock time in the task's own timezone (see {@link CalendarEvent.start}).
* Every occurrence renders identically regardless of status; the details modal
* carries the state.
*/
export function taskToCalendarEvent(task: ScheduledTask): CalendarEvent {
const prompt = task.prompt.trim()
return {
id: task.id,
start: zonedClockDate(task.runAt, task.timezone),
title: prompt ? truncate(prompt, 60) : 'Scheduled task',
task,
}
}
/** Groups events by calendar day for both the month grid and the time grid. */
export function bucketEventsByDay(events: CalendarEvent[]): Map<string, CalendarEvent[]> {
const map = new Map<string, CalendarEvent[]>()
for (const event of events) {
const key = dayKey(event.start)
const bucket = map.get(key)
if (bucket) bucket.push(event)
else map.set(key, [event])
}
return map
}