Skip to content

Commit 7be0163

Browse files
committed
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)
1 parent 0125e54 commit 7be0163

11 files changed

Lines changed: 16550 additions & 3 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: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ function DayEvents({
138138
* they stay aligned. The sticky header paints chrome on the day cells only —
139139
* its gutter spacer is transparent and border-free, so the hour labels scroll
140140
* clear to the top of the viewport. Today's column is `relative` and hosts the
141-
* {@link CurrentTimeIndicator}. Events flow in via `eventsByHour` — the single
141+
* {@link CurrentTimeIndicator}. Events flow in via `eventsByDay` — the single
142142
* injection point the container fills.
143143
*/
144144
export function TimeGrid({

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,9 @@ import {
2323
DEFAULT_RECURRENCE,
2424
type Recurrence,
2525
} from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/recurrence'
26+
import { useTimezone } from '@/hooks/queries/general-settings'
2627
import type { ChatContext } from '@/stores/panel'
2728

28-
const DEFAULT_TIMEZONE = Intl.DateTimeFormat().resolvedOptions().timeZone
2929
const DEFAULT_TIME = '09:00'
3030
const PAST_LAUNCH_MESSAGE = "You can't schedule a one-time task in the past"
3131

@@ -131,6 +131,7 @@ function TaskModalContent({
131131
onRequestDelete,
132132
}: Omit<TaskModalProps, 'open'>) {
133133
const { workspaceId } = useParams<{ workspaceId: string }>()
134+
const timezone = useTimezone()
134135
const editor = usePromptEditor({ workspaceId, initialValue: edit?.prompt })
135136
const setContexts = editor.setContexts
136137

@@ -163,7 +164,7 @@ function TaskModalContent({
163164
contexts: editor.contexts.length > 0 ? editor.contexts : undefined,
164165
launchDate,
165166
launchTime,
166-
timezone: DEFAULT_TIMEZONE,
167+
timezone,
167168
recurrence,
168169
})
169170
close()

apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { useRouter } from 'next/navigation'
88
import {
99
Button,
1010
Chip,
11+
ChipCombobox,
1112
ChipModal,
1213
ChipModalBody,
1314
ChipModalError,
@@ -26,6 +27,7 @@ import { ANONYMOUS_USER_ID } from '@/lib/auth/constants'
2627
import { getEnv, isTruthy } from '@/lib/core/config/env'
2728
import { isHosted } from '@/lib/core/config/feature-flags'
2829
import { handleKeyboardActivation } from '@/lib/core/utils/keyboard'
30+
import { getBrowserTimezone, getSupportedTimezones } from '@/lib/core/utils/timezone'
2931
import { getBaseUrl } from '@/lib/core/utils/urls'
3032
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
3133
import { useProfilePictureUpload } from '@/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload'
@@ -40,6 +42,12 @@ import { clearUserData } from '@/stores'
4042

4143
const logger = createLogger('General')
4244

45+
/** IANA zones for the timezone picker; labels drop underscores so search reads naturally. */
46+
const TIMEZONE_OPTIONS = getSupportedTimezones().map((tz) => ({
47+
label: tz.replace(/_/g, ' '),
48+
value: tz,
49+
}))
50+
4351
/**
4452
* Extracts initials from a user's name.
4553
* @param name - The user's full name
@@ -197,6 +205,10 @@ export function General() {
197205
await updateSetting.mutateAsync({ key: 'theme', value: value as 'system' | 'light' | 'dark' })
198206
}
199207

208+
const handleTimezoneChange = async (value: string) => {
209+
await updateSetting.mutateAsync({ key: 'timezone', value })
210+
}
211+
200212
const handleAutoConnectChange = async (checked: boolean) => {
201213
if (checked !== settings?.autoConnect && !updateSetting.isPending) {
202214
await updateSetting.mutateAsync({ key: 'autoConnect', value: checked })
@@ -406,6 +418,31 @@ export function General() {
406418
/>
407419
</div>
408420

421+
<div className='flex items-center justify-between gap-4'>
422+
<div className='flex items-center gap-1.5'>
423+
<Label htmlFor='timezone-select'>Timezone</Label>
424+
<Tooltip.Root>
425+
<Tooltip.Trigger asChild>
426+
<Info className='size-[14px] cursor-default text-[var(--text-muted)]' />
427+
</Tooltip.Trigger>
428+
<Tooltip.Content side='bottom' align='start'>
429+
<p>The timezone scheduled tasks run in. Defaults to this device's zone.</p>
430+
</Tooltip.Content>
431+
</Tooltip.Root>
432+
</div>
433+
<ChipCombobox
434+
className='min-w-0 max-w-[260px]'
435+
align='start'
436+
dropdownWidth={260}
437+
searchable
438+
searchPlaceholder='Search timezones'
439+
value={settings?.timezone ?? getBrowserTimezone()}
440+
onChange={handleTimezoneChange}
441+
placeholder='Select timezone'
442+
options={TIMEZONE_OPTIONS}
443+
/>
444+
</div>
445+
409446
<div className='flex items-center justify-between'>
410447
<div className='flex items-center gap-1.5'>
411448
<Label htmlFor='auto-connect'>Auto-connect on drop</Label>

apps/sim/hooks/queries/general-settings.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
updateUserSettingsContract,
1010
} from '@/lib/api/contracts'
1111
import { syncThemeToNextThemes } from '@/lib/core/utils/theme'
12+
import { getBrowserTimezone } from '@/lib/core/utils/timezone'
1213

1314
const logger = createLogger('GeneralSettingsQuery')
1415

@@ -34,6 +35,8 @@ export interface GeneralSettings {
3435
errorNotificationsEnabled: boolean
3536
snapToGridSize: number
3637
showActionBar: boolean
38+
/** Saved IANA timezone, or `null` when unset (the app falls back to the browser zone). */
39+
timezone: string | null
3740
}
3841

3942
/**
@@ -52,6 +55,7 @@ export function mapGeneralSettingsResponse(data: UserSettingsApi): GeneralSettin
5255
errorNotificationsEnabled: data.errorNotificationsEnabled,
5356
snapToGridSize: data.snapToGridSize,
5457
showActionBar: data.showActionBar,
58+
timezone: data.timezone ?? null,
5559
}
5660
}
5761

@@ -130,6 +134,16 @@ export function useErrorNotificationsEnabled(): boolean {
130134
return data?.errorNotificationsEnabled ?? true
131135
}
132136

137+
/**
138+
* The user's effective scheduling timezone: their saved preference, or the
139+
* browser-detected zone when unset. Use this wherever a task's timezone is
140+
* captured so scheduling honors the account preference rather than the device.
141+
*/
142+
export function useTimezone(): string {
143+
const { data } = useGeneralSettings()
144+
return data?.timezone ?? getBrowserTimezone()
145+
}
146+
133147
/**
134148
* Update general settings mutation
135149
*/

apps/sim/lib/api/contracts/user.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,19 @@ export const userSettingsEmailPreferencesSchema = z.object({
6464
export const mothershipEnvironmentSchema = z.enum(['default', 'dev', 'staging', 'prod'])
6565
export type MothershipEnvironment = z.infer<typeof mothershipEnvironmentSchema>
6666

67+
/** An IANA timezone identifier (e.g. `America/New_York`), validated against the runtime's zone database. */
68+
export const ianaTimezoneSchema = z.string().refine(
69+
(tz) => {
70+
try {
71+
new Intl.DateTimeFormat('en-US', { timeZone: tz })
72+
return true
73+
} catch {
74+
return false
75+
}
76+
},
77+
{ message: 'Must be a valid IANA timezone (e.g. America/New_York)' }
78+
)
79+
6780
export const userSettingsSchema = z.object({
6881
theme: z.enum(['system', 'light', 'dark']).default('system'),
6982
autoConnect: z.boolean().default(true),
@@ -76,6 +89,8 @@ export const userSettingsSchema = z.object({
7689
errorNotificationsEnabled: z.boolean().default(true),
7790
snapToGridSize: z.number().min(0).max(50).default(0),
7891
showActionBar: z.boolean().default(true),
92+
/** IANA timezone for scheduling; `null` means the client falls back to the browser-detected zone. */
93+
timezone: z.string().nullable().default(null),
7994
lastActiveWorkspaceId: z.string().nullable().optional(),
8095
})
8196

@@ -93,6 +108,8 @@ export const updateUserSettingsBodySchema = z.object({
93108
errorNotificationsEnabled: z.boolean().optional(),
94109
snapToGridSize: z.number().min(0).max(50).optional(),
95110
showActionBar: z.boolean().optional(),
111+
/** IANA timezone; explicit `null` resets to the browser-detected zone. */
112+
timezone: ianaTimezoneSchema.nullable().optional(),
96113
/** Mirrors `userSettingsSchema.lastActiveWorkspaceId` so explicit `null` is accepted to clear the active workspace. */
97114
lastActiveWorkspaceId: z.string().nullable().optional(),
98115
})
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/** The IANA timezone the current runtime resolves to (e.g. `America/New_York`). */
2+
export function getBrowserTimezone(): string {
3+
return Intl.DateTimeFormat().resolvedOptions().timeZone
4+
}
5+
6+
/**
7+
* Every IANA timezone identifier the runtime knows, for populating a picker.
8+
* Empty on runtimes without `Intl.supportedValuesOf` so callers can fall back.
9+
*/
10+
export function getSupportedTimezones(): string[] {
11+
return typeof Intl.supportedValuesOf === 'function' ? Intl.supportedValuesOf('timeZone') : []
12+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ALTER TABLE "settings" ADD COLUMN "timezone" text;

0 commit comments

Comments
 (0)