From 6a75685854234565e02d03e91bbe41637b1d5916 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sat, 31 Oct 2026 12:25:42 +0530 Subject: [PATCH] fix(web): settings time fields honor the time format preference --- .../ScheduledTasksSettings.browser.test.tsx | 82 ++++++++++++++++- .../settings/ScheduledTasksSettings.tsx | 12 ++- .../settings/SnoozePresetsSettings.tsx | 26 +++--- .../components/settings/TimeOfDayInput.tsx | 89 +++++++++++++++++++ 4 files changed, 184 insertions(+), 25 deletions(-) create mode 100644 apps/web/src/components/settings/TimeOfDayInput.tsx diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx index ca8192dd894e3..5bbf4e6b51b6e 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx @@ -1,4 +1,5 @@ -import { RegistryContext } from "@effect/atom-react"; +import { RegistryContext, useAtomValue } from "@effect/atom-react"; +import type { TimestampFormat } from "@t3tools/contracts/settings"; import { Atom, AtomRegistry } from "effect/reactivity"; import { DEFAULT_UNIFIED_SETTINGS, @@ -15,6 +16,7 @@ import { page, userEvent } from "vite-plus/test/browser"; import "~/index.css"; import { act, render } from "../../../test/render"; import { ScheduledTasksSettings } from "./ScheduledTasksSettings"; +import { resolveHour12 } from "../../timestampFormat"; const environmentId = EnvironmentId.make("laptop"); const projectId = ProjectId.make("project"); @@ -40,6 +42,10 @@ const task: ScheduledTask = { lastRunError: null, runCount: 0, }; +const fixedTask: ScheduledTask = { + ...task, + schedule: { type: "fixed_time", timeOfDay: "17:30" }, +}; // Stands in for the thread family read the editor makes when the shell lacks the bound thread. const threadFamilyRead = { @@ -47,7 +53,13 @@ const threadFamilyRead = { threads: [{ id: task.threadId, title: "Fix login bug" }], }; -const state = vi.hoisted(() => ({ connected: true, tasksLoaded: true, secondary: false })); +const state = vi.hoisted(() => ({ + connected: true, + tasksLoaded: true, + secondary: false, + fixed: false, +})); +const settingsAtom = Atom.make(DEFAULT_UNIFIED_SETTINGS); const secondaryId = EnvironmentId.make("secondary"); const permissions = new Map>(); @@ -55,6 +67,7 @@ function setup() { state.connected = true; state.tasksLoaded = true; state.secondary = false; + state.fixed = false; permissions.clear(); permissions.set(environmentId, Atom.make(true)); permissions.set(secondaryId, Atom.make(true)); @@ -125,7 +138,9 @@ vi.mock(import("../../state/query"), async (original) => ({ data: request === null || request === threadFamilyRead ? request - : { tasks: state.tasksLoaded ? [task] : [] }, + : { + tasks: state.tasksLoaded ? [state.fixed ? fixedTask : task] : [], + }, error: null, isPending: false, refresh: vi.fn(), @@ -142,10 +157,69 @@ vi.mock(import("../../state/entities"), async (original) => ({ })); vi.mock(import("../../hooks/useSettings"), async (original) => ({ ...(await original()), - useEnvironmentSettings: (() => DEFAULT_UNIFIED_SETTINGS) as never, + useEnvironmentSettings: (() => useAtomValue(settingsAtom)) as never, })); describe("ScheduledTasksSettings", () => { + it("honors time format while preserving the fixed time through noon and midnight", async () => { + setup(); + state.fixed = true; + const registry = AtomRegistry.make(); + using _registry = { [Symbol.dispose]: () => registry.dispose() }; + registry.set(settingsAtom, { ...DEFAULT_UNIFIED_SETTINGS, timestampFormat: "24-hour" }); + await using _screen = await render( + + + , + ); + const setFormat = (timestampFormat: TimestampFormat) => + act(() => registry.set(settingsAtom, { ...DEFAULT_UNIFIED_SETTINGS, timestampFormat })); + const hours = page.getByRole("combobox", { name: "Run at hours", exact: true }); + const minutes = page.getByRole("combobox", { name: "Run at minutes", exact: true }); + const period = page.getByRole("combobox", { name: "Run at period", exact: true }); + await expect.element(page.getByRole("dialog")).toBeVisible(); + await expect.element(hours).toHaveTextContent("17"); + await expect.element(minutes).toHaveTextContent("30"); + await expect.element(period).not.toBeInTheDocument(); + + await setFormat("12-hour"); + await expect.element(hours).toHaveTextContent("05"); + await expect.element(period).toHaveTextContent("PM"); + await hours.click(); + await page.getByRole("option", { name: "12", exact: true }).click(); + await minutes.click(); + await page.getByRole("option", { name: "00", exact: true }).click(); + + await setFormat("24-hour"); + await expect.element(hours).toHaveTextContent("12"); + await expect.element(minutes).toHaveTextContent("00"); + + await setFormat("12-hour"); + await period.click(); + await page.getByRole("option", { name: "AM", exact: true }).click(); + await setFormat("24-hour"); + await expect.element(hours).toHaveTextContent("00"); + await expect.element(minutes).toHaveTextContent("00"); + + await setFormat("locale"); + await expect.element(hours).toHaveTextContent(resolveHour12("locale") ? "12" : "00"); + if (resolveHour12("locale")) { + await expect.element(period).toHaveTextContent("AM"); + } else { + await expect.element(period).not.toBeInTheDocument(); + } + + await setFormat("24-hour"); + await hours.click(); + await page.getByRole("option", { name: "23", exact: true }).click(); + await minutes.click(); + await page.getByRole("option", { name: "59", exact: true }).click(); + await setFormat("12-hour"); + await expect.element(hours).toHaveTextContent("11"); + await expect.element(minutes).toHaveTextContent("59"); + await expect.element(period).toHaveTextContent("PM"); + }); + it("creates on a permitted secondary and reacts to permission changes", async () => { setup(); state.secondary = true; diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.tsx index 9489e4162cba2..e0764a0e81d95 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx @@ -72,6 +72,7 @@ import { ConnectionStatusDot, connectionPhaseDotClassName } from "../ConnectionS import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; import { readEnvironmentScope } from "~/state/session"; import { useSettingsScope } from "./SettingsScopeContext"; +import { TimeOfDayInput } from "./TimeOfDayInput"; import { EnvironmentRow, useEnvironmentTransportLabel } from "./EnvironmentRow"; import { SettingsGroup } from "./SettingsGroup"; import { settingsScopeEnvironmentLabel, settingsScopeEnvironmentLabels } from "./settingsScopeAxis"; @@ -1829,15 +1830,12 @@ function ScheduledTaskEditorForm({
- - setDraft((current) => ({ ...current, timeOfDay: event.target.value })) - } + timestampFormat={settings.timestampFormat} + onChange={(timeOfDay) => setDraft((current) => ({ ...current, timeOfDay }))} /> on
diff --git a/apps/web/src/components/settings/SnoozePresetsSettings.tsx b/apps/web/src/components/settings/SnoozePresetsSettings.tsx index 22f2d43497fde..f66983782b19c 100644 --- a/apps/web/src/components/settings/SnoozePresetsSettings.tsx +++ b/apps/web/src/components/settings/SnoozePresetsSettings.tsx @@ -19,7 +19,7 @@ import { useNowMinute } from "../../hooks/useNowMinute"; import { reconcileSnoozeDraft, sortSnoozeRows } from "./SnoozePresetsSettings.logic"; import { formatShortTimestamp } from "../../timestampFormat"; import { Button } from "../ui/button"; -import { Input } from "../ui/input"; +import { TimeOfDayInput } from "./TimeOfDayInput"; import { NumberField, NumberFieldDecrement, @@ -284,7 +284,11 @@ function SnoozePresetList(props: { aria-label={`Snooze option ${index + 1}`} className="flex flex-wrap items-center gap-2" > - props.onChange(index, next)} /> + props.onChange(index, next)} + />
{preview.tone === "menu" ? ( @@ -331,6 +335,7 @@ function SnoozePresetList(props: { function SnoozePresetFields(props: { preset: SnoozePresetSetting; + timestampFormat: Parameters[1]; onChange: (preset: SnoozePresetSetting) => void; }) { const { preset, onChange } = props; @@ -339,7 +344,7 @@ function SnoozePresetFields(props: { // misclick on "When" can be undone without retyping it. const [memory, setMemory] = useState({ minutes: 60, time: "09:00" }); return ( -
+
{ - if (/^\d{2}:\d{2}$/.test(event.target.value)) { - onChange({ ...preset, time: event.target.value }); - } - }} + timestampFormat={props.timestampFormat} + onChange={(time) => onChange({ ...preset, time })} /> )} diff --git a/apps/web/src/components/settings/TimeOfDayInput.tsx b/apps/web/src/components/settings/TimeOfDayInput.tsx new file mode 100644 index 0000000000000..cd42b335c73fa --- /dev/null +++ b/apps/web/src/components/settings/TimeOfDayInput.tsx @@ -0,0 +1,89 @@ +import type { TimestampFormat } from "@t3tools/contracts/settings"; + +import { resolveHour12 } from "../../timestampFormat"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; + +const pad = (value: number) => String(value).padStart(2, "0"); +const HOURS_24 = Array.from({ length: 24 }, (_, hour) => pad(hour)); +const HOURS_12 = Array.from({ length: 12 }, (_, hour) => pad(hour + 1)); +const MINUTES = Array.from({ length: 60 }, (_, minute) => pad(minute)); +const PERIODS = ["AM", "PM"]; + +export function TimeOfDayInput({ + id, + label, + value, + timestampFormat, + onChange, +}: { + id?: string; + label: string; + value: string; + timestampFormat: TimestampFormat; + onChange: (value: string) => void; +}) { + const hour12 = resolveHour12(timestampFormat); + const separator = value.indexOf(":"); + const hour = Number(value.slice(0, separator)); + const minute = value.slice(separator + 1); + const period = hour < 12 ? "AM" : "PM"; + + return ( +
+ { + const nextHour = hour12 ? (Number(next) % 12) + (hour >= 12 ? 12 : 0) : Number(next); + onChange(`${pad(nextHour)}:${minute}`); + }} + /> + + onChange(`${pad(hour)}:${next}`)} + /> + {hour12 ? ( + onChange(`${pad((hour % 12) + (next === "PM" ? 12 : 0))}:${minute}`)} + /> + ) : null} +
+ ); +} + +function TimePart({ + id, + label, + value, + options, + onChange, +}: { + id?: string | undefined; + label: string; + value: string; + options: ReadonlyArray; + onChange: (value: string) => void; +}) { + return ( + + ); +}