Skip to content
Open
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
53 changes: 7 additions & 46 deletions apps/mobile/src/date-time.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
8 changes: 3 additions & 5 deletions apps/mobile/src/screens.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
<View style={{ gap: 25 }}>
<View
Expand Down Expand Up @@ -652,7 +650,7 @@ export function CalendarScreen() {
return api.request<CalendarEvent[]>(`/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) {
Expand Down
8 changes: 4 additions & 4 deletions apps/server/src/workspace.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<BrowserSession>(owner, "browsers"),
actions: await this.db.list<ActionProposal>(owner, "actions"),
Expand Down Expand Up @@ -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,
};
}
Expand Down
79 changes: 79 additions & 0 deletions packages/domain/src/date-time.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
180 changes: 180 additions & 0 deletions tests/calendar-event-order.test.ts
Original file line number Diff line number Diff line change
@@ -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 = <T extends { start: string }>(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"],
);
});
Loading