diff --git a/frontend/src/lib/components/DateTimeInput.svelte b/frontend/src/lib/components/DateTimeInput.svelte index ac95a1a566..55e1dbc0e1 100644 --- a/frontend/src/lib/components/DateTimeInput.svelte +++ b/frontend/src/lib/components/DateTimeInput.svelte @@ -8,6 +8,7 @@ import { twMerge } from 'tailwind-merge' import { createDispatcherIfMounted } from '$lib/createDispatcherIfMounted' import TextInput from './text_input/TextInput.svelte' + import { parseDateTime } from './dateTimeInput' // import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte' interface Props { @@ -45,7 +46,7 @@ // let format: 'local' | 'utc' = 'local' function parseValue(value: string | null | undefined = undefined) { - let dateFromValue: Date | undefined = value ? new Date(value) : undefined + let dateFromValue: Date | undefined = value ? parseDateTime(value, timezone) : undefined if (!isValidDate(dateFromValue)) { date = undefined time = '12:00' diff --git a/frontend/src/lib/components/dateTimeInput.test.ts b/frontend/src/lib/components/dateTimeInput.test.ts new file mode 100644 index 0000000000..8a466ee5cf --- /dev/null +++ b/frontend/src/lib/components/dateTimeInput.test.ts @@ -0,0 +1,14 @@ +import { expect, it } from 'vitest' +import { parseDateTime } from './dateTimeInput' + +it('reads a zone-less naive value as its digits, outside UTC too', () => { + const tz = process.env.TZ + process.env.TZ = 'Europe/Paris' + try { + expect(parseDateTime('2027-09-09T10:00', 'naive').getUTCHours()).toBe(10) + expect(parseDateTime('2027-09-09T10:00', 'local').getUTCHours()).toBe(8) + } finally { + if (tz === undefined) delete process.env.TZ + else process.env.TZ = tz + } +}) diff --git a/frontend/src/lib/components/dateTimeInput.ts b/frontend/src/lib/components/dateTimeInput.ts new file mode 100644 index 0000000000..7ed8517321 --- /dev/null +++ b/frontend/src/lib/components/dateTimeInput.ts @@ -0,0 +1,5 @@ +/** A naive value's digits are its time: `Date` would read a zone-less date-time as local time */ +export function parseDateTime(value: string, timezone: 'naive' | 'local'): Date { + const zoneless = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?$/.test(value) + return new Date(timezone === 'naive' && zoneless ? value.replace(' ', 'T') + 'Z' : value) +} diff --git a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte index df09a81671..48227dea44 100644 --- a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte +++ b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte @@ -103,7 +103,7 @@ let loading = $state(false) let drawerLoading = $state(true) let showLoading = $state(false) - let initialConfig: Record | undefined = undefined + let initialConfig = $state | undefined>(undefined) let extraPerms: Record = $state({}) // Path the permissions above were loaded for — the verdict is about the schedule as // stored, not about a rename being typed into the form. `undefined` until a config has @@ -281,7 +281,7 @@ schedule = s?.schedule ?? '0 0 12 * *' initialSchedule = schedule timezone = s?.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone - paused_until = s?.paused_until ?? undefined + paused_until = asPicked(s?.paused_until) showPauseUntil = paused_until !== undefined summary = s?.summary ?? '' labels = s?.labels ?? undefined @@ -372,6 +372,12 @@ } } + // The draft check compares as text and the picker writes `.000Z`: spell the stored time + // the same, or a time changed and changed back reads as unsaved (`…:00Z` vs `…:00.000Z`). + function asPicked(time: string | null | undefined): string | undefined { + return time ? new Date(time).toISOString() : undefined + } + async function loadScheduleCfg(cfg: Record): Promise { loading = true @@ -382,7 +388,7 @@ schedule = cfg.schedule initialSchedule = schedule timezone = cfg.timezone - paused_until = cfg.paused_until + paused_until = asPicked(cfg.paused_until) showPauseUntil = paused_until !== undefined summary = cfg.summary ?? '' labels = cfg.labels ?? undefined diff --git a/frontend/src/lib/components/triggers/useTriggerDraftSync.svelte.ts b/frontend/src/lib/components/triggers/useTriggerDraftSync.svelte.ts index abe2e287bd..daaab5a6f5 100644 --- a/frontend/src/lib/components/triggers/useTriggerDraftSync.svelte.ts +++ b/frontend/src/lib/components/triggers/useTriggerDraftSync.svelte.ts @@ -47,6 +47,8 @@ export interface TriggerDraftSyncOptions { /** * The deployed baseline the editor's dirty check compares against — * `originalConfig` for TriggerCrud editors, `initialConfig` for Schedule. + * Must read `$state`: the baseline is cached, so a plain `let` set by a later + * open stays stale and the "unsaved changes" banner never shows. */ deployed: () => Cfg | undefined } @@ -61,8 +63,8 @@ export interface TriggerDraftSync { */ readonly hasDraft: boolean /** - * Whether a deployed baseline exists (banner can appear). Reactive, unlike - * reading `deployed` directly (a plain `let`), so callers can reserve its slot. + * Whether a deployed baseline exists (banner can appear), so callers can + * reserve its slot. */ readonly hasBaseline: boolean /** The deployed baseline the dirty check compares against. */ @@ -172,8 +174,7 @@ export function useTriggerDraftSync(opts: TriggerDraftSyncOptions): TriggerDraft !opts.drawerLoading() && baseline != null && cfgDiffers(opts.getCfg() as Cfg, baseline as Cfg) ) - // Reactive "banner is possible" — depends on `drawerLoading()` so it - // re-evaluates (and re-reads the plain-`let` baseline) once the load settles. + // "Banner is possible" — false until the load settles. const hasBaseline = $derived(!opts.drawerLoading() && opts.deployed() != null) /** `auto: true` marks a discard from the reactive persist-effect (not an @@ -313,8 +314,7 @@ export function useTriggerDraftSync(opts: TriggerDraftSyncOptions): TriggerDraft const deployed = opts.deployed() if (deployed == null) return // The deployed object too, so `resetToDeployed` restores the new value; - // `settledBaseline` because it shadows the deployed object and is the - // reactive side (an editor's `deployed` may be a plain `let`). + // `settledBaseline` because it shadows the deployed object. Object.assign(deployed, patch) settledBaseline = { ...snapshotCfg(settledBaseline ?? deployed), ...patch } }