Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
0125e54
feat(scheduled-tasks): position week/day chips at their exact minute
waleedlatif1 Jun 14, 2026
7be0163
feat(settings): user timezone preference for scheduled tasks
waleedlatif1 Jun 14, 2026
4021af7
fix(scheduled-tasks): interpret launch/end times in the account timezone
waleedlatif1 Jun 14, 2026
29d4527
feat(scheduled-tasks): Google-Calendar-style side-by-side overlap layout
waleedlatif1 Jun 14, 2026
01943e8
fix(scheduled-tasks): zone-consistent recurrence/edit + duplicate + l…
waleedlatif1 Jun 14, 2026
00dd9b8
fix(scheduled-tasks): edit/duplicate use the task's own timezone, not…
waleedlatif1 Jun 14, 2026
3a4d248
fix(scheduled-tasks): duplicating a past one-time task seeds a future…
waleedlatif1 Jun 14, 2026
21eeada
fix(scheduled-tasks): clear duplicate pre-fill when starting a fresh …
waleedlatif1 Jun 14, 2026
062e049
fix(scheduled-tasks): make create/duplicate/edit modals mutually excl…
waleedlatif1 Jun 14, 2026
9371764
feat(scheduled-tasks): render calendar in the effective timezone
waleedlatif1 Jun 14, 2026
a95683a
fix(scheduled-tasks): re-sync calendar day frame when timezone resolves
waleedlatif1 Jun 14, 2026
0a6a47f
fix(scheduled-tasks): pad view window for timezone slop; re-center sc…
waleedlatif1 Jun 14, 2026
e54eb4d
test(scheduled-tasks): pass timezone to cronToRecurrence ends-on case
waleedlatif1 Jun 14, 2026
90a66c4
fix(scheduled-tasks): re-seed blank-create launch when timezone resolves
waleedlatif1 Jun 14, 2026
a095ef9
fix(scheduled-tasks): DST fall-back resolve, today month-cell default…
waleedlatif1 Jun 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/sim/app/api/users/me/settings/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const defaultSettings = {
errorNotificationsEnabled: true,
snapToGridSize: 0,
showActionBar: true,
timezone: null,
lastActiveWorkspaceId: null,
}

Expand Down Expand Up @@ -52,6 +53,7 @@ export const GET = withRouteHandler(async () => {
errorNotificationsEnabled: settings.errorNotificationsEnabled,
snapToGridSize: settings.snapToGridSize,
showActionBar: settings.showActionBar,
timezone: settings.timezone,
lastActiveWorkspaceId: settings.lastActiveWorkspaceId,
})
.from(settings)
Expand All @@ -78,6 +80,7 @@ export const GET = withRouteHandler(async () => {
errorNotificationsEnabled: userSettings.errorNotificationsEnabled ?? true,
snapToGridSize: userSettings.snapToGridSize ?? 0,
showActionBar: userSettings.showActionBar ?? true,
timezone: userSettings.timezone ?? null,
lastActiveWorkspaceId: userSettings.lastActiveWorkspaceId ?? null,
},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,20 @@ import { useEffect, useState } from 'react'
import { format } from 'date-fns'
import { chipPrimaryFillTokens } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { zonedClockDate } from '@/lib/core/utils/timezone'
import { CalendarEventChip } from '@/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/calendar-event-chip'
import {
type CalendarDayCell,
EVENT_CHIP_HEIGHT,
formatHourLabel,
formatSlotTime,
layoutColumn,
TIME_SLOT_HEIGHT,
timeToOffset,
} from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/calendar-grid'
import {
type CalendarEvent,
hourKey,
dayKey,
type ScheduledTask,
} from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events'

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

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

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

return (
<div style={{ top: timeToOffset(now) }} className='pointer-events-none absolute inset-x-0 z-10'>
<div
style={{ top: timeToOffset(zonedClockDate(now, timezone)) }}
className='pointer-events-none absolute inset-x-0 z-20'
>
<div className='-translate-x-1/2 -translate-y-1/2 absolute top-0 left-0 size-[10px] rounded-full bg-[var(--text-primary)] dark:bg-white' />
<div className='-translate-y-1/2 absolute inset-x-0 top-0 h-[2px] bg-[var(--text-primary)] dark:bg-white' />
</div>
)
}

