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,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
}