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
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:
@@ -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 7–13, 2026 (Sun–Sat); 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 (4–6) 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 4–6 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 1st–4th, 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 10–14 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 10–14 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
|
||||
}
|
||||
Reference in New Issue
Block a user