/**
* One hour cell in a day column. Clicking empty space opens the create modal;
* the cell is a plain clickable `<div>` so the task pills inside can be real
* `<button>`s without nesting interactive elements. Concurrent tasks share the
* slot side-by-side — each pill flexes to an equal share of the row and
* truncates, so any number of simultaneous tasks stays clickable.
* One hour cell in a day column: a click target that opens the create modal
* seeded to this hour, plus the hour's gridlines. Tasks are not rendered here —
* they live in the day's {@link DayEvents} overlay so each sits at its exact
* minute rather than snapping to the top of the hour.
*/
function TimeSlot({
function HourCell({
date,
hour,
events,
isLastColumn,
onSelect,
onSelectTask,
onTaskContextMenu,
}: {
date: Date
hour: number
events: CalendarEvent[]
isLastColumn: boolean
onSelect: (date: Date, time: string) => void
onSelectTask: (task: ScheduledTask) => void
onTaskContextMenu: (task: ScheduledTask, e: React.MouseEvent) => void
}) {
return (
<div
onClick={() => onSelect(date, formatSlotTime(hour))}
style={{ height: TIME_SLOT_HEIGHT }}
className={cn(
'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)]',
'cursor-pointer border-[var(--border)] border-r border-b transition-colors hover-hover:bg-[var(--surface-active)]',
isLastColumn && 'pr-6'
)}
/>
)
}

/**
* A day column's task pills, each absolutely positioned at its exact start time
* via {@link timeToOffset}. The layer is non-interactive so empty space falls
* through to the hour cells beneath (click-to-create); the pills re-enable
* pointer events. The layer clips to the day's bounds so a late-night pill never
* spills past the final hour row. Coincident tasks overlap by design.
*/
function DayEvents({
events,
isLastColumn,
onSelectTask,
onTaskContextMenu,
}: {
events: CalendarEvent[]
isLastColumn: boolean
onSelectTask: (task: ScheduledTask) => void
onTaskContextMenu: (task: ScheduledTask, e: React.MouseEvent) => void
}) {
const placed = layoutColumn(events, EVENT_CHIP_HEIGHT)
return (
<div
className={cn(
'pointer-events-none absolute inset-y-0 left-0.5 z-10 overflow-hidden',
isLastColumn ? 'right-6' : 'right-0.5'
)}
>
{events.map((event) => (
<CalendarEventChip
{placed.map(({ item: event, topPx, lane, lanes }) => (
<div
key={event.id}
event={event}
onSelect={onSelectTask}
onContextMenu={onTaskContextMenu}
className='min-w-0 flex-1'
/>
style={{
top: topPx,
height: EVENT_CHIP_HEIGHT,
left: `${(lane / lanes) * 100}%`,
width: `${(1 / lanes) * 100}%`,
}}
className='pointer-events-auto absolute pr-0.5'
>
<CalendarEventChip
event={event}
onSelect={onSelectTask}
onContextMenu={onTaskContextMenu}
className='h-full w-full'
/>
</div>
))}
</div>
)
Expand All @@ -113,16 +154,17 @@ function TimeSlot({
* they stay aligned. The sticky header paints chrome on the day cells only —
* its gutter spacer is transparent and border-free, so the hour labels scroll
* clear to the top of the viewport. Today's column is `relative` and hosts the
* {@link CurrentTimeIndicator}. Events flow in via `eventsByHour` — the single
* {@link CurrentTimeIndicator}. Events flow in via `eventsByDay` — the single
* injection point the container fills.
*/
export function TimeGrid({
days,
hours,
timezone,
onSelectSlot,
onSelectTask,
onTaskContextMenu,
eventsByHour,
eventsByDay,
}: TimeGridProps) {
const columnsStyle = {
gridTemplateColumns: `${GUTTER_WIDTH}px repeat(${days.length}, minmax(0, 1fr))`,
Expand Down Expand Up @@ -170,19 +212,22 @@ export function TimeGrid({

{days.map((day, dayIndex) => (
<div key={day.date.toISOString()} className='relative flex flex-col'>
{day.isToday && <CurrentTimeIndicator />}
{day.isToday && <CurrentTimeIndicator timezone={timezone} />}
{hours.map((hour) => (
<TimeSlot
<HourCell
key={hour}
date={day.date}
hour={hour}
events={eventsByHour?.get(hourKey(day.date, hour)) ?? []}
isLastColumn={dayIndex === days.length - 1}
onSelect={onSelectSlot}
onSelectTask={onSelectTask}
onTaskContextMenu={onTaskContextMenu}
/>
))}
<DayEvents
events={eventsByDay?.get(dayKey(day.date)) ?? []}
isLastColumn={dayIndex === days.length - 1}
onSelectTask={onSelectTask}
onTaskContextMenu={onTaskContextMenu}
/>
</div>
))}
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client'

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

/**
Expand All @@ -53,13 +54,14 @@ interface ScheduleCalendarProps {
* computed from the time-grid header height plus {@link timeToOffset} rather than
* the now-line element, so it works even on first paint before the line mounts.
*
* Event injection is the single integration point — `eventsByDay`/`eventsByHour`
* are threaded straight into the two grids, which forward them to their cells.
* Event injection is the single integration point — `eventsByDay` is threaded
* straight into both grids, which forward it to their cells.
*/
export function ScheduleCalendar({
scope,
anchor,
today,
timezone,
onScopeChange,
onPrev,
onNext,
Expand All @@ -70,7 +72,6 @@ export function ScheduleCalendar({
onTaskContextMenu,
onShowDay,
eventsByDay,
eventsByHour,
}: ScheduleCalendarProps) {
const scrollRef = useRef<HTMLDivElement>(null)
const lastScrollSignalRef = useRef(0)
Expand All @@ -96,9 +97,10 @@ export function ScheduleCalendar({
}
const header = region.querySelector('[data-time-grid-header]')
const headerHeight = header ? header.getBoundingClientRect().height : 0
const target = headerHeight + timeToOffset(new Date()) - region.clientHeight / 2
const target =
headerHeight + timeToOffset(zonedClockDate(new Date(), timezone)) - region.clientHeight / 2
region.scrollTo({ top: Math.max(0, target), behavior })
}, [scope, scrollSignal])
}, [scope, scrollSignal, timezone])

return (
<div className='relative flex min-h-0 flex-1 flex-col overflow-hidden'>
Expand Down Expand Up @@ -126,10 +128,11 @@ export function ScheduleCalendar({
<TimeGrid
days={grid.kind === 'week' ? grid.days : [grid.day]}
hours={grid.hours}
timezone={timezone}
onSelectSlot={(date, time) => onSelectSlot(date, time)}
onSelectTask={onSelectTask}
onTaskContextMenu={onTaskContextMenu}
eventsByHour={eventsByHour}
eventsByDay={eventsByDay}
/>
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/emcn'
import { Eye, Pencil, Trash } from '@/components/emcn/icons'
import { Duplicate as DuplicateIcon, Pencil, Trash } from '@/components/emcn/icons'
import type { ScheduledTask } from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events'

interface TaskContextMenuProps {
Expand All @@ -16,23 +16,24 @@ interface TaskContextMenuProps {
onClose: () => void
/** The right-clicked task; its status decides which actions render. */
task: ScheduledTask | null
onSeeDetails: () => void
onEdit: () => void
/** Opens a new-task modal pre-filled from this task. */
onDuplicate: () => void
onDelete: () => void
}

/**
* Right-click menu for a calendar task pill. The action set follows the task's
* lifecycle: upcoming (`pending`) tasks can still be edited or deleted, while
* tasks that have started or finished only expose their read-only record.
* Right-click menu for a calendar task pill. Upcoming (`pending`) tasks can be
* edited or deleted; any task can be duplicated into a new one. Finished tasks
* open their read-only record on click, so the menu only offers Duplicate.
*/
export function TaskContextMenu({
isOpen,
position,
onClose,
task,
onSeeDetails,
onEdit,
onDuplicate,
onDelete,
}: TaskContextMenuProps) {
const isUpcoming = task?.status === 'pending'
Expand Down Expand Up @@ -66,16 +67,20 @@ export function TaskContextMenu({
<Pencil />
Edit
</DropdownMenuItem>
<DropdownMenuItem onSelect={onDuplicate}>
<DuplicateIcon />
Duplicate
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onDelete}>
<Trash />
Delete
</DropdownMenuItem>
</>
) : (
<DropdownMenuItem onSelect={onSeeDetails}>
<Eye />
See details
<DropdownMenuItem onSelect={onDuplicate}>
<DuplicateIcon />
Duplicate
</DropdownMenuItem>
)}
</DropdownMenuContent>
Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { type TaskDraft, type TaskEditSeed, TaskModal } from './task-modal'
export { type TaskDraft, type TaskEditSeed, TaskModal, type TaskPrefill } from './task-modal'
Loading
Loading