From 3a56ceaa0f730eace99ff29763a214f9f159a2db Mon Sep 17 00:00:00 2001 From: JJ Eaton <58495321+oldbettie@users.noreply.github.com> Date: Fri, 13 Nov 2026 15:40:07 +0800 Subject: [PATCH] fix(mobile): schedule type fits a phone, and times read the same everywhere The five-option schedule type control overflowed at 390pt, so it is now a "Runs" menu row. Cron summaries use the device's time format like fixed-time ones. Zone labels no longer collapse "GMT+7" to "GMT" when the platform's Intl drops the offset from the zone name part. Co-Authored-By: Claude Opus 5.5 --- .../SettingsScheduledTasksRouteScreen.tsx | 89 +++++++++++-------- .../src/scheduledTaskCron.test.ts | 9 ++ .../client-runtime/src/scheduledTaskCron.ts | 9 +- .../src/scheduledTaskTimeZone.test.ts | 22 ++++- .../src/scheduledTaskTimeZone.ts | 42 ++++++++- 5 files changed, 125 insertions(+), 46 deletions(-) diff --git a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx index 36b6eeba39773..239ad46d64921 100644 --- a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx @@ -61,7 +61,6 @@ import { import { Menu } from "../../components/ui/Menu"; import type { ComposerEditorSelection } from "../../components/ComposerEditor"; import { ScreenScrollView as ScrollView } from "../../components/ui/ScreenScrollView"; -import { SegmentedControl } from "../../components/ui/SegmentedControl"; import { ListFilterMenu } from "../layout/workspace/ListFilterMenu"; import { agentModelLabel, useEnvironmentAgents } from "../../state/agents"; import { ThemedSwitch } from "../../components/ui/ThemedSwitch"; @@ -90,6 +89,7 @@ import { scheduledTaskDraftRouting, scheduleFromDraft, unbindDraftFromThread, + type ScheduleDraft, type ScheduledTaskDraft as Draft, } from "./scheduledTaskDraft"; import { settingsTargetsForProject } from "./settings-environment-filter.logic"; @@ -138,6 +138,15 @@ const THREAD_MODE_LABELS: Record = { }; const AGENT_DESTINATION_LABEL = "An agent's own thread"; +const SCHEDULE_MODE_LABELS: Record = { + fixed_time: "At a time", + once: "Once", + interval: "Interval", + cron: "Cron", + webhook: "On webhook", +}; +const SCHEDULE_MODES = Object.keys(SCHEDULE_MODE_LABELS) as ReadonlyArray; + const REPEAT_PRESET_LABELS: Record = { daily: "Every day", weekdays: "Weekdays", @@ -151,7 +160,7 @@ function describeSchedule( timeZone?: string, ): string { if (task.schedule.type === "cron") { - return describeRepeatSchedule(task.schedule, (iso) => iso, localTimeZone()); + return describeRepeatSchedule(task.schedule, (iso) => iso, localTimeZone(), formatTime); } if (task.schedule.type === "webhook") return "On webhook"; if (task.schedule.type === "once") { @@ -1222,43 +1231,45 @@ function TaskForm({ - - { - setTimePickerOpen(false); - setRunAtPicker(null); - setDraft({ - ...draft, - prompt: - mode === "webhook" && !draft.prompt.trim() - ? DEFAULT_WEBHOOK_PROMPT - : draft.prompt, - schedule: { - ...draft.schedule, - mode, - runAt: - mode === "once" && !draft.schedule.runAt - ? new Date(Math.ceil(Date.now() / 3_600_000) * 3_600_000).toISOString() - : draft.schedule.runAt, - }, - }); - }} - /> - + ({ + id: mode, + title: SCHEDULE_MODE_LABELS[mode], + state: draft.schedule.mode === mode ? ("on" as const) : undefined, + }))} + onSelect={(id) => { + const mode = SCHEDULE_MODES.find((entry) => entry === id); + if (mode === undefined) return; + setTimePickerOpen(false); + setRunAtPicker(null); + setDraft({ + ...draft, + prompt: + mode === "webhook" && !draft.prompt.trim() + ? DEFAULT_WEBHOOK_PROMPT + : draft.prompt, + schedule: { + ...draft.schedule, + mode, + runAt: + mode === "once" && !draft.schedule.runAt + ? new Date(Math.ceil(Date.now() / 3_600_000) * 3_600_000).toISOString() + : draft.schedule.runAt, + }, + }); + }} + /> {draft.schedule.mode === "cron" ? ( { expect( describeSchedule({ type: "cron", expression: "0 7 * * 1-5", timezone: "UTC" }, at, "UTC"), ).toBe("Weekdays at 07:00"); + const twelveHour = (time: string) => `${Number(time.slice(0, 2)) % 12 || 12}:${time.slice(3)}`; + expect( + describeSchedule( + { type: "cron", expression: "30 14 * * 1-5", timezone: "UTC" }, + at, + "UTC", + twelveHour, + ), + ).toBe("Weekdays at 2:30"); expect( describeSchedule( { type: "cron", expression: "30 6 * * 0", timezone: "Asia/Bangkok" }, diff --git a/packages/client-runtime/src/scheduledTaskCron.ts b/packages/client-runtime/src/scheduledTaskCron.ts index 42f0d001f2ac1..873de398b9c01 100644 --- a/packages/client-runtime/src/scheduledTaskCron.ts +++ b/packages/client-runtime/src/scheduledTaskCron.ts @@ -73,6 +73,7 @@ export function describeSchedule( schedule: ScheduledTaskSchedule, formatInstant: (iso: string) => string, viewerTimeZone: string = localTimeZone(), + formatTimeOfDay: (time: string) => string = (time) => time, ): string { switch (schedule.type) { case "once": @@ -89,7 +90,7 @@ export function describeSchedule( days.length === 0 || days.length === 7 ? "Every day" : days.map((day) => WEEKDAY_NAMES[day]).join(", "); - return `${on} at ${schedule.timeOfDay}`; + return `${on} at ${formatTimeOfDay(schedule.timeOfDay)}`; } case "webhook": return "On webhook"; @@ -100,11 +101,11 @@ export function describeSchedule( const zone = schedule.timezone === viewerTimeZone ? "" : ` (${schedule.timezone})`; switch (form.preset) { case "daily": - return `Every day at ${form.time}${zone}`; + return `Every day at ${formatTimeOfDay(form.time)}${zone}`; case "weekdays": - return `Weekdays at ${form.time}${zone}`; + return `Weekdays at ${formatTimeOfDay(form.time)}${zone}`; case "weekly": - return `Every ${WEEKDAY_NAMES[form.weekday]} at ${form.time}${zone}`; + return `Every ${WEEKDAY_NAMES[form.weekday]} at ${formatTimeOfDay(form.time)}${zone}`; case "hourly": return `Every hour at :${form.time.slice(3)}${zone}`; case "custom": diff --git a/packages/client-runtime/src/scheduledTaskTimeZone.test.ts b/packages/client-runtime/src/scheduledTaskTimeZone.test.ts index 35ce8c4b70f7a..e83ee4f04e650 100644 --- a/packages/client-runtime/src/scheduledTaskTimeZone.test.ts +++ b/packages/client-runtime/src/scheduledTaskTimeZone.test.ts @@ -1,4 +1,4 @@ -import { expect, it } from "vite-plus/test"; +import { expect, it, vi } from "vite-plus/test"; import { scheduleTimeZoneLabel } from "./scheduledTaskTimeZone.ts"; @@ -10,3 +10,23 @@ it("uses short names instead of full IANA zones", () => { expect(scheduleTimeZoneLabel("America/Los_Angeles", "2027-01-15T17:00:00Z")).toBe("PST"); expect(scheduleTimeZoneLabel("America/Los_Angeles", "2027-07-15T16:00:00Z")).toBe("PDT"); }); + +it("keeps the offset when the zone name part drops it", () => { + const formatToParts = Intl.DateTimeFormat.prototype.formatToParts; + // Mimics an Intl that returns "GMT+7" as a "GMT" name followed by a "+7" literal. + using _parts = vi + .spyOn(Intl.DateTimeFormat.prototype, "formatToParts") + .mockImplementation(function (this: Intl.DateTimeFormat, date) { + return formatToParts.call(this, date).flatMap((part) => + part.type === "timeZoneName" && /^GMT[+-]/.test(part.value) + ? [ + { type: "timeZoneName" as const, value: "GMT" }, + { type: "literal" as const, value: part.value.slice(3) }, + ] + : [part], + ); + }); + expect(scheduleTimeZoneLabel("Asia/Bangkok", "2026-11-16T02:00:00Z")).toBe("GMT+7"); + expect(scheduleTimeZoneLabel("Asia/Kolkata", "2026-11-16T03:30:00Z")).toBe("GMT+5:30"); + expect(scheduleTimeZoneLabel("Europe/London", "2027-01-15T09:00:00Z")).toBe("GMT"); +}); diff --git a/packages/client-runtime/src/scheduledTaskTimeZone.ts b/packages/client-runtime/src/scheduledTaskTimeZone.ts index 9321ab11acbe8..ae2f5018f4af2 100644 --- a/packages/client-runtime/src/scheduledTaskTimeZone.ts +++ b/packages/client-runtime/src/scheduledTaskTimeZone.ts @@ -1,5 +1,38 @@ import * as DateTime from "effect/DateTime"; +const MINUTE_MS = 60_000; + +/** Offset from UTC in minutes, read from numeric date parts rather than the zone name. */ +function zoneOffsetMinutes(timeZone: string, at: number): number { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone, + hourCycle: "h23", + year: "numeric", + month: "numeric", + day: "numeric", + hour: "numeric", + minute: "numeric", + }).formatToParts(at); + const part = (type: Intl.DateTimeFormatPartTypes) => + Number(parts.find((entry) => entry.type === type)?.value); + const wallClock = Date.UTC( + part("year"), + part("month") - 1, + part("day"), + part("hour"), + part("minute"), + ); + return Math.round((wallClock - Math.floor(at / MINUTE_MS) * MINUTE_MS) / MINUTE_MS); +} + +function offsetLabel(minutes: number): string { + if (minutes === 0) return "GMT"; + const absolute = Math.abs(minutes); + const hours = Math.floor(absolute / 60); + const rest = absolute % 60; + return `GMT${minutes < 0 ? "-" : "+"}${hours}${rest === 0 ? "" : `:${String(rest).padStart(2, "0")}`}`; +} + /** * Short label for the server zone a fixed-time schedule runs in. With a run time it names * that instant's offset (PST/PDT); without one it uses the generic name (PT). @@ -11,12 +44,17 @@ export function scheduleTimeZoneLabel( if (timeZone === undefined) return undefined; if (timeZone === "Etc/UTC" || timeZone === "UTC" || timeZone === "GMT") return "UTC"; try { - return new Intl.DateTimeFormat("en-US", { + const at = runAt + ? DateTime.toEpochMillis(DateTime.makeUnsafe(runAt)) + : DateTime.toEpochMillis(DateTime.nowUnsafe()); + const name = new Intl.DateTimeFormat("en-US", { timeZone, timeZoneName: runAt ? "short" : "shortGeneric", }) - .formatToParts(runAt ? DateTime.toEpochMillis(DateTime.makeUnsafe(runAt)) : undefined) + .formatToParts(at) .find((part) => part.type === "timeZoneName")?.value; + // Hermes on iOS can return "GMT+7" as a bare "GMT" name, which would claim the wrong zone. + return name === "GMT" ? offsetLabel(zoneOffsetMinutes(timeZone, at)) : name; } catch { return undefined; }