From 2c09f51b9bbfceda74f62d09ac69a00b7d937294 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BB=BA=E5=86=9B?= Date: Tue, 6 Oct 2026 14:55:28 +0800 Subject: [PATCH 1/2] fix: keep the calendar day view working when midnight is skipped by DST MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The events query built its day range with zonedInstant(date, '00:00'), which deliberately throws for nonexistent local times. On days when a zone springs forward at midnight (e.g. America/Santiago 00:00→01:00), the whole day view failed with 'This time does not exist' and showed no events. Day boundaries are a range-computation detail, not an entered appointment time, so startOfZonedDay clamps to the first existing local time instead of throwing. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/mobile/src/date-time.ts | 23 +++++++++++++++++++++++ apps/mobile/src/screens.tsx | 6 +++--- apps/mobile/test/date-time.test.ts | 10 +++++++++- 3 files changed, 35 insertions(+), 4 deletions(-) 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 86ba851d5..64a193dc4 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, @@ -636,8 +636,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..f23cd1856 100644 --- a/apps/mobile/test/date-time.test.ts +++ b/apps/mobile/test/date-time.test.ts @@ -1,6 +1,6 @@ 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 +27,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", From f1e9a131c82fb5382adc5df03e9b234a7ee61499 Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Tue, 6 Oct 2026 12:29:05 -0700 Subject: [PATCH 2/2] style: format calendar date-time test imports --- apps/mobile/test/date-time.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/mobile/test/date-time.test.ts b/apps/mobile/test/date-time.test.ts index f23cd1856..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, startOfZonedDay, 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"), {