From eb1f6eac78d2d79f86b91c219d5b60949f998bd9 Mon Sep 17 00:00:00 2001 From: Charan Rathore <180254320+charan-rathore@users.noreply.github.com> Date: Thu, 8 Oct 2026 11:09:09 +0530 Subject: [PATCH 1/2] fix(server): sort calendar events by instant, not offset text The three server calendar read paths sorted ISO timestamp strings lexically, which orders mixed-offset events wrong: 14:00Z was returned after 09:00-07:00 even though it starts an hour earlier. Compare parsed instants instead in events(), snapshot(), and sectionSnapshot(calendar). All-day civil-date strings keep the existing Date.parse UTC convention; this is not an all-day-zone redesign. Adds tests/calendar-event-order.test.ts covering all three read paths with mixed-offset timed events; it fails against the old string sort and passes with the instant comparison. Signed-off-by: Charan Rathore <180254320+charan-rathore@users.noreply.github.com> --- apps/server/src/workspace.ts | 8 +++-- tests/calendar-event-order.test.ts | 52 ++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) create mode 100644 tests/calendar-event-order.test.ts diff --git a/apps/server/src/workspace.ts b/apps/server/src/workspace.ts index 1bbe4ae51..02965276e 100644 --- a/apps/server/src/workspace.ts +++ b/apps/server/src/workspace.ts @@ -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((a, b) => Date.parse(a.start) - Date.parse(b.start)); 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((a, b) => Date.parse(a.start) - Date.parse(b.start)), files: await this.files.list(owner), browsers: await this.db.list(owner, "browsers"), actions: await this.db.list(owner, "actions"), @@ -385,7 +385,9 @@ 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((a, b) => Date.parse(a.start) - Date.parse(b.start)) + : undefined, files, }; } diff --git a/tests/calendar-event-order.test.ts b/tests/calendar-event-order.test.ts new file mode 100644 index 000000000..b658f477e --- /dev/null +++ b/tests/calendar-event-order.test.ts @@ -0,0 +1,52 @@ +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(); + } +}); From 8c7ad0ddfe2484f0dab4f4a3afda93f2dbf2d074 Mon Sep 17 00:00:00 2001 From: Charan Rathore <180254320+charan-rathore@users.noreply.github.com> Date: Thu, 8 Oct 2026 21:33:53 +0530 Subject: [PATCH 2/2] fix: order calendar events by instant in the app and server, with all-day and fallback handling The mobile app re-sorted events by their start text (Calendar and Today screens), undoing the server order. A date-only all-day start parsed as midnight UTC, placing it inside the previous day west of Greenwich and after same-morning events east of it. An unparseable start made Date.parse comparisons inconsistent. Add compareEventStart to packages/domain: timed events order by instant, all-day (or date-only) events anchor to the start of their day in their own time zone, and an unparseable start falls back to the text comparison so the order stays total. Use it in all three server sort sites and both app screens; move zonedInstant and startOfZonedDay into the domain package so both sides share them. Signed-off-by: Charan Rathore <180254320+charan-rathore@users.noreply.github.com> --- apps/mobile/src/date-time.ts | 53 ++---------- apps/mobile/src/screens.tsx | 8 +- apps/server/src/workspace.ts | 10 +-- packages/domain/src/date-time.ts | 79 ++++++++++++++++++ tests/calendar-event-order.test.ts | 128 +++++++++++++++++++++++++++++ 5 files changed, 221 insertions(+), 57 deletions(-) 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 02965276e..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) => Date.parse(a.start) - Date.parse(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) => Date.parse(a.start) - Date.parse(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,9 +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) => Date.parse(a.start) - Date.parse(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 index b658f477e..9cade87bf 100644 --- a/tests/calendar-event-order.test.ts +++ b/tests/calendar-event-order.test.ts @@ -50,3 +50,131 @@ test("calendar reads sort timed events by instant rather than offset text", asyn 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"], + ); +});