Skip to content

feat(scheduled-tasks): minute-granular calendar + user timezone preference#5038

Merged
waleedlatif1 merged 15 commits into
stagingfrom
feat/calendar-granularity
Jun 14, 2026
Merged

feat(scheduled-tasks): minute-granular calendar + user timezone preference#5038
waleedlatif1 merged 15 commits into
stagingfrom
feat/calendar-granularity

Conversation

@waleedlatif1

Copy link
Copy Markdown
Collaborator

Summary

Two follow-ups to the scheduled-tasks calendar (PR #4979):

Minute-granular week/day positioning. Task chips in the week/day time grid now sit at their exact start time (a 5:38 task renders at 5:38, not the top of the 5:00 cell). Each day column renders a non-interactive absolute overlay positioning every chip at top: timeToOffset(start) — the same mechanism the current-time line uses — so empty-space clicks still create. This made the hour-bucketing path (eventsByHour/hourKey/bucketEventsByHour) obsolete, so it's removed (month view already used eventsByDay).

User timezone preference. Settings → General gains a searchable Timezone picker. 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 = "use the browser-detected zone", so existing users are unchanged.
  • useTimezone() resolves the saved zone or the browser fallback; the task modal captures it instead of recomputing the device zone.
  • IANA timezone validated at the settings boundary; picker defaults to the detected zone.

Type of Change

  • Improvement / new feature

Testing

  • type-check, check:api-validation:strict, check:react-query, lint:check all pass
  • Vitest: scheduled-tasks utils (recurrence, schedule-events mapping, calendar-grid) — green

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

@vercel

vercel Bot commented Jun 14, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Jun 14, 2026 3:40am

Request Review

@cursor

cursor Bot commented Jun 14, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Touches schedule time ↔ UTC conversion and calendar positioning across DST; mistakes could shift run times or misplace events, though behavior is covered by new timezone/recurrence tests.

Overview
Adds a saved IANA timezone on user settings (DB column, API, General → searchable picker) and threads it through scheduling via useTimezone() so new tasks default to the account zone instead of the device. Recurrence create/edit and calendar “today” / now-line / scroll centering interpret wall-clock times in the chosen zone using new timezone helpers (zonedWallClockToUtc, zonedClockDate, etc.).

Week/day calendar places task chips at exact minutes in a per-day overlay (DayEvents + layoutColumn for overlaps), drops hour-bucketing (eventsByHour), and buckets only by day. Events render at each task’s own zone; visibleRange is padded ±1 day so boundary occurrences still expand.

Task modal submits the effective timezone, re-seeds defaults when settings load, and supports Duplicate (context menu + TaskPrefill) alongside tighter mutual exclusion between create/edit/duplicate flows. Loading skeleton matches the calendar chrome (no table columns).

Reviewed by Cursor Bugbot for commit a095ef9. Configure here.

@greptile-apps

greptile-apps Bot commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR delivers two follow-ups to the scheduled-tasks calendar: minute-granular chip positioning in week/day views (replacing hour-bucketing with a layoutColumn absolute-overlay approach), and an account-level IANA timezone preference that gates all scheduling wall-clock ↔ UTC conversions.

  • Minute-granular positioning: TimeSlot is split into a click-target HourCell and a non-interactive DayEvents absolute overlay; layoutColumn implements Google-Calendar-style side-by-side lane assignment for overlapping events; visibleRange is padded ±1 day to absorb cross-timezone boundary slop.
  • Timezone preference: A timezone column is added to the settings table (migration 0237, nullable — null means use browser zone); ianaTimezoneSchema validates at the PATCH boundary; useTimezone() resolves the saved zone or browser fallback; zonedWallClockToUtc handles DST with a two-pass offset reconciliation.
  • Duplicate task: The right-click context menu's "See details" action is replaced by "Duplicate", which opens the create modal pre-filled from the original task's schedule seed.

Confidence Score: 5/5

Safe to merge — all timezone arithmetic is correct, the DST two-pass resolution is well-tested, and the lane-layout algorithm matches the test expectations.

The change is well-scoped: new utilities are pure functions with comprehensive unit tests, the calendar refactor cleanly removes hour-bucketing, and the timezone preference flows correctly from settings through scheduling. The two findings are both non-blocking style/consistency issues that do not affect correctness.

general.tsx (handleTimezoneChange guard consistency) and schedules.ts contract (timezone validation parity with the user-settings contract).

Important Files Changed

Filename Overview
apps/sim/lib/core/utils/timezone.ts New utility: DST-safe wall-clock to UTC conversion, zonedClockDate layout coordinate, and getSupportedTimezones with curated fallback. Well-tested with DST edge cases.
apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/calendar-grid.ts Adds layoutColumn (Google-Calendar-style lane assignment) and EVENT_CHIP_HEIGHT; visibleRange now pads by 1 day each side to cover cross-timezone boundary slop.
apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/time-grid/time-grid.tsx Replaces hour-bucketed TimeSlot rendering with HourCell (click target) plus DayEvents absolute overlay; the now-line is positioned via zonedClockDate. JSDoc updated to reference eventsByDay.
apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx Adds timezone ChipCombobox with getBrowserTimezone default; handleTimezoneChange is missing the isPending guard and same-value equality check present in every other setting handler.
apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/recurrence.ts Replaces device-local new Date(date + time) with zonedWallClockToUtc for one-time tasks and endsAt; cron weekday/DOM now reads from UTC-parsed launch date to avoid device-zone interference.
apps/sim/hooks/queries/general-settings.ts Adds `timezone: string
apps/sim/lib/api/contracts/user.ts Adds ianaTimezoneSchema (runtime-validated via Intl.DateTimeFormat) and threads it through updateUserSettingsBodySchema; the GET response schema uses plain z.string().nullable() which is an acceptable asymmetry.
apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events.ts Removes hourKey and bucketEventsByHour; taskToCalendarEvent now positions events at zonedClockDate(task.runAt, task.timezone) — the task's own-zone wall-clock as a layout coordinate.
apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/task-modal.tsx Adds prefill prop (duplicate flow); re-seed effect guards via launchEditedRef so only untouched blank creates update when useTimezone() resolves; isLaunchInPast now zone-aware.
apps/sim/app/workspace/[workspaceId]/scheduled-tasks/hooks/use-calendar.ts Accepts timezone parameter; anchor/today initialise from zonedClockDate; re-syncs anchor when timezone changes with a preserved-navigation guard using todayRef.
packages/db/migrations/0237_user_settings_timezone.sql Single ALTER TABLE ADD COLUMN timezone text — nullable, no default, safe for existing rows (null falls back to browser zone).

Sequence Diagram

sequenceDiagram
    participant U as User
    participant GS as General Settings UI
    participant API as /api/users/me/settings
    participant DB as settings table
    participant TM as TaskModal
    participant SC as ScheduleCalendar
    participant BE as Schedules API

    U->>GS: Select timezone in picker
    GS->>API: PATCH timezone validated by ianaTimezoneSchema
    API->>DB: UPDATE settings SET timezone
    DB-->>API: ok
    API-->>GS: success
    Note over GS: useTimezone() now returns saved zone

    U->>SC: Open calendar
    SC->>SC: useCalendar(timezone) - today and anchor set via zonedClockDate

    U->>SC: Click time slot
    SC->>TM: open slot with timezone
    TM->>TM: defaultLaunch and isLaunchInPast both use zonedWallClockToUtc

    U->>TM: Submit draft
    TM->>BE: POST schedule with timezone and UTC-resolved time
    BE->>DB: INSERT schedule

    Note over SC: taskToCalendarEvent positions start via zonedClockDate(runAt, task.timezone)
Loading

Reviews (14): Last reviewed commit: "fix(scheduled-tasks): DST fall-back reso..." | Re-trigger Greptile

Comment thread apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx Outdated
Comment thread apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx Outdated
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).
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)
@waleedlatif1 waleedlatif1 force-pushed the feat/calendar-granularity branch from 88bddd4 to 7be0163 Compare June 14, 2026 01:01
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

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
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 4021af7. Configure here.

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.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile re-review the latest commits

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

…oading

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)
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile re-review the latest commits

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

… 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.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

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.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile review

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

…roll 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.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile review

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

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.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile review

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

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.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile review

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 90a66c4. Configure here.

…, 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.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile review

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit a095ef9. Configure here.

@waleedlatif1 waleedlatif1 merged commit 4ec26a0 into staging Jun 14, 2026
15 checks passed
@waleedlatif1 waleedlatif1 deleted the feat/calendar-granularity branch June 14, 2026 03:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant