type Appointment = { id: string; startsAt: string }; // ISO 8601, UTC
function localDateKey(iso: string, timeZone: string): string {
// en-CA yields YYYY-MM-DD
return new Intl.DateTimeFormat("en-CA", {
timeZone, year: "numeric", month: "2-digit", day: "2-digit",
}).format(new Date(iso));
}
export function groupByLocalDate(
appts: Appointment[],
timeZone: string,
): Map<string, Appointment[]> {
const groups = new Map<string, Appointment[]>();
for (const a of appts) {
const key = localDateKey(a.startsAt, timeZone);
(groups.get(key) ?? groups.set(key, []).get(key)!).push(a);
}
return groups;
}
Time zones: Store instants in UTC; pass the user's IANA zone explicitly (never rely on the server's local zone). Intl handles DST and offset history. Avoid toISOString().slice(0,10), which always gives the UTC date.
Midnight test:
test("23:30 Pacific stays on the 15th", () => {
const a = { id: "1", startsAt: "2024-03-16T07:30:00Z" }; // 23:30 PDT, Mar 15
const g = groupByLocalDate([a], "America/Los_Angeles");
expect([...g.keys()]).toEqual(["2024-03-15"]);
expect(groupByLocalDate([a], "UTC").has("2024-03-16")).toBe(true);
});