fix(frontend): align one-time DST conversion with the scheduler (#6756)

Co-authored-by: LingYi-Liang <270316896+LingYi-Liang@users.noreply.github.com>
This commit is contained in:
LingYi 2026-10-14 22:17:51 +08:00 committed by GitHub
parent 33b8e84f11
commit 96f930f5be
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 111 additions and 15 deletions

View File

@ -2929,6 +2929,7 @@ Current MVP capabilities:
- Manage tasks at `/workspace/scheduled-tasks`
- One-time task forms reject local times skipped by daylight-saving transitions; select another time before creating or saving the task.
- Existing one-time tasks display the scheduler's execution time even when a conversation or API saved a local time skipped by daylight saving. Local times without an explicit offset use the first occurrence of a repeated hour.
- Choose whether each scheduled task reuses a thread and its conversation history or creates a fresh thread per run
- Pin each task to `lead_agent` (default) or a custom agent the owner already has; unknown names are rejected
- Sending `assistant_id: null` in a scheduled-task PATCH resets the task to `lead_agent`; omitting `assistant_id` preserves the current agent, including when that custom agent has since been deleted

View File

@ -207,6 +207,11 @@ UI validation separate from the API payload. Preserve the original instant when
wall time and timezone match the mounted snapshot; validate changed inputs, and
restore the exact original timestamp when those edits are reverted.
Wall-time conversion matches the scheduler's `ZoneInfo(fold=0)` policy: choose
the first occurrence of a repeated time and the pre-transition offset for a
skipped time. Stored one-time schedules use that instant for display; the form's
round-trip validation must still reject newly entered skipped times.
## Resources
- [LangGraph Documentation](https://langchain-ai.github.io/langgraph/)

View File

@ -542,8 +542,9 @@ export function describeTaskSchedule(
/**
* Convert a datetime-local wall value (interpreted in `timezone`) to a UTC ISO
* string. The backend's `datetime.fromisoformat` treats naive strings as UTC,
* so we must submit an explicit offset. DST-safe via Intl.
* string. Match the scheduler's ZoneInfo(fold=0) reading: use the first
* occurrence of a repeated time and the pre-transition offset for a skipped
* time. Form validation rejects skipped times separately.
*/
export function zonedLocalToUtcIso(
localValue: string,
@ -553,19 +554,20 @@ export function zonedLocalToUtcIso(
const [y, mo, d] = (date ?? "").split("-").map(Number);
const [h, mi] = (time ?? "00:00").split(":").map(Number);
const refMs = Date.UTC(y ?? 1970, (mo ?? 1) - 1, d ?? 1, h ?? 0, mi ?? 0);
// The offset at the wall-time-as-UTC instant is stale when a DST transition
// sits between that instant and the real one (e.g. "03:30" the morning of
// spring-forward resolves with the pre-transition offset, firing an hour
// late and breaking the round-trip with `utcToZonedLocalInput`). Resolve
// once, then re-derive the offset at the candidate instant; one correction
// converges for real timezones.
let offsetMs = tzOffsetMs(timezone, new Date(refMs));
let utcMs = refMs - offsetMs;
const correctedOffsetMs = tzOffsetMs(timezone, new Date(utcMs));
if (correctedOffsetMs !== offsetMs) {
offsetMs = correctedOffsetMs;
utcMs = refMs - offsetMs;
}
// Look on both sides of a possible transition, including half-hour shifts
// and a skipped calendar day. Iterating one offset guess can oscillate in
// a gap or settle on the second occurrence of a repeated time.
const dayMs = 24 * 60 * 60 * 1000;
const before = tzOffsetMs(timezone, new Date(refMs - dayMs));
const after = tzOffsetMs(timezone, new Date(refMs + dayMs));
const candidates = [...new Set([refMs - before, refMs - after])].sort(
(a, b) => a - b,
);
const utcMs =
candidates.find(
(candidate) =>
candidate + tzOffsetMs(timezone, new Date(candidate)) === refMs,
) ?? Math.max(...candidates);
const utc = new Date(utcMs);
return `${utc.getUTCFullYear()}-${pad2(utc.getUTCMonth() + 1)}-${pad2(
utc.getUTCDate(),

View File

@ -788,6 +788,42 @@ test("reuse-thread option explains the queue behaviour in the dialog", async ({
await expect(dialog.getByRole("alert")).toHaveCount(0);
});
test("a stored DST gap shows the scheduled instant and a title edit preserves it", async ({
page,
}) => {
await page.clock.setFixedTime(new Date("2026-08-01T00:00:00Z"));
const patches = writesTo(page, "PATCH", "/api/scheduled-tasks/dst-gap");
await openPage(page, {
scheduledTasks: [
task({
id: "dst-gap",
title: "Midnight reminder",
schedule_type: "once",
schedule_spec: { run_at: "2026-09-06" },
timezone: "America/Santiago",
next_run_at: "2026-09-06T04:00:00+00:00",
}),
],
});
// The server-provided next_run_at line was already correct on main.
// Check the schedule description itself, not any matching date in the panel.
await expect(
detail(page).getByText("Once, Sep 6 01:00 (America/Santiago)", {
exact: true,
}),
).toBeVisible();
await detail(page).getByRole("button", { name: "Edit" }).click();
await expect(form(page).getByLabel("Run at")).toHaveValue("2026-09-06T01:00");
await form(page)
.getByLabel("Title", { exact: true })
.fill("Renamed reminder");
await form(page).getByRole("button", { name: "Save changes" }).click();
await expect(detail(page).getByRole("heading")).toHaveText(
"Renamed reminder",
);
expect(patches).toEqual([{ title: "Renamed reminder" }]);
});
for (const locale of ["en-US", "zh-CN"] as const) {
test(`list and detail show no raw identifiers (${locale})`, async ({
page,

View File

@ -122,6 +122,28 @@ test("parent feedback and title-only rerenders retain the original instant", ()
);
});
test("a stored skipped midnight opens at the scheduler's time without rewriting it", () => {
const initial = once("2026-09-06", "America/Santiago");
const onChange = rs.fn();
const ui = render(
<ScheduledTaskScheduleInput
initial={initial}
onChange={onChange}
scheduleTypeLocked
/>,
);
const input = ui.getByLabelText("Run at") as HTMLInputElement;
expect(input.value).toBe("2026-09-06T01:00");
expect(onChange).toHaveBeenLastCalledWith(initial);
fireEvent.change(input, { target: { value: "2026-09-06T02:00" } });
expect(onChange).toHaveBeenLastCalledWith(
once("2026-09-06T05:00:00+00:00", "America/Santiago"),
);
fireEvent.change(input, { target: { value: "2026-09-06T01:00" } });
expect(onChange).toHaveBeenLastCalledWith(initial);
});
test("changing timezone recomputes the instant and reverting restores seconds", () => {
const initial = once("2027-06-01T12:30:45Z");
const onChange = rs.fn();

View File

@ -493,6 +493,21 @@ describe("utcToZonedLocalInput", () => {
});
describe("zonedLocalToUtcIso DST transitions", () => {
// Expected instants match scheduler.next_run_at's ZoneInfo(fold=0) reading.
test.each([
["America/Santiago", "2026-09-06T00:00", "2026-09-06T04:00:00+00:00"],
["America/Santiago", "2026-09-06T00:30", "2026-09-06T04:30:00+00:00"],
["America/New_York", "2026-03-08T02:30", "2026-03-08T07:30:00+00:00"],
["Europe/Berlin", "2026-03-29T02:30", "2026-03-29T01:30:00+00:00"],
["Europe/Berlin", "2026-10-25T02:30", "2026-10-25T00:30:00+00:00"],
["Australia/Lord_Howe", "2026-10-04T02:15", "2026-10-03T15:45:00+00:00"],
["Australia/Lord_Howe", "2026-04-05T01:45", "2026-04-04T14:45:00+00:00"],
["Pacific/Apia", "2011-12-30T12:00", "2011-12-30T22:00:00+00:00"],
])("matches the scheduler at %s %s", (timezone, local, expected) => {
expect(zonedLocalToUtcIso(local, timezone)).toBe(expected);
expect(onceRunAtInstant(local, timezone)).toBe(expected);
});
// US spring-forward 2026: clocks jump 02:00 -> 03:00 EST->EDT on 2026-03-08.
test("New_York wall time after spring-forward uses the post-transition offset", () => {
// 03:30 EDT (-4) is 07:30Z; the stale pre-transition offset (-5) would say 08:30Z.
@ -654,6 +669,19 @@ describe("describeTaskSchedule", () => {
});
describe("describeTaskSchedule for a chat-created one-time task", () => {
test("a stored skipped midnight displays when the scheduler will run it", () => {
const task = {
schedule_type: "once" as const,
schedule_spec: { run_at: "2026-09-06" },
timezone: "America/Santiago",
};
expect(
describeTaskSchedule(task, "en-US", {
now: new Date("2026-08-01T00:00:00Z"),
}).text,
).toBe("Once, Sep 6 01:00 (America/Santiago)");
});
test("reads the agent's local run_at in the task's zone, whatever the viewer's zone", () => {
const task = {
schedule_type: "once" as const,

View File

@ -7,6 +7,7 @@ import {
describe("one-time local time validation", () => {
test.each([
["America/Santiago", "2026-09-06T00:30"],
["America/New_York", "2027-03-14T02:30"],
["Australia/Lord_Howe", "2027-10-03T02:15"],
["Pacific/Apia", "2011-12-30T12:00"],
@ -21,6 +22,7 @@ describe("one-time local time validation", () => {
["America/New_York", "2027-03-14T01:30", "2027-03-14T06:30:00+00:00"],
["America/New_York", "2027-03-14T03:30", "2027-03-14T07:30:00+00:00"],
["America/New_York", "2026-11-01T01:30", "2026-11-01T05:30:00+00:00"],
["Europe/Berlin", "2026-10-25T02:30", "2026-10-25T00:30:00+00:00"],
["Australia/Lord_Howe", "2027-10-03T01:45", "2027-10-02T15:15:00+00:00"],
["Australia/Lord_Howe", "2027-10-03T02:45", "2027-10-02T15:45:00+00:00"],
["Asia/Shanghai", "2027-03-14T02:30", "2027-03-13T18:30:00+00:00"],