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
82 changes: 82 additions & 0 deletions packages/domain/src/date-time.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,85 @@ 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. Unparseable starts form a separate group
* after valid instants, ordered by text within that group. Mixing text and
* chronological comparisons across groups would make the order non-transitive.
*/
export function compareEventStart(a: EventStartLike, b: EventStartLike): number {
const aMs = eventStartMs(a);
const bMs = eventStartMs(b);
const aValid = Number.isFinite(aMs);
const bValid = Number.isFinite(bMs);
if (aValid !== bValid) return aValid ? -1 : 1;
if (aValid) return aMs - bMs;
return a.start.localeCompare(b.start);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Give invalid dates a separate, consistent sort position. Mixing chronological comparison for two valid starts with lexical comparison whenever either is invalid creates a cycle: A=2026-10-10T09:00:00-07:00, B=2026-10-10T12:bad, C=2026-10-10T14:00:00+00:00 yields A<B, B<C, and C<A. Reproduced on this head: sorting [A,B,C] leaves the 16:00Z event before the 14:00Z event, whereas another input permutation produces a different order. Partition valid versus invalid starts consistently (then compare instants or text within each group), and add mixed-invalid permutation coverage.

}
52 changes: 52 additions & 0 deletions tests/calendar-comparator.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { compareEventStart, type EventStartLike } from "../packages/domain/src/date-time.ts";

function permutations<T>(values: T[]): T[][] {
if (values.length === 0) return [[]];
return values.flatMap((value, index) =>
permutations(values.filter((_, i) => i !== index)).map((rest) => [value, ...rest]),
);
}

test("mixed invalid dates cannot reorder valid instants across input permutations", () => {
const later = { start: "2026-10-10T09:00:00-07:00" };
const invalid = { start: "2026-10-10T12:bad" };
const earlier = { start: "2026-10-10T14:00:00+00:00" };
for (const input of permutations([later, invalid, earlier])) {
assert.deepEqual([...input].sort(compareEventStart), [earlier, later, invalid]);
}
});

test("event start ordering is antisymmetric and transitive across valid and invalid groups", () => {
const values: EventStartLike[] = [
{ start: "2026-10-10T09:00:00-07:00" },
{ start: "2026-10-10T12:bad" },
{ start: "2026-10-10T14:00:00+00:00" },
{ start: "", allDay: true },
{ start: "2026-10-10", allDay: true, timeZone: "Pacific/Auckland" },
{ start: "2026-10-10", allDay: true, timeZone: "invalid-zone" },
{ start: "not a date" },
];
const sign = (value: number) => (value < 0 ? -1 : value > 0 ? 1 : 0);
for (const a of values) {
assert.equal(compareEventStart(a, a), 0);
for (const b of values) {
assert.equal(sign(compareEventStart(a, b)) + sign(compareEventStart(b, a)), 0);
for (const c of values) {
if (compareEventStart(a, b) <= 0 && compareEventStart(b, c) <= 0) {
assert.ok(compareEventStart(a, c) <= 0);
}
}
}
}
});

test("invalid starts retain a deterministic text order after valid events", () => {
const valid = { start: "2026-10-10T14:00:00Z" };
const invalid = [{ start: "!invalid" }, { start: "" }, { start: "garbage" }];
const expected = [valid, ...[...invalid].sort((a, b) => a.start.localeCompare(b.start))];
for (const input of permutations([valid, ...invalid])) {
assert.deepEqual([...input].sort(compareEventStart), expected);
}
});
Loading
Loading