diff --git a/apps/mobile/src/date-time.ts b/apps/mobile/src/date-time.ts index 81c97e86a..1483c4dfc 100644 --- a/apps/mobile/src/date-time.ts +++ b/apps/mobile/src/date-time.ts @@ -40,6 +40,29 @@ export function zonedInstant(date: string, time: string, timeZone: string): stri throw new Error("This time does not exist in the selected time zone. Choose another time."); } +/** + * The first existing instant of a local day. Day-range boundaries are an internal + * computation, not a user-entered appointment time, so when a DST gap removes + * midnight (e.g. America/Santiago springs forward 00:00→01:00) the boundary + * clamps to the first existing local time instead of failing the whole query. + */ +export function startOfZonedDay(date: string, timeZone: string): string { + try { + return zonedInstant(date, "00:00", timeZone); + } catch (error) { + if (!(error instanceof Error) || !error.message.includes("does not exist")) throw error; + } + for (let minutes = 1; minutes < 24 * 60; minutes++) { + const time = `${String(Math.floor(minutes / 60)).padStart(2, "0")}:${String(minutes % 60).padStart(2, "0")}`; + try { + return zonedInstant(date, time, timeZone); + } catch { + // Still inside the gap; keep walking toward the first existing time. + } + } + throw new Error("This day has no existing local time in the selected time zone."); +} + /** Only fully serialized instants may reset a date editor's local text. */ export function isCompleteInstant(value: string): boolean { return ( diff --git a/apps/mobile/src/screens.tsx b/apps/mobile/src/screens.tsx index 2e4afca79..2dc8bb0ca 100644 --- a/apps/mobile/src/screens.tsx +++ b/apps/mobile/src/screens.tsx @@ -39,7 +39,7 @@ import type { EmailDraft, } from "../../../packages/domain/src"; import { API_URL } from "./api"; -import { localDateTime, zonedInstant } from "./date-time"; +import { localDateTime, startOfZonedDay, zonedInstant } from "./date-time"; import { Button, Card, @@ -645,8 +645,8 @@ export function CalendarScreen() { .then(() => { const query = new URLSearchParams({ calendarId, - timeMin: zonedInstant(date, "00:00", zone), - timeMax: zonedInstant(plusDays(date, all ? 30 : 1), "00:00", zone), + timeMin: startOfZonedDay(date, zone), + timeMax: startOfZonedDay(plusDays(date, all ? 30 : 1), zone), }); return api.request(`/api/calendar/events?${query}`); }) diff --git a/apps/mobile/test/date-time.test.ts b/apps/mobile/test/date-time.test.ts index 29fcffa65..c9845b474 100644 --- a/apps/mobile/test/date-time.test.ts +++ b/apps/mobile/test/date-time.test.ts @@ -1,6 +1,11 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { isCompleteInstant, localDateTime, zonedInstant } from "../src/date-time.ts"; +import { + isCompleteInstant, + localDateTime, + startOfZonedDay, + zonedInstant, +} from "../src/date-time.ts"; test("calendar time is rendered and entered in the selected named zone", () => { assert.deepEqual(localDateTime("2026-09-15T17:30:00Z", "America/Los_Angeles"), { @@ -27,6 +32,14 @@ test("a daylight-saving gap cannot become a different appointment time", () => { ); }); +test("a day boundary clamps to the first existing local time instead of failing", () => { + // America/Santiago springs forward 00:00→01:00 on 2026-09-06. + assert.equal(startOfZonedDay("2026-09-06", "America/Santiago"), "2026-09-06T04:00:00.000Z"); + // Ordinary days still start exactly at midnight (Santiago is UTC-3 after the transition). + assert.equal(startOfZonedDay("2026-09-07", "America/Santiago"), "2026-09-07T03:00:00.000Z"); + assert.equal(startOfZonedDay("2026-09-15", "UTC"), "2026-09-15T00:00:00.000Z"); +}); + test("partial native date edits never normalize from Date.parse", () => { for (const value of [ "2026-09-1 09:00",