diff --git a/apps/mobile/src/date-time.ts b/apps/mobile/src/date-time.ts index a1844f1eb..3cdc18815 100644 --- a/apps/mobile/src/date-time.ts +++ b/apps/mobile/src/date-time.ts @@ -1,50 +1,11 @@ -import { localDateTime } from "../../../packages/domain/src/date-time.ts"; +import { + localDateTime, + startOfZonedDay, + zonedInstant, +} from "../../../packages/domain/src/date-time.ts"; -export { localDateTime }; - -/** Resolve a local wall-clock time, rejecting gaps at daylight-saving transitions. */ -export function zonedInstant(date: string, time: string, timeZone: string): string { - if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || !/^\d{2}:\d{2}$/.test(time)) - throw new Error("Enter a complete date and time."); - const desired = Date.parse(`${date}T${time}:00Z`); - if ( - !Number.isFinite(desired) || - new Date(desired).toISOString().slice(0, 16) !== `${date}T${time}` - ) - throw new Error("Choose a valid date and time."); - let candidate = desired; - for (let pass = 0; pass < 4; pass++) { - const local = localDateTime(new Date(candidate).toISOString(), timeZone); - const actual = Date.parse(`${local.date}T${local.time}:00Z`); - const delta = desired - actual; - if (delta === 0) return new Date(candidate).toISOString(); - candidate += delta; - } - 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."); -} +export { compareEventStart } from "../../../packages/domain/src/date-time.ts"; +export { localDateTime, startOfZonedDay, zonedInstant }; /** Only fully serialized instants may reset a date editor's local text. */ export function isCompleteInstant(value: string): boolean { diff --git a/apps/mobile/src/screens.tsx b/apps/mobile/src/screens.tsx index 5f2345a84..f74a716a7 100644 --- a/apps/mobile/src/screens.tsx +++ b/apps/mobile/src/screens.tsx @@ -40,7 +40,7 @@ import type { } from "../../../packages/domain/src"; import { API_URL } from "./api"; import { browserAddress } from "./browser-address"; -import { localDateTime, startOfZonedDay, zonedInstant } from "./date-time"; +import { compareEventStart, localDateTime, startOfZonedDay, zonedInstant } from "./date-time"; import { Button, Card, @@ -74,9 +74,7 @@ export function TodayScreen() { const pending = w.actions.filter((a) => a.status === "awaiting_review"); const unread = w.mail.filter((m) => m.unread); const today = todayDate(); - const events = w.events - .filter((e) => eventDate(e) === today) - .sort((a, b) => a.start.localeCompare(b.start)); + const events = w.events.filter((e) => eventDate(e) === today).sort(compareEventStart); return ( (`/api/calendar/events?${query}`); }) .then((items) => { - if (active) setEvents(items.sort((a, b) => a.start.localeCompare(b.start))); + if (active) setEvents(items.sort(compareEventStart)); }) .catch((e) => { if (active) { diff --git a/apps/server/src/workspace.ts b/apps/server/src/workspace.ts index 1bbe4ae51..884753e8a 100644 --- a/apps/server/src/workspace.ts +++ b/apps/server/src/workspace.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { localDateTime } from "../../../packages/domain/src/date-time.ts"; +import { compareEventStart, localDateTime } from "../../../packages/domain/src/date-time.ts"; import type { ActionProposal, ActivityEntry, @@ -104,7 +104,7 @@ export class WorkspaceService { (!options.timeMin || Date.parse(event.end) > Date.parse(options.timeMin)) ); }) - .sort((a, b) => a.start.localeCompare(b.start)); + .sort(compareEventStart); return { events, truncated: false }; } private async cacheMail(owner: string, mail: Mail[], connectionId: string) { @@ -328,7 +328,7 @@ export class WorkspaceService { email: tokens?.account ?? (this.config.mode === "sample" ? "alex@example.com" : ""), }, mail: mail.sort((a, b) => b.date.localeCompare(a.date)), - events: events.sort((a, b) => a.start.localeCompare(b.start)), + events: events.sort(compareEventStart), files: await this.files.list(owner), browsers: await this.db.list(owner, "browsers"), actions: await this.db.list(owner, "actions"), @@ -385,7 +385,7 @@ export class WorkspaceService { : undefined; return { mail: wantMail ? mail.sort((a, b) => b.date.localeCompare(a.date)) : undefined, - events: wantEvents ? events.sort((a, b) => a.start.localeCompare(b.start)) : undefined, + events: wantEvents ? events.sort(compareEventStart) : undefined, files, }; } diff --git a/packages/domain/src/date-time.ts b/packages/domain/src/date-time.ts index 61a24d2f1..69f7a0a81 100644 --- a/packages/domain/src/date-time.ts +++ b/packages/domain/src/date-time.ts @@ -26,3 +26,82 @@ export function localDateTime(value: string, timeZone: string): { date: string; time: `${part("hour")}:${part("minute")}`, }; } + +/** Resolve a local wall-clock time, rejecting gaps at daylight-saving transitions. */ +export function zonedInstant(date: string, time: string, timeZone: string): string { + if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || !/^\d{2}:\d{2}$/.test(time)) + throw new Error("Enter a complete date and time."); + const desired = Date.parse(`${date}T${time}:00Z`); + if ( + !Number.isFinite(desired) || + new Date(desired).toISOString().slice(0, 16) !== `${date}T${time}` + ) + throw new Error("Choose a valid date and time."); + let candidate = desired; + for (let pass = 0; pass < 4; pass++) { + const local = localDateTime(new Date(candidate).toISOString(), timeZone); + const actual = Date.parse(`${local.date}T${local.time}:00Z`); + const delta = desired - actual; + if (delta === 0) return new Date(candidate).toISOString(); + candidate += delta; + } + 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."); +} + +export interface EventStartLike { + start: string; + allDay?: boolean; + timeZone?: string; +} + +const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/; + +function eventStartMs(event: EventStartLike): number { + if (event.allDay || DATE_ONLY.test(event.start)) { + // A date-only start names a day, not an instant: Date.parse would read it + // as midnight UTC, which lands inside the previous day west of Greenwich. + // Anchor it to the start of that day in the event's own time zone. + try { + return Date.parse(startOfZonedDay(event.start.slice(0, 10), event.timeZone || "UTC")); + } catch { + return Number.NaN; + } + } + return Date.parse(event.start); +} + +/** + * Order events by the instant they start. All-day events sit at the start of + * their day in their own time zone. When either start cannot be parsed, + * Date.parse yields NaN and subtraction would make the comparator inconsistent, + * so fall back to the previous text comparison, which is always total. + */ +export function compareEventStart(a: EventStartLike, b: EventStartLike): number { + const aMs = eventStartMs(a); + const bMs = eventStartMs(b); + if (Number.isFinite(aMs) && Number.isFinite(bMs)) return aMs - bMs; + return a.start.localeCompare(b.start); +} diff --git a/tests/calendar-event-order.test.ts b/tests/calendar-event-order.test.ts new file mode 100644 index 000000000..9cade87bf --- /dev/null +++ b/tests/calendar-event-order.test.ts @@ -0,0 +1,180 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import type { Config } from "../apps/server/src/config.ts"; +import { createStore } from "../apps/server/src/db.ts"; +import type { Files } from "../apps/server/src/files.ts"; +import type { GoogleAuth } from "../apps/server/src/google-auth.ts"; +import { WorkspaceService } from "../apps/server/src/workspace.ts"; +import type { CalendarEvent } from "../packages/domain/src/index.ts"; + +test("calendar reads sort timed events by instant rather than offset text", async () => { + const db = await createStore(); + try { + const workspace = new WorkspaceService( + db, + { mode: "sample", agentBackend: "sample" } as Config, + { list: async () => [] } as unknown as Files, + {} as GoogleAuth, + ); + const event = (id: string, start: string, end: string): CalendarEvent => ({ + id, + calendarId: "primary", + title: id, + start, + end, + allDay: false, + timeZone: "UTC", + location: "", + description: "", + attendees: [], + }); + // The event with the later-looking local clock actually starts first. + await db.put( + "owner", + "events", + event("later", "2026-10-10T09:00:00-07:00", "2026-10-10T10:00:00-07:00"), + ); + await db.put( + "owner", + "events", + event("earlier", "2026-10-10T14:00:00+00:00", "2026-10-10T15:00:00+00:00"), + ); + const ids = (events: CalendarEvent[]) => events.map(({ id }) => id); + assert.deepEqual(ids(await workspace.events("owner")), ["earlier", "later"]); + assert.deepEqual(ids((await workspace.snapshot("owner")).events), ["earlier", "later"]); + assert.deepEqual(ids((await workspace.sectionSnapshot("owner", "calendar")).events ?? []), [ + "earlier", + "later", + ]); + } finally { + await db.close(); + } +}); + +test("calendar reads place an all-day event at the start of its own day", async () => { + const db = await createStore(); + try { + const workspace = new WorkspaceService( + db, + { mode: "sample", agentBackend: "sample" } as Config, + { list: async () => [] } as unknown as Files, + {} as GoogleAuth, + ); + const event = ( + id: string, + start: string, + end: string, + allDay = false, + timeZone = "America/Los_Angeles", + ): CalendarEvent => ({ + id, + calendarId: "primary", + title: id, + start, + end, + allDay, + timeZone, + location: "", + description: "", + attendees: [], + }); + await db.put( + "owner", + "events", + event("evening", "2026-10-09T18:00:00-07:00", "2026-10-09T19:00:00-07:00"), + ); + await db.put("owner", "events", event("allday", "2026-10-10", "2026-10-11", true)); + const ids = (events: CalendarEvent[]) => events.map(({ id }) => id); + // The all-day event belongs to the morning of the 10th in its own zone, + // so the evening meeting of the 9th comes first. + assert.deepEqual(ids(await workspace.events("owner")), ["evening", "allday"]); + } finally { + await db.close(); + } +}); + +test("calendar reads keep a stable text order when a start does not parse", async () => { + const db = await createStore(); + try { + const workspace = new WorkspaceService( + db, + { mode: "sample", agentBackend: "sample" } as Config, + { list: async () => [] } as unknown as Files, + {} as GoogleAuth, + ); + const event = (id: string, start: string, end: string): CalendarEvent => ({ + id, + calendarId: "primary", + title: id, + start, + end, + allDay: false, + timeZone: "UTC", + location: "", + description: "", + attendees: [], + }); + await db.put( + "owner", + "events", + event("zulu", "2026-10-10T14:00:00+00:00", "2026-10-10T15:00:00+00:00"), + ); + await db.put("owner", "events", event("junk", "not a real start", "not a real end")); + await db.put( + "owner", + "events", + event("pacific", "2026-10-10T09:00:00-07:00", "2026-10-10T10:00:00-07:00"), + ); + const ids = (events: CalendarEvent[]) => events.map(({ id }) => id); + // Text fallback between an unparseable start and a parseable one keeps the + // comparison total: digits sort before letters, so the junk entry lands last. + assert.deepEqual(ids(await workspace.events("owner")), ["zulu", "pacific", "junk"]); + } finally { + await db.close(); + } +}); + +test("compareEventStart orders by instant across offsets and zones", async () => { + const { compareEventStart } = await import("../packages/domain/src/date-time.ts"); + const timed = (start: string, timeZone = "UTC") => ({ start, allDay: false, timeZone }); + const allDay = (date: string, timeZone: string) => ({ start: date, allDay: true, timeZone }); + const sorted = (events: T[]) => [...events].sort(compareEventStart); + + // The event with the later-looking local clock starts first. + assert.deepEqual( + sorted([timed("2026-10-10T09:00:00-07:00"), timed("2026-10-10T14:00:00+00:00")]).map( + (e) => e.start, + ), + ["2026-10-10T14:00:00+00:00", "2026-10-10T09:00:00-07:00"], + ); + // Behind UTC: the all-day event starts the 10th in its zone, after the 9th's evening. + assert.deepEqual( + sorted([allDay("2026-10-10", "America/Los_Angeles"), timed("2026-10-09T18:00:00-07:00")]).map( + (e) => e.start, + ), + ["2026-10-09T18:00:00-07:00", "2026-10-10"], + ); + // Ahead of UTC: the all-day event starts the 10th in its zone, before that morning's meeting. + assert.deepEqual( + sorted([timed("2026-10-10T09:00:00+13:00"), allDay("2026-10-10", "Pacific/Auckland")]).map( + (e) => e.start, + ), + ["2026-10-10", "2026-10-10T09:00:00+13:00"], + ); + // A date-only start is treated as all-day even without the flag. + assert.deepEqual( + sorted([timed("2026-10-09T23:00:00+00:00"), { start: "2026-10-10", timeZone: "UTC" }]).map( + (e) => e.start, + ), + ["2026-10-09T23:00:00+00:00", "2026-10-10"], + ); + // An unparseable start falls back to text comparison instead of scrambling the list. + assert.deepEqual( + sorted([ + timed("2026-10-10T14:00:00+00:00"), + { start: "garbage" }, + timed("2026-10-10T09:00:00-07:00"), + ]).map((e) => e.start), + ["2026-10-10T14:00:00+00:00", "2026-10-10T09:00:00-07:00", "garbage"], + ); +});