From d47f26da209a14cf0af085e1d5a420c2d018d68c Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:51:05 +0200 Subject: [PATCH] fix: show invitation times in your time zone #1252 --- .../email/calendar-invitation-banner.tsx | 56 ++--- lib/__tests__/calendar-invitation.test.ts | 101 +++++++- lib/__tests__/calendar-utils.test.ts | 27 +++ lib/calendar-invitation.ts | 220 ++++++++++++++---- lib/calendar-utils.ts | 10 +- lib/timezone.ts | 18 ++ 6 files changed, 352 insertions(+), 80 deletions(-) diff --git a/components/email/calendar-invitation-banner.tsx b/components/email/calendar-invitation-banner.tsx index 395beac22..cbc1a18b2 100644 --- a/components/email/calendar-invitation-banner.tsx +++ b/components/email/calendar-invitation-banner.tsx @@ -33,12 +33,15 @@ import { getInvitationActorSummary, getInvitationTrustAssessment, formatEventSummary, + formatInvitationSchedule, findParticipantByEmail, extractMethodFromRawIcs, + type EventSummary, type InvitationMethod, type InvitationTrustAssessment, } from '@/lib/calendar-invitation'; import { cn } from '@/lib/utils'; +import { getEventStartDate } from '@/lib/calendar-utils'; import { sanitizeColor } from '@/components/calendar/event-card'; import { RecipientPopover } from './recipient-popover'; @@ -260,7 +263,7 @@ function buildInvitationChangeItems( t: ReturnType, currentEvent: Partial | null, proposedEvent: Partial | null, - formatDateTime: (dateStr: string | null) => string, + formatSchedule: (summary: EventSummary) => string, ): InvitationChangeItem[] { if (!currentEvent || !proposedEvent) { return []; @@ -279,10 +282,10 @@ function buildInvitationChangeItems( } const currentSchedule = currentSummary.start - ? `${formatDateTime(currentSummary.start)}${currentSummary.end ? ` - ${formatDateTime(currentSummary.end)}` : ''}` + ? formatSchedule(currentSummary) : t('change_empty'); const proposedSchedule = proposedSummary.start - ? `${formatDateTime(proposedSummary.start)}${proposedSummary.end ? ` - ${formatDateTime(proposedSummary.end)}` : ''}` + ? formatSchedule(proposedSummary) : t('change_empty'); if (currentSchedule !== proposedSchedule && proposedSummary.start) { changes.push({ @@ -667,13 +670,14 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp }; const handleViewInCalendar = () => { - const targetDate = existingEvent?.utcStart - || existingEvent?.start - || parsedEvent?.utcStart - || parsedEvent?.start; - - if (targetDate) { - const parsedDate = new Date(targetDate); + const target = existingEvent?.start ? existingEvent : parsedEvent; + if (target?.start) { + const parsedDate = getEventStartDate({ + start: target.start, + utcStart: target.utcStart ?? null, + showWithoutTime: target.showWithoutTime ?? false, + timeZone: target.timeZone, + }); if (!Number.isNaN(parsedDate.getTime())) { setSelectedDate(parsedDate); } @@ -708,31 +712,14 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp } }; - const isAllDayEvent = parsedEvent?.showWithoutTime ?? false; - - const formatDateTime = (dateStr: string | null) => { - if (!dateStr) return ''; - const date = new Date(dateStr); - if (isNaN(date.getTime())) return dateStr; - if (isAllDayEvent) { - return format.dateTime(date, { - weekday: 'short', - month: 'short', - day: 'numeric', - }); - } - return format.dateTime(date, { - weekday: 'short', - month: 'short', - day: 'numeric', - hour: 'numeric', - minute: '2-digit', - hour12: timeFormat === '12h', - }); - }; + const formatSchedule = (eventSummary: EventSummary) => formatInvitationSchedule( + eventSummary, + (date, options) => format.dateTime(date, options), + timeFormat === '12h', + ); const proposedChanges = method === 'counter' && existingEvent && parsedEvent - ? buildInvitationChangeItems(t, existingEvent, parsedEvent, formatDateTime) + ? buildInvitationChangeItems(t, existingEvent, parsedEvent, formatSchedule) : []; const canApplyProposal = Boolean( client @@ -887,8 +874,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp - {formatDateTime(summary.start)} - {summary.end && ` – ${formatDateTime(summary.end)}`} + {formatSchedule(summary)} )} diff --git a/lib/__tests__/calendar-invitation.test.ts b/lib/__tests__/calendar-invitation.test.ts index 68813a274..7b548e036 100644 --- a/lib/__tests__/calendar-invitation.test.ts +++ b/lib/__tests__/calendar-invitation.test.ts @@ -1,10 +1,11 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { findCalendarAttachment, getInvitationActorSummary, getInvitationMethod, getInvitationTrustAssessment, formatEventSummary, + formatInvitationSchedule, findParticipantByEmail, } from '../calendar-invitation'; import type { Email, CalendarEvent, CalendarParticipant } from '@/lib/jmap/types'; @@ -357,7 +358,7 @@ describe('formatEventSummary', () => { showWithoutTime: true, }); expect(summary.start).toBe('2026-03-16T00:00:00'); - expect(summary.end).toBe('2026-03-17T00:00:00Z'); + expect(summary.end).toBe('2026-03-17T00:00:00'); expect(summary.isAllDay).toBe(true); }); @@ -532,3 +533,99 @@ describe('findParticipantByEmail', () => { expect(findParticipantByEmail(event, '')).toBeNull(); }); }); + +describe('invitation times across time zones (#1252)', () => { + // A process zone that differs from both the organizer's and the viewer's, + // so reading a LocalDateTime as browser-local would show up as a shift. + let originalTZ: string | undefined; + beforeAll(() => { + originalTZ = process.env.TZ; + process.env.TZ = 'America/New_York'; + }); + afterAll(() => { + if (originalTZ === undefined) delete process.env.TZ; + else process.env.TZ = originalTZ; + }); + + // What next-intl's format.dateTime does with the provider's display zone. + const formatIn = (timeZone: string) => (date: Date, options: Intl.DateTimeFormatOptions) => + new Intl.DateTimeFormat('en-US', { timeZone, ...options }).format(date); + + // CalendarEvent/parse output for the Exchange invite in the issue. + const londonInvite: Partial = { + title: 'Sync', + start: '2026-10-20T15:00:00', + timeZone: 'Europe/London', + duration: 'PT25M', + }; + + it('resolves start + timeZone to a real instant when there is no utcStart', () => { + const summary = formatEventSummary(londonInvite); + expect(summary.start).toBe('2026-10-20T14:00:00.000Z'); + expect(summary.end).toBe('2026-10-20T14:25:00.000Z'); + expect(summary.isFloating).toBe(false); + }); + + it('shows the invite in the viewer zone with a zone label', () => { + const summary = formatEventSummary(londonInvite); + expect(formatInvitationSchedule(summary, formatIn('Europe/Warsaw'), false)) + .toBe('Tue, Oct 20, 16:00 – Tue, Oct 20, 16:25 GMT+2'); + expect(formatInvitationSchedule(summary, formatIn('America/New_York'), true)) + .toBe('Tue, Oct 20, 10:00 AM – Tue, Oct 20, 10:25 AM EDT'); + }); + + it('treats a UTC start as UTC', () => { + const summary = formatEventSummary({ start: '2026-10-20T14:00:00', timeZone: 'Etc/UTC', duration: 'PT1H' }); + expect(summary.start).toBe('2026-10-20T14:00:00.000Z'); + expect(summary.end).toBe('2026-10-20T15:00:00.000Z'); + }); + + it('keeps nominal days in the event zone across a DST change', () => { + // Oct 25 2026 is the end of BST: 15:00 the next day is 15:00 GMT. + const summary = formatEventSummary({ start: '2026-10-24T15:00:00', timeZone: 'Europe/London', duration: 'P1DT1H' }); + expect(summary.start).toBe('2026-10-24T14:00:00.000Z'); + expect(summary.end).toBe('2026-10-25T16:00:00.000Z'); + }); + + it('shows floating times as the same wall clock everywhere, without a zone label', () => { + const summary = formatEventSummary({ start: '2026-10-20T15:00:00', timeZone: null, duration: 'PT30M' }); + expect(summary.isFloating).toBe(true); + expect(formatInvitationSchedule(summary, formatIn('Asia/Tokyo'), false)) + .toBe('Tue, Oct 20, 15:00 – Tue, Oct 20, 15:30'); + }); + + it('does not shift all-day dates in zones west or east of UTC', () => { + const oneDay = formatEventSummary({ start: '2026-03-16T00:00:00', duration: 'P1D', showWithoutTime: true, timeZone: 'Europe/London' }); + expect(formatInvitationSchedule(oneDay, formatIn('America/Los_Angeles'), false)).toBe('Mon, Mar 16'); + expect(formatInvitationSchedule(oneDay, formatIn('Pacific/Auckland'), false)).toBe('Mon, Mar 16'); + + const threeDays = formatEventSummary({ + start: '2026-03-16T00:00:00', + showWithoutTime: true, + utcStart: '2026-03-16T00:00:00Z', + utcEnd: '2026-03-19T00:00:00Z', + }); + expect(formatInvitationSchedule(threeDays, formatIn('America/Los_Angeles'), false)).toBe('Mon, Mar 16 – Wed, Mar 18'); + }); + + it('shows only the start for a zero-duration event', () => { + const summary = formatEventSummary({ + ...londonInvite, + duration: 'PT0S', + utcStart: '2026-10-20T14:00:00Z', + utcEnd: '2026-10-20T14:00:00Z', + }); + expect(formatInvitationSchedule(summary, formatIn('Europe/Warsaw'), false)) + .toBe('Tue, Oct 20, 16:00 GMT+2'); + }); + + it('still prefers the server utcStart/utcEnd when present', () => { + const summary = formatEventSummary({ + ...londonInvite, + utcStart: '2026-10-20T14:00:00Z', + utcEnd: '2026-10-20T14:25:00Z', + }); + expect(formatInvitationSchedule(summary, formatIn('UTC'), false)) + .toBe('Tue, Oct 20, 14:00 – Tue, Oct 20, 14:25 UTC'); + }); +}); diff --git a/lib/__tests__/calendar-utils.test.ts b/lib/__tests__/calendar-utils.test.ts index 7a90f367f..c34990532 100644 --- a/lib/__tests__/calendar-utils.test.ts +++ b/lib/__tests__/calendar-utils.test.ts @@ -126,6 +126,33 @@ describe('calendar-utils all-day handling', () => { expect(getEventEndDate(event).toISOString()).toBe('2026-03-15T14:00:00.000Z'); }); + it('reads start in the event timezone when utcStart is missing (CalendarEvent/parse, #1252)', () => { + const event = makeEvent({ + start: '2026-10-20T15:00:00', + duration: 'PT25M', + timeZone: 'Europe/London', + showWithoutTime: false, + utcStart: undefined, + utcEnd: undefined, + }); + + expect(getEventStartDate(event).toISOString()).toBe('2026-10-20T14:00:00.000Z'); + expect(getEventEndDate(event).toISOString()).toBe('2026-10-20T14:25:00.000Z'); + }); + + it('keeps floating start times as wall clock when utcStart is missing', () => { + const event = makeEvent({ + start: '2026-10-20T15:00:00', + duration: 'PT1H', + timeZone: null, + showWithoutTime: false, + utcStart: undefined, + utcEnd: undefined, + }); + + expectLocalDateParts(getEventStartDate(event), 2026, 10, 20, 15); + }); + it('clips timed multi-day events to the visible day bounds', () => { const event = makeEvent({ start: '2026-03-14T22:00:00', diff --git a/lib/calendar-invitation.ts b/lib/calendar-invitation.ts index 009ea1249..a49766e16 100644 --- a/lib/calendar-invitation.ts +++ b/lib/calendar-invitation.ts @@ -1,6 +1,6 @@ -import { parseISO } from 'date-fns'; import type { Email, Attachment, CalendarEvent, CalendarParticipant, EmailBodyPart } from '@/lib/jmap/types'; import { normalizeCalendarEventLike } from '@/lib/calendar-event-normalization'; +import { fromWallClock, isValidTimeZone, parseLocalDateTime, type WallClock } from '@/lib/timezone'; export type InvitationMethod = | 'publish' @@ -450,6 +450,8 @@ export interface EventSummary { start: string | null; end: string | null; isAllDay: boolean; + /** Timed event without a usable time zone: start/end are wall-clock times to show as they are. */ + isFloating: boolean; location: string | null; organizer: string | null; organizerEmail: string | null; @@ -498,25 +500,14 @@ export function formatEventSummary(event: Partial): EventSummary } const isAllDay = normalizedEvent.showWithoutTime ?? false; - - let end: string | null = null; - if (normalizedEvent.utcEnd) { - end = normalizedEvent.utcEnd; - } else if (normalizedEvent.start && normalizedEvent.duration) { - end = addDurationToDate(normalizedEvent.start, normalizedEvent.duration, normalizedEvent.timeZone); - } - - // For all-day events, prefer the local start (no timezone) to avoid - // UTC conversion shifting the displayed date in non-UTC timezones. - const start = isAllDay - ? (normalizedEvent.start || null) - : (normalizedEvent.utcStart || normalizedEvent.start || null); + const { start, end, isFloating } = resolveSummaryTimes(normalizedEvent, isAllDay); return { title: normalizedEvent.title || '', start, end, isAllDay, + isFloating, location, organizer, organizerEmail, @@ -524,41 +515,188 @@ export function formatEventSummary(event: Partial): EventSummary }; } -function addDurationToDate(start: string, duration: string, _timeZone?: string | null): string | null { +const HAS_UTC_OFFSET = /(?:Z|[+-]\d{2}:?\d{2})$/i; + +/** Wall-clock arithmetic on plain fields, independent of any zone. */ +function shiftWallClock(w: WallClock, days: number, seconds: number): WallClock { + const d = new Date(0); + d.setUTCFullYear(w.year, w.month - 1, w.day + days); + d.setUTCHours(w.hour, w.minute, w.second + seconds, 0); + return { + year: d.getUTCFullYear(), + month: d.getUTCMonth() + 1, + day: d.getUTCDate(), + hour: d.getUTCHours(), + minute: d.getUTCMinutes(), + second: d.getUTCSeconds(), + }; +} + +function formatLocalDateTime(w: WallClock): string { + const pad = (n: number, len = 2) => String(n).padStart(len, '0'); + return `${pad(w.year, 4)}-${pad(w.month)}-${pad(w.day)}T${pad(w.hour)}:${pad(w.minute)}:${pad(w.second)}`; +} + +/** Days (nominal) and seconds (exact) of an ISO 8601 / JSCalendar duration. */ +function parseDurationParts(duration: string): { days: number; seconds: number } | null { const match = duration.match(/^P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/); if (!match) return null; + return { + days: Number(match[1] || 0) * 7 + Number(match[2] || 0), + seconds: Number(match[3] || 0) * 3600 + Number(match[4] || 0) * 60 + Number(match[5] || 0), + }; +} - const weeks = parseInt(match[1] || '0'); - const days = parseInt(match[2] || '0') + weeks * 7; - const hours = parseInt(match[3] || '0'); - const minutes = parseInt(match[4] || '0'); - const seconds = parseInt(match[5] || '0'); +function isValidInstant(value: string | null | undefined): value is string { + return typeof value === 'string' && HAS_UTC_OFFSET.test(value) && !Number.isNaN(new Date(value).getTime()); +} - const date = parseISO(start); - if (isNaN(date.getTime())) return null; +/** + * Start and end of an event for display. Timed events come back as UTC + * instants (ISO strings ending in `Z`) so they can be shown in the user's + * zone; all-day and floating events come back as LocalDateTime strings that + * must be shown as the wall clock they are (#1252). + * + * CalendarEvent/parse returns `start` as a LocalDateTime in `timeZone` and + * no `utcStart`, so reading `start` as an instant showed the organizer's + * wall clock as if it were the user's. + */ +function resolveSummaryTimes( + event: Partial, + isAllDay: boolean, +): { start: string | null; end: string | null; isFloating: boolean } { + const duration = event.duration ? parseDurationParts(event.duration) : null; + const localStart = event.start ? parseLocalDateTime(event.start) : null; - const isUTC = start.endsWith('Z') || start.includes('+'); - - if (isUTC) { - date.setUTCDate(date.getUTCDate() + days); - date.setUTCHours(date.getUTCHours() + hours); - date.setUTCMinutes(date.getUTCMinutes() + minutes); - date.setUTCSeconds(date.getUTCSeconds() + seconds); - return date.toISOString(); + if (isAllDay) { + // The date is the same everywhere: never convert it through a zone. + if (!localStart) return { start: event.start || null, end: null, isFloating: false }; + let end: string | null = null; + if (duration) { + end = formatLocalDateTime(shiftWallClock(localStart, duration.days, duration.seconds)); + } else if (event.utcEnd) { + const endDate = parseLocalDateTime(event.utcEnd.slice(0, 10)); + if (endDate) end = formatLocalDateTime(endDate); + } + return { start: formatLocalDateTime(localStart), end, isFloating: false }; } - date.setDate(date.getDate() + days); - date.setHours(date.getHours() + hours); - date.setMinutes(date.getMinutes() + minutes); - date.setSeconds(date.getSeconds() + seconds); + const addExact = (instant: Date, days: number, seconds: number) => + new Date(instant.getTime() + (days * 86400 + seconds) * 1000).toISOString(); - const y = date.getFullYear(); - const m = String(date.getMonth() + 1).padStart(2, '0'); - const d = String(date.getDate()).padStart(2, '0'); - const h = String(date.getHours()).padStart(2, '0'); - const min = String(date.getMinutes()).padStart(2, '0'); - const s = String(date.getSeconds()).padStart(2, '0'); - return `${y}-${m}-${d}T${h}:${min}:${s}`; + if (isValidInstant(event.utcStart)) { + const end = isValidInstant(event.utcEnd) + ? event.utcEnd + : duration ? addExact(new Date(event.utcStart), duration.days, duration.seconds) : null; + return { start: event.utcStart, end, isFloating: false }; + } + + if (isValidInstant(event.start)) { + const end = isValidInstant(event.utcEnd) + ? event.utcEnd + : duration ? addExact(new Date(event.start), duration.days, duration.seconds) : null; + return { start: new Date(event.start).toISOString(), end, isFloating: false }; + } + + if (!localStart) return { start: event.start || null, end: null, isFloating: false }; + + if (event.timeZone && isValidTimeZone(event.timeZone)) { + const timeZone = event.timeZone; + const startInstant = fromWallClock(localStart, timeZone); + let end: string | null = null; + if (isValidInstant(event.utcEnd)) { + end = event.utcEnd; + } else if (duration) { + // Days are nominal (same wall clock N days later in the event's zone), + // hours/minutes/seconds are exact. + const dayShifted = fromWallClock(shiftWallClock(localStart, duration.days, 0), timeZone); + end = addExact(dayShifted, 0, duration.seconds); + } + return { start: startInstant.toISOString(), end, isFloating: false }; + } + + // Floating time (no zone, or one we can't resolve): same wall clock everywhere. + const end = duration + ? formatLocalDateTime(shiftWallClock(localStart, duration.days, duration.seconds)) + : null; + return { start: formatLocalDateTime(localStart), end, isFloating: true }; +} + +export interface InvitationDisplayTime { + date: Date; + /** + * True when `date` carries a wall clock in its UTC fields (all-day or + * floating). Format it with `timeZone: 'UTC'` so it is not converted. + */ + wallClock: boolean; +} + +function toInvitationDisplayTime(value: string | null): InvitationDisplayTime | null { + if (!value) return null; + if (HAS_UTC_OFFSET.test(value)) { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : { date, wallClock: false }; + } + const w = parseLocalDateTime(value); + if (!w) return null; + const date = new Date(0); + date.setUTCFullYear(w.year, w.month - 1, w.day); + date.setUTCHours(w.hour, w.minute, w.second, 0); + return { date, wallClock: true }; +} + +/** + * What the invitation banner shows for an event summary. All-day events get + * their last day (the stored end is exclusive), or no end for a single day. + */ +export function getInvitationDisplayRange( + summary: Pick, +): { start: InvitationDisplayTime; end: InvitationDisplayTime | null } | null { + const start = toInvitationDisplayTime(summary.start); + if (!start) return null; + let end = toInvitationDisplayTime(summary.end); + if (end && summary.isAllDay) { + const lastDay = new Date(end.date.getTime()); + lastDay.setUTCDate(lastDay.getUTCDate() - 1); + end = lastDay.getTime() > start.date.getTime() ? { date: lastDay, wallClock: true } : null; + } else if (end && end.date.getTime() <= start.date.getTime()) { + // Zero-duration event (utcEnd == utcStart): no separate end to show. + end = null; + } + return { start, end }; +} + +/** Intl options limited to what next-intl's format.dateTime accepts. */ +export type InvitationDateFormatOptions = Pick< + Intl.DateTimeFormatOptions, + 'weekday' | 'month' | 'day' | 'hour' | 'minute' | 'hour12' | 'timeZone' +> & { + timeZoneName?: 'short' | 'long'; +}; + +/** + * The banner's "when" line. `formatDate` is next-intl's `format.dateTime` + * (which formats in the user's display zone) or an Intl equivalent. Timed + * events with a real zone get a zone label so it is clear which clock the + * time is on. + */ +export function formatInvitationSchedule( + summary: Pick, + formatDate: (date: Date, options: InvitationDateFormatOptions) => string, + hour12: boolean, +): string { + const range = getInvitationDisplayRange(summary); + if (!range) return summary.start ?? ''; + const base: InvitationDateFormatOptions = summary.isAllDay + ? { weekday: 'short', month: 'short', day: 'numeric' } + : { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12 }; + const formatPoint = (point: InvitationDisplayTime, last: boolean) => formatDate(point.date, { + ...base, + ...(point.wallClock ? { timeZone: 'UTC' } : {}), + ...(last && !point.wallClock && !summary.isAllDay ? { timeZoneName: 'short' } : {}), + }); + const start = formatPoint(range.start, !range.end); + return range.end ? `${start} – ${formatPoint(range.end, true)}` : start; } export function findParticipantByEmail( diff --git a/lib/calendar-utils.ts b/lib/calendar-utils.ts index ab9c0434f..a6d41eb66 100644 --- a/lib/calendar-utils.ts +++ b/lib/calendar-utils.ts @@ -1,7 +1,7 @@ import { addDays, differenceInCalendarDays, parseISO, startOfDay, subMilliseconds } from "date-fns"; import { parseDuration } from "@/components/calendar/event-card"; import type { CalendarEvent } from "@/lib/jmap/types"; -import { toDisplayDate } from "@/lib/timezone"; +import { fromWallClock, isValidTimeZone, parseLocalDateTime, toDisplayDate } from "@/lib/timezone"; export interface CalendarWeekSegment { event: CalendarEvent; @@ -30,7 +30,7 @@ export interface TimedEventLayout { * with `fromDisplayDate` before turning grid positions into instants. */ export function getEventStartDate( - event: Pick, + event: Pick & Partial>, ): Date { // Prefer utcStart for timed events but fall back to start if utcStart is // missing or unparseable - a malformed utcStart used to surface as an @@ -39,6 +39,12 @@ export function getEventStartDate( const utc = parseISO(event.utcStart); if (!isNaN(utc.getTime())) return toDisplayDate(utc); } + // CalendarEvent/parse (.ics import, invitations) returns no utcStart: read + // `start` as the wall clock in the event's own zone, not the user's (#1252). + if (!event.showWithoutTime && event.timeZone && isValidTimeZone(event.timeZone)) { + const wall = event.start ? parseLocalDateTime(event.start) : null; + if (wall) return toDisplayDate(fromWallClock(wall, event.timeZone)); + } return parseISO(event.start); } diff --git a/lib/timezone.ts b/lib/timezone.ts index ef27591f7..a1f7d34a8 100644 --- a/lib/timezone.ts +++ b/lib/timezone.ts @@ -89,6 +89,24 @@ export interface WallClock { second: number; } +/** + * Wall-clock fields of a JMAP LocalDateTime ("YYYY-MM-DDTHH:mm:ss", or a bare + * date). Returns null for anything else, including strings with `Z` or an + * offset, which are instants rather than wall clocks. + */ +export function parseLocalDateTime(value: string): WallClock | null { + const match = value.match(/^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2}))?)?$/); + if (!match) return null; + return { + year: Number(match[1]), + month: Number(match[2]), + day: Number(match[3]), + hour: Number(match[4] || 0), + minute: Number(match[5] || 0), + second: Number(match[6] || 0), + }; +} + // Constructing Intl.DateTimeFormat is expensive (ICU data lookup); cache one // formatter per zone - the calendar calls into this for every event. const wallClockFormatterCache = new Map();