Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions apps/mobile/src/date-time.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
6 changes: 3 additions & 3 deletions apps/mobile/src/screens.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<CalendarEvent[]>(`/api/calendar/events?${query}`);
})
Expand Down
15 changes: 14 additions & 1 deletion apps/mobile/test/date-time.test.ts
Original file line number Diff line number Diff line change
@@ -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"), {
Expand All @@ -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",
Expand Down
Loading