Skip to content

Commit 4ec26a0

Browse files
authored
feat(scheduled-tasks): minute-granular calendar + user timezone preference (#5038)
* feat(scheduled-tasks): position week/day chips at their exact minute Replace hour-bucketed event rendering with a per-day absolute overlay that places each task chip at timeToOffset(start), so a 5:38 task sits at 5:38 instead of the top of the 5:00 cell. Hour cells become click/gridline-only; the overlay is non-interactive so empty-space clicks still create. Removes the now-obsolete eventsByHour/hourKey/bucketEventsByHour path (month view already used eventsByDay). * feat(settings): user timezone preference for scheduled tasks Add a Timezone preference under Settings → General. Scheduled tasks now run in the user's chosen IANA zone instead of whatever device created them. - settings table gains a nullable `timezone` column (migration 0236); null means "use the browser-detected zone", so existing users are unchanged - contract: validated IANA `timezone` on the settings get/update shapes - useTimezone() resolves the saved zone or the browser fallback; the task modal captures it instead of recomputing the device zone - General settings adds a searchable timezone combobox defaulting to the detected zone - shared timezone util (getBrowserTimezone / getSupportedTimezones) * fix(scheduled-tasks): interpret launch/end times in the account timezone Address review: one-time runs and the end-of-day boundary were resolved in the browser zone, so a task could fire at the wrong instant when the account zone differed from the device. Resolve wall-clock launch/end through the account zone (DST-correct), and evaluate the past-launch guard and the default seed in that zone too — matching how the recurring cron is already evaluated. - timezone util: zonedWallClockToUtc (DST-correct, no library), wallClockNow; getSupportedTimezones falls back to a common set and always includes UTC - recurrenceToScheduleFields takes the timezone and resolves time/endsAt in it - settings timezone Label drops its dangling htmlFor - tests for the zone converter (UTC / +5:30 / DST) and the zoned mappings * feat(scheduled-tasks): Google-Calendar-style side-by-side overlap layout Tasks whose pills would collide now split the column into side-by-side lanes (like Google Calendar) instead of stacking on top of each other; tasks that don't overlap keep the full width. Adds a pure layoutColumn lane-assignment helper (interval clustering + greedy lane packing) with tests. * fix(scheduled-tasks): zone-consistent recurrence/edit + duplicate + loading Address review (zone consistency): - recurrenceToCron derives weekday/day-of-month from a UTC-parsed calendar date so the cron targets the right day regardless of device zone - cronToRecurrence + editSeedFor recover the launch date/time and ends-on date read back in the schedule's zone (zonedWallClock), so editing shows the right values when the account zone differs from the device - defaultLaunch no longer compares browser-local slot days against account-zone "today" Features: - right-click Duplicate: opens a pre-filled create modal from any task (TaskEditSeed now extends a shared TaskPrefill; modal gains a prefill prop) - loading.tsx paints only the header chrome (the page is a calendar, not a table) so it no longer pops table -> calendar; the empty calendar loads tasks in - task context menu drops "See details" (finished tasks open on click) * fix(scheduled-tasks): edit/duplicate use the task's own timezone, not the account one A task created in one zone but edited after the account zone changed (or duplicated) seeded its launch in the task's stored zone while validating and submitting in the current account zone, drifting unchanged run times. TaskPrefill now carries the task's timezone; the modal seeds AND submits in it for edit/duplicate, and only blank creates use the account zone. * fix(scheduled-tasks): duplicating a past one-time task seeds a future launch Audit follow-up: a duplicate of a one-time task whose launch already passed opened with Schedule disabled. It now falls through to the next-hour default so the new task is immediately schedulable. Also clarifies the DST spring-forward note on zonedWallClockToUtc and drops a stray test comment. * fix(scheduled-tasks): clear duplicate pre-fill when starting a fresh create The create modal had two open-sources (isCreateOpen + duplicatePrefill) that weren't coordinated. Starting a header/slot create now clears any duplicate pre-fill (and duplicating closes any open create), so a stale duplicate draft can never bleed into a blank or slot-seeded create. * fix(scheduled-tasks): make create/duplicate/edit modals mutually exclusive Opening any of the three modal flows (blank create, duplicate pre-fill, task edit/record) now closes the other two, so the create modal can never co-exist with the edit modal and no stale state survives a switch. * feat(scheduled-tasks): render calendar in the effective timezone Position each occurrence at its wall-clock time in the task's own timezone, and draw the now-line / today highlight in the viewer's effective zone, so the calendar always shows a task at the local time it was scheduled for — matching the modal. Adds zonedClockDate as the single zone boundary; the default case (account zone == browser zone) is unchanged. * fix(scheduled-tasks): re-sync calendar day frame when timezone resolves When useTimezone() resolves from the browser fallback to the saved account zone after mount, re-derive today (and the focused day, while it is still on today) so the grid frame, now-line, and fetched range stay in agreement. The focused day is preserved across the change once the user has navigated away. * fix(scheduled-tasks): pad view window for timezone slop; re-center scroll on zone change visibleRange now expands the rendered span by a day on each side so an occurrence whose own-zone display day is on screen is never filtered out by the account-zone frame; bucketEventsByDay still places each on its zoned day, dropping any off-screen. The week/day auto-scroll re-centers when the effective timezone resolves. * test(scheduled-tasks): pass timezone to cronToRecurrence ends-on case The second cronToRecurrence call omitted the required timezone, so the recovered end date depended on the test runner's system zone instead of the schedule zone. Pin it to UTC for determinism. * fix(scheduled-tasks): re-seed blank-create launch when timezone resolves useTimezone() starts on the browser fallback, so a blank create's next-top-of-the-hour default (and its past-launch guard) could be seeded in the wrong zone and submitted in the resolved account zone. Re-seed the default when the effective zone changes, unless the user has edited the fields; slot/edit/duplicate seeds are zone-stable and untouched. * fix(scheduled-tasks): DST fall-back resolve, today month-cell default, late-night pill bounds Audit follow-ups: - zonedWallClockToUtc resolves to the self-consistent instant, fixing one-time launches on the autumn fall-back day (were an hour early) while keeping the spring-forward gap rolling forward; adds DST regression tests. - defaultLaunch: today's whole-day (month-cell) click defaults to the next top of the hour like the header action, not a past 9am that disables Save. - DayEvents clips to the day bounds so a late-night pill never spills past the final hour row; now-line sits above event pills.
1 parent 32b380f commit 4ec26a0

25 files changed

Lines changed: 17309 additions & 215 deletions

File tree

apps/sim/app/api/users/me/settings/route.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const defaultSettings = {
2424
errorNotificationsEnabled: true,
2525
snapToGridSize: 0,
2626
showActionBar: true,
27+
timezone: null,
2728
lastActiveWorkspaceId: null,
2829
}
2930

@@ -52,6 +53,7 @@ export const GET = withRouteHandler(async () => {
5253
errorNotificationsEnabled: settings.errorNotificationsEnabled,
5354
snapToGridSize: settings.snapToGridSize,
5455
showActionBar: settings.showActionBar,
56+
timezone: settings.timezone,
5557
lastActiveWorkspaceId: settings.lastActiveWorkspaceId,
5658
})
5759
.from(settings)
@@ -78,6 +80,7 @@ export const GET = withRouteHandler(async () => {
7880
errorNotificationsEnabled: userSettings.errorNotificationsEnabled ?? true,
7981
snapToGridSize: userSettings.snapToGridSize ?? 0,
8082
showActionBar: userSettings.showActionBar ?? true,
83+
timezone: userSettings.timezone ?? null,
8184
lastActiveWorkspaceId: userSettings.lastActiveWorkspaceId ?? null,
8285
},
8386
},

apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/time-grid/time-grid.tsx

Lines changed: 76 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,20 @@ import { useEffect, useState } from 'react'
44
import { format } from 'date-fns'
55
import { chipPrimaryFillTokens } from '@/components/emcn'
66
import { cn } from '@/lib/core/utils/cn'
7+
import { zonedClockDate } from '@/lib/core/utils/timezone'
78
import { CalendarEventChip } from '@/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/calendar-event-chip'
89
import {
910
type CalendarDayCell,
11+
EVENT_CHIP_HEIGHT,
1012
formatHourLabel,
1113
formatSlotTime,
14+
layoutColumn,
1215
TIME_SLOT_HEIGHT,
1316
timeToOffset,
1417
} from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/calendar-grid'
1518
import {
1619
type CalendarEvent,
17-
hourKey,
20+
dayKey,
1821
type ScheduledTask,
1922
} from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events'
2023

@@ -27,21 +30,24 @@ interface TimeGridProps {
2730
/** One column per day: 7 for week scope, 1 for day scope. */
2831
days: CalendarDayCell[]
2932
hours: number[]
33+
/** The viewer's effective timezone — positions the now-line. */
34+
timezone: string
3035
onSelectSlot: (date: Date, time: string) => void
3136
onSelectTask: (task: ScheduledTask) => void
3237
/** A task pill was right-clicked — open its context menu at the cursor. */
3338
onTaskContextMenu: (task: ScheduledTask, e: React.MouseEvent) => void
34-
eventsByHour?: Map<string, CalendarEvent[]>
39+
eventsByDay?: Map<string, CalendarEvent[]>
3540
}
3641

3742
/**
3843
* Live now-line drawn over today's column — a chip-primary dot at the left edge
3944
* and a hairline across the column, positioned by {@link timeToOffset}. Renders
4045
* nothing until mounted (keeps SSR output stable, avoiding a hydration mismatch
4146
* on the time-dependent offset), then ticks once a minute so the line advances.
47+
* Positioned in `timezone` so it tracks the same zone the day columns render in.
4248
* The parent column is `relative`; this is `absolute`.
4349
*/
44-
function CurrentTimeIndicator() {
50+
function CurrentTimeIndicator({ timezone }: { timezone: string }) {
4551
const [now, setNow] = useState<Date | null>(null)
4652

4753
useEffect(() => {
@@ -53,54 +59,89 @@ function CurrentTimeIndicator() {
5359
if (!now) return null
5460

5561
return (
56-
<div style={{ top: timeToOffset(now) }} className='pointer-events-none absolute inset-x-0 z-10'>
62+
<div
63+
style={{ top: timeToOffset(zonedClockDate(now, timezone)) }}
64+
className='pointer-events-none absolute inset-x-0 z-20'
65+
>
5766
<div className='-translate-x-1/2 -translate-y-1/2 absolute top-0 left-0 size-[10px] rounded-full bg-[var(--text-primary)] dark:bg-white' />
5867
<div className='-translate-y-1/2 absolute inset-x-0 top-0 h-[2px] bg-[var(--text-primary)] dark:bg-white' />
5968
</div>
6069
)
6170
}
6271

6372
/**
64-
* One hour cell in a day column. Clicking empty space opens the create modal;
65-
* the cell is a plain clickable `<div>` so the task pills inside can be real
66-
* `<button>`s without nesting interactive elements. Concurrent tasks share the
67-
* slot side-by-side — each pill flexes to an equal share of the row and
68-
* truncates, so any number of simultaneous tasks stays clickable.
73+
* One hour cell in a day column: a click target that opens the create modal
74+
* seeded to this hour, plus the hour's gridlines. Tasks are not rendered here —
75+
* they live in the day's {@link DayEvents} overlay so each sits at its exact
76+
* minute rather than snapping to the top of the hour.
6977
*/
70-
function TimeSlot({
78+
function HourCell({
7179
date,
7280
hour,
73-
events,
7481
isLastColumn,
7582
onSelect,
76-
onSelectTask,
77-
onTaskContextMenu,
7883
}: {
7984
date: Date
8085
hour: number
81-
events: CalendarEvent[]
8286
isLastColumn: boolean
8387
onSelect: (date: Date, time: string) => void
84-
onSelectTask: (task: ScheduledTask) => void
85-
onTaskContextMenu: (task: ScheduledTask, e: React.MouseEvent) => void
8688
}) {
8789
return (
8890
<div
8991
onClick={() => onSelect(date, formatSlotTime(hour))}
9092
style={{ height: TIME_SLOT_HEIGHT }}
9193
className={cn(
92-
'flex cursor-pointer items-start gap-0.5 overflow-hidden border-[var(--border)] border-r border-b p-0.5 transition-colors hover-hover:bg-[var(--surface-active)]',
94+
'cursor-pointer border-[var(--border)] border-r border-b transition-colors hover-hover:bg-[var(--surface-active)]',
9395
isLastColumn && 'pr-6'
9496
)}
97+
/>
98+
)
99+
}
100+
101+
/**
102+
* A day column's task pills, each absolutely positioned at its exact start time
103+
* via {@link timeToOffset}. The layer is non-interactive so empty space falls
104+
* through to the hour cells beneath (click-to-create); the pills re-enable
105+
* pointer events. The layer clips to the day's bounds so a late-night pill never
106+
* spills past the final hour row. Coincident tasks overlap by design.
107+
*/
108+
function DayEvents({
109+
events,
110+
isLastColumn,
111+
onSelectTask,
112+
onTaskContextMenu,
113+
}: {
114+
events: CalendarEvent[]
115+
isLastColumn: boolean
116+
onSelectTask: (task: ScheduledTask) => void
117+
onTaskContextMenu: (task: ScheduledTask, e: React.MouseEvent) => void
118+
}) {
119+
const placed = layoutColumn(events, EVENT_CHIP_HEIGHT)
120+
return (
121+
<div
122+
className={cn(
123+
'pointer-events-none absolute inset-y-0 left-0.5 z-10 overflow-hidden',
124+
isLastColumn ? 'right-6' : 'right-0.5'
125+
)}
95126
>
96-
{events.map((event) => (
97-
<CalendarEventChip
127+
{placed.map(({ item: event, topPx, lane, lanes }) => (
128+
<div
98129
key={event.id}
99-
event={event}
100-
onSelect={onSelectTask}
101-
onContextMenu={onTaskContextMenu}
102-
className='min-w-0 flex-1'
103-
/>
130+
style={{
131+
top: topPx,
132+
height: EVENT_CHIP_HEIGHT,
133+
left: `${(lane / lanes) * 100}%`,
134+
width: `${(1 / lanes) * 100}%`,
135+
}}
136+
className='pointer-events-auto absolute pr-0.5'
137+
>
138+
<CalendarEventChip
139+
event={event}
140+
onSelect={onSelectTask}
141+
onContextMenu={onTaskContextMenu}
142+
className='h-full w-full'
143+
/>
144+
</div>
104145
))}
105146
</div>
106147
)
@@ -113,16 +154,17 @@ function TimeSlot({
113154
* they stay aligned. The sticky header paints chrome on the day cells only —
114155
* its gutter spacer is transparent and border-free, so the hour labels scroll
115156
* clear to the top of the viewport. Today's column is `relative` and hosts the
116-
* {@link CurrentTimeIndicator}. Events flow in via `eventsByHour` — the single
157+
* {@link CurrentTimeIndicator}. Events flow in via `eventsByDay` — the single
117158
* injection point the container fills.
118159
*/
119160
export function TimeGrid({
120161
days,
121162
hours,
163+
timezone,
122164
onSelectSlot,
123165
onSelectTask,
124166
onTaskContextMenu,
125-
eventsByHour,
167+
eventsByDay,
126168
}: TimeGridProps) {
127169
const columnsStyle = {
128170
gridTemplateColumns: `${GUTTER_WIDTH}px repeat(${days.length}, minmax(0, 1fr))`,
@@ -170,19 +212,22 @@ export function TimeGrid({
170212

171213
{days.map((day, dayIndex) => (
172214
<div key={day.date.toISOString()} className='relative flex flex-col'>
173-
{day.isToday && <CurrentTimeIndicator />}
215+
{day.isToday && <CurrentTimeIndicator timezone={timezone} />}
174216
{hours.map((hour) => (
175-
<TimeSlot
217+
<HourCell
176218
key={hour}
177219
date={day.date}
178220
hour={hour}
179-
events={eventsByHour?.get(hourKey(day.date, hour)) ?? []}
180221
isLastColumn={dayIndex === days.length - 1}
181222
onSelect={onSelectSlot}
182-
onSelectTask={onSelectTask}
183-
onTaskContextMenu={onTaskContextMenu}
184223
/>
185224
))}
225+
<DayEvents
226+
events={eventsByDay?.get(dayKey(day.date)) ?? []}
227+
isLastColumn={dayIndex === days.length - 1}
228+
onSelectTask={onSelectTask}
229+
onTaskContextMenu={onTaskContextMenu}
230+
/>
186231
</div>
187232
))}
188233
</div>

apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/schedule-calendar.tsx

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use client'
22

33
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
4+
import { zonedClockDate } from '@/lib/core/utils/timezone'
45
import {
56
CalendarToolbar,
67
MonthGrid,
@@ -21,6 +22,8 @@ interface ScheduleCalendarProps {
2122
scope: CalendarScope
2223
anchor: Date
2324
today: Date
25+
/** The viewer's effective timezone — positions the now-line and centering. */
26+
timezone: string
2427
onScopeChange: (scope: CalendarScope) => void
2528
onPrev: () => void
2629
onNext: () => void
@@ -33,10 +36,8 @@ interface ScheduleCalendarProps {
3336
onTaskContextMenu: (task: ScheduledTask, e: React.MouseEvent) => void
3437
/** A month cell's overflow line was clicked — jump to that day's view. */
3538
onShowDay: (date: Date) => void
36-
/** Day-bucketed events for the month grid. */
39+
/** Day-bucketed events feeding both the month grid and the time grid. */
3740
eventsByDay?: Map<string, CalendarEvent[]>
38-
/** Hour-bucketed events for the time grid. */
39-
eventsByHour?: Map<string, CalendarEvent[]>
4041
}
4142

4243
/**
@@ -53,13 +54,14 @@ interface ScheduleCalendarProps {
5354
* computed from the time-grid header height plus {@link timeToOffset} rather than
5455
* the now-line element, so it works even on first paint before the line mounts.
5556
*
56-
* Event injection is the single integration point — `eventsByDay`/`eventsByHour`
57-
* are threaded straight into the two grids, which forward them to their cells.
57+
* Event injection is the single integration point — `eventsByDay` is threaded
58+
* straight into both grids, which forward it to their cells.
5859
*/
5960
export function ScheduleCalendar({
6061
scope,
6162
anchor,
6263
today,
64+
timezone,
6365
onScopeChange,
6466
onPrev,
6567
onNext,
@@ -70,7 +72,6 @@ export function ScheduleCalendar({
7072
onTaskContextMenu,
7173
onShowDay,
7274
eventsByDay,
73-
eventsByHour,
7475
}: ScheduleCalendarProps) {
7576
const scrollRef = useRef<HTMLDivElement>(null)
7677
const lastScrollSignalRef = useRef(0)
@@ -96,9 +97,10 @@ export function ScheduleCalendar({
9697
}
9798
const header = region.querySelector('[data-time-grid-header]')
9899
const headerHeight = header ? header.getBoundingClientRect().height : 0
99-
const target = headerHeight + timeToOffset(new Date()) - region.clientHeight / 2
100+
const target =
101+
headerHeight + timeToOffset(zonedClockDate(new Date(), timezone)) - region.clientHeight / 2
100102
region.scrollTo({ top: Math.max(0, target), behavior })
101-
}, [scope, scrollSignal])
103+
}, [scope, scrollSignal, timezone])
102104

103105
return (
104106
<div className='relative flex min-h-0 flex-1 flex-col overflow-hidden'>
@@ -126,10 +128,11 @@ export function ScheduleCalendar({
126128
<TimeGrid
127129
days={grid.kind === 'week' ? grid.days : [grid.day]}
128130
hours={grid.hours}
131+
timezone={timezone}
129132
onSelectSlot={(date, time) => onSelectSlot(date, time)}
130133
onSelectTask={onSelectTask}
131134
onTaskContextMenu={onTaskContextMenu}
132-
eventsByHour={eventsByHour}
135+
eventsByDay={eventsByDay}
133136
/>
134137
)}
135138
</div>

apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-context-menu/task-context-menu.tsx

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
DropdownMenuSeparator,
88
DropdownMenuTrigger,
99
} from '@/components/emcn'
10-
import { Eye, Pencil, Trash } from '@/components/emcn/icons'
10+
import { Duplicate as DuplicateIcon, Pencil, Trash } from '@/components/emcn/icons'
1111
import type { ScheduledTask } from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events'
1212

1313
interface TaskContextMenuProps {
@@ -16,23 +16,24 @@ interface TaskContextMenuProps {
1616
onClose: () => void
1717
/** The right-clicked task; its status decides which actions render. */
1818
task: ScheduledTask | null
19-
onSeeDetails: () => void
2019
onEdit: () => void
20+
/** Opens a new-task modal pre-filled from this task. */
21+
onDuplicate: () => void
2122
onDelete: () => void
2223
}
2324

2425
/**
25-
* Right-click menu for a calendar task pill. The action set follows the task's
26-
* lifecycle: upcoming (`pending`) tasks can still be edited or deleted, while
27-
* tasks that have started or finished only expose their read-only record.
26+
* Right-click menu for a calendar task pill. Upcoming (`pending`) tasks can be
27+
* edited or deleted; any task can be duplicated into a new one. Finished tasks
28+
* open their read-only record on click, so the menu only offers Duplicate.
2829
*/
2930
export function TaskContextMenu({
3031
isOpen,
3132
position,
3233
onClose,
3334
task,
34-
onSeeDetails,
3535
onEdit,
36+
onDuplicate,
3637
onDelete,
3738
}: TaskContextMenuProps) {
3839
const isUpcoming = task?.status === 'pending'
@@ -66,16 +67,20 @@ export function TaskContextMenu({
6667
<Pencil />
6768
Edit
6869
</DropdownMenuItem>
70+
<DropdownMenuItem onSelect={onDuplicate}>
71+
<DuplicateIcon />
72+
Duplicate
73+
</DropdownMenuItem>
6974
<DropdownMenuSeparator />
7075
<DropdownMenuItem onSelect={onDelete}>
7176
<Trash />
7277
Delete
7378
</DropdownMenuItem>
7479
</>
7580
) : (
76-
<DropdownMenuItem onSelect={onSeeDetails}>
77-
<Eye />
78-
See details
81+
<DropdownMenuItem onSelect={onDuplicate}>
82+
<DuplicateIcon />
83+
Duplicate
7984
</DropdownMenuItem>
8085
)}
8186
</DropdownMenuContent>
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
export { type TaskDraft, type TaskEditSeed, TaskModal } from './task-modal'
1+
export { type TaskDraft, type TaskEditSeed, TaskModal, type TaskPrefill } from './task-modal'

0 commit comments

Comments
 (0)