fix: spell the schedule pause time the way the picker writes it (#11875)

* fix: make the schedule editor's saved baseline reactive

* fix: spell the schedule pause time the way the picker writes it

* fix: read a zone-less naive date-time as wall-clock time

---------

Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
This commit is contained in:
AlexRV12
2026-10-19 23:19:38 +02:00
committed by GitHub
co-authored by Ruben Fiszel
parent 4c254c60ed
commit 4186ffff84
5 changed files with 36 additions and 10 deletions
@@ -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'
@@ -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
}
})
@@ -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)
}
@@ -103,7 +103,7 @@
let loading = $state(false)
let drawerLoading = $state(true)
let showLoading = $state(false)
let initialConfig: Record<string, any> | undefined = undefined
let initialConfig = $state<Record<string, any> | undefined>(undefined)
let extraPerms: Record<string, boolean> = $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<string, any>): Promise<void> {
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
@@ -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 }
